Repository: Aleph-Alpha/ts-rs
Pull request #399 (2025-04-04 20:49:51)

Allow arbitrary expressions in `#[ts(export_to)]`
This is basically @NimonSour's #347 including tests and implemented like #398.  
Thanks @NimonSour for the idea & initial implementation!

Docs & Changelog are updated, export-logic remains the same, and unlike #398, everything is 100% backwards-compatible.

Note that this implementation only allows for expressions of whose type implements `Display`.  
That excludes `Path` and `PathBuf`. Some more work would need to be done to accept those as well.  
If that's needed in the meantime, calling `.display()` on a `Path`/`PathBuf` makes them `Display`, fixing the issue.



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 933fc3b..9c7b38e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,7 @@
 ### Features
 - The `#[ts(rename)]` attribute on structs, enums and variants now accepts any expression.  
   This makes it possible to, for example, rename a struct to the name of a module it is contained in using `#[ts(rename = module_path!().rsplit_once("::").unwrap().1)]`
+- The `#[ts(export_to)]` attribute on structs and enums now accepts any expression.
 - Added `#[ts(optional_fields)]` and `#[ts(optional_fields = nullable)]` attribute to structs, this attribute is equivalent to using the corresponding `#[ts(optional)]` or `#[ts(optional = nullable)]` on every field of the struct. ([#366](https://github.com/Aleph-Alpha/ts-rs/pull/366))
 
 ### Fixes
diff --git a/macros/src/attr/enum.rs b/macros/src/attr/enum.rs
index efd542d..11e83d3 100644
--- a/macros/src/attr/enum.rs
+++ b/macros/src/attr/enum.rs
@@ -16,7 +16,7 @@ pub struct EnumAttr {
     pub rename_all: Option<Inflection>,
     pub rename_all_fields: Option<Inflection>,
     pub rename: Option<Expr>,
-    pub export_to: Option<String>,
+    pub export_to: Option<Expr>,
     pub export: bool,
     pub docs: String,
     pub concrete: HashMap<Ident, Type>,
@@ -212,7 +212,7 @@ impl_parse! {
         "rename" => out.rename = Some(parse_assign_expr(input)?),
         "rename_all" => out.rename_all = Some(parse_assign_inflection(input)?),
         "rename_all_fields" => out.rename_all_fields = Some(parse_assign_inflection(input)?),
-        "export_to" => out.export_to = Some(parse_assign_str(input)?),
+        "export_to" => out.export_to = Some(parse_assign_expr(input)?),
         "export" => out.export = true,
         "tag" => out.tag = Some(parse_assign_str(input)?),
         "content" => out.content = Some(parse_assign_str(input)?),
diff --git a/macros/src/attr/struct.rs b/macros/src/attr/struct.rs
index bbf231d..ffcb52b 100644
--- a/macros/src/attr/struct.rs
+++ b/macros/src/attr/struct.rs
@@ -18,7 +18,7 @@ pub struct StructAttr {
     pub type_override: Option<String>,
     pub rename_all: Option<Inflection>,
     pub rename: Option<Expr>,
-    pub export_to: Option<String>,
+    pub export_to: Option<Expr>,
     pub export: bool,
     pub tag: Option<String>,
     pub docs: String,
@@ -163,7 +163,7 @@ impl_parse! {
         "rename_all" => out.rename_all = Some(parse_assign_inflection(input)?),
         "tag" => out.tag = Some(parse_assign_str(input)?),
         "export" => out.export = true,
-        "export_to" => out.export_to = Some(parse_assign_str(input)?),
+        "export_to" => out.export_to = Some(parse_assign_expr(input)?),
         "concrete" => out.concrete = parse_concrete(input)?,
         "bound" => out.bound = Some(parse_bound(input)?),
         "optional_fields" => out.optional_fields = parse_optional(input)?,
diff --git a/macros/src/lib.rs b/macros/src/lib.rs
index 1862caf..8f4eecc 100644
--- a/macros/src/lib.rs
+++ b/macros/src/lib.rs
@@ -30,7 +30,7 @@ struct DerivedTS {
     bound: Option<Vec<WherePredicate>>,
 
     export: bool,
-    export_to: Option<String>,
+    export_to: Option<Expr>,
 }
 
 impl DerivedTS {
@@ -42,14 +42,18 @@ impl DerivedTS {
         let output_path_fn = {
             let ts_name = &self.ts_name;
             // expression of type `String` containing the file path
-            let path_string = match self.export_to.as_deref() {
-                Some(dirname) if dirname.ends_with('/') => {
-                    quote![format!("{}{}.ts", #dirname, #ts_name)]
-                }
-                Some(filename) => quote![#filename.to_owned()],
-                None => {
-                    quote![format!("{}.ts", #ts_name)]
-                }
+            let path_string = match &self.export_to {
+                Some(dir_or_file) => quote![{
+                    let dir_or_file = format!("{}", #dir_or_file);
+                    if dir_or_file.ends_with('/') {
+                        // export into directory
+                        format!("{dir_or_file}{}.ts", #ts_name)
+                    } else {
+                        // export into provided file
+                        format!("{dir_or_file}")
+                    }
+                }],
+                None => quote![format!("{}.ts", #ts_name)],
             };
 
             quote! {
diff --git a/ts-rs/src/lib.rs b/ts-rs/src/lib.rs
index 49e65fb..9cc9303 100644
--- a/ts-rs/src/lib.rs
+++ b/ts-rs/src/lib.rs
@@ -203,7 +203,8 @@ mod tokio;
 ///   Specifies where the type should be exported to. Defaults to `<name>.ts`.  
 ///   The path given to the `export_to` attribute is relative to the `TS_RS_EXPORT_DIR` environment variable,
 ///   or, if `TS_RS_EXPORT_DIR` is not set, to `./bindings`  
-///   If the provided path ends in a trailing `/`, it is interpreted as a directory.   
+///   If the provided path ends in a trailing `/`, it is interpreted as a directory.  
+///   This attribute also accepts arbitrary expressions.  
 ///   Note that you need to add the `export` attribute as well, in order to generate a test which exports the type.
 ///   <br/><br/>
 ///


Test patch:
diff --git a/ts-rs/tests/integration/export_to.rs b/ts-rs/tests/integration/export_to.rs
new file mode 100644
index 0000000..75fd48b
--- /dev/null
+++ b/ts-rs/tests/integration/export_to.rs
@@ -0,0 +1,100 @@
+use std::path::Path;
+use ts_rs::TS;
+
+#[derive(TS)]
+#[ts(export, export_to = "export_to/with_str_to_file.ts")]
+struct WithStrToFile;
+
+#[derive(TS)]
+#[ts(export, export_to = "export_to/")]
+struct WithStrToDir;
+
+// --
+
+#[derive(TS)]
+#[ts(export, export_to = &"export_to/with_str_ref_to_file.ts")]
+struct WithStrRefToFile;
+
+#[derive(TS)]
+#[ts(export, export_to = &"export_to/")]
+struct WithStrRefToDir;
+
+// --
+
+#[derive(TS)]
+#[ts(export, export_to = format!("export_to/with_string_to_file.ts"))]
+struct WithStringToFile;
+
+#[derive(TS)]
+#[ts(export, export_to = format!("export_to/"))]
+struct WithStringToDir;
+
+// --
+
+#[derive(TS)]
+#[ts(export, export_to = &format!("export_to/with_string_ref_to_file.ts"))]
+struct WithStringRefToFile;
+
+#[derive(TS)]
+#[ts(export, export_to = &format!("export_to/"))]
+struct WithStringRefToDir;
+
+// --
+
+#[derive(TS)]
+#[ts(export, export_to = {
+    let dir = WithStrToFile::default_output_path().unwrap();
+    let dir = dir.parent().unwrap();
+    let file = dir.join("to_absolute_file_path.ts");
+    let file = std::path::absolute(file).unwrap();
+    file.display().to_string()
+})]
+struct ToAbsoluteFilePath(WithStrToDir, WithStrToFile);
+
+#[derive(TS)]
+#[ts(export, export_to = {
+    let dir = WithStrToFile::default_output_path().unwrap();
+    let dir = dir.parent().unwrap();
+    let dir = std::path::absolute(dir).unwrap();
+    let dir = dir.display();
+    format!("{dir}/")
+})]
+struct ToAbsoluteDirPath(WithStrToDir, WithStrToFile, ToAbsoluteFilePath);
+
+// --
+
+#[test]
+#[cfg(test)]
+fn check_export_complete() {
+    export_bindings_withstrtofile();
+    export_bindings_withstrtodir();
+    export_bindings_withstrreftofile();
+    export_bindings_withstrreftodir();
+    export_bindings_withstringtofile();
+    export_bindings_withstringtodir();
+    export_bindings_withstringreftofile();
+    export_bindings_withstringreftodir();
+    export_bindings_toabsolutefilepath();
+    export_bindings_toabsolutedirpath();
+
+    let files = [
+        "with_str_to_file.ts",
+        "WithStrToDir.ts",
+        "with_str_ref_to_file.ts",
+        "WithStrRefToDir.ts",
+        "with_string_to_file.ts",
+        "WithStringToDir.ts",
+        "with_string_ref_to_file.ts",
+        "WithStringRefToDir.ts",
+        "to_absolute_file_path.ts",
+        "ToAbsoluteDirPath.ts",
+    ];
+
+    let dir = std::env::var("TS_RS_EXPORT_DIR").unwrap_or_else(|_| "./bindings".to_owned());
+    let dir = Path::new(&dir).join("export_to");
+
+    files
+        .iter()
+        .map(|file| dir.join(file))
+        .for_each(|file| assert!(file.is_file(), "{file:?}"));
+}
diff --git a/ts-rs/tests/integration/main.rs b/ts-rs/tests/integration/main.rs
index c473395..452e13b 100644
--- a/ts-rs/tests/integration/main.rs
+++ b/ts-rs/tests/integration/main.rs
@@ -12,6 +12,7 @@ mod enum_flattening_nested;
 mod enum_struct_rename_all;
 mod enum_variant_annotation;
 mod export_manually;
+mod export_to;
 mod field_rename;
 mod flatten;
 mod generic_fields;