Repository: Aleph-Alpha/ts-rs
Pull request #250 (2024-03-06 17:21:19)

Make `#[ts(export_to = "...")]` relative to `TS_RS_EXPORT_DIR`
As discussed in #245, when using both `TS_RS_EXPORT_DIR` and `#[ts(export_to = "...")]`, the `export_to` path should be relative to `TS_RS_EXPORT_DIR` instead of the project's `Cargo.toml`


Patch:
diff --git a/macros/src/lib.rs b/macros/src/lib.rs
index afb24c2..09cd71d 100644
--- a/macros/src/lib.rs
+++ b/macros/src/lib.rs
@@ -30,26 +30,22 @@ struct DerivedTS {
 
 impl DerivedTS {
     fn into_impl(mut self, rust_ty: Ident, generics: Generics) -> TokenStream {
-        let mut get_export_to = quote! {};
-        let export_to = match &self.export_to {
-            Some(dirname) if dirname.ends_with('/') => {
-                format!("{}{}.ts", dirname, self.ts_name)
-            }
-            Some(filename) => filename.clone(),
-            None => {
-                get_export_to = quote! {
-                    fn get_export_to() -> Option<String> {
-                        ts_rs::__private::get_export_to_path::<Self>()
-                    }
-                };
-                format!("bindings/{}.ts", self.ts_name)
-            }
-        };
-
         let export = self
             .export
             .then(|| self.generate_export_test(&rust_ty, &generics));
 
+        let export_to = {
+            let path = match self.export_to.as_deref() {
+                Some(dirname) if dirname.ends_with('/') => format!("{}{}.ts", dirname, self.ts_name),
+                Some(filename) => filename.to_owned(),
+                None => format!("bindings/{}.ts", self.ts_name),
+            };
+
+            quote! {
+                const EXPORT_TO: Option<&'static str> = Some(#path);
+            }
+        };
+
         let docs = match &*self.docs {
             "" => None,
             docs => Some(quote!(const DOCS: Option<&'static str> = Some(#docs);)),
@@ -67,13 +63,12 @@ impl DerivedTS {
         quote! {
             #impl_start {
                 #assoc_type
-                const EXPORT_TO: Option<&'static str> = Some(#export_to);
+                #export_to
 
                 fn ident() -> String {
                     #ident.to_owned()
                 }
 
-                #get_export_to
                 #docs
                 #name
                 #decl
diff --git a/ts-rs/src/export.rs b/ts-rs/src/export.rs
index eeb6078..0dae793 100644
--- a/ts-rs/src/export.rs
+++ b/ts-rs/src/export.rs
@@ -5,7 +5,7 @@ use std::{
     collections::BTreeMap,
     fmt::Write,
     path::{Component, Path, PathBuf},
-    sync::{Mutex, OnceLock},
+    sync::Mutex,
 };
 
 use thiserror::Error;
@@ -87,7 +87,7 @@ mod recursive_export {
 /// Export `T` to the file specified by the `#[ts(export_to = ..)]` attribute
 pub(crate) fn export_type<T: TS + ?Sized + 'static>() -> Result<(), ExportError> {
     let path = output_path::<T>()?;
-    export_type_to::<T, _>(&path)
+    export_type_to::<T, _>(path::absolute(path)?)
 }
 
 /// Export `T` to the file specified by the `path` argument.
@@ -124,30 +124,6 @@ pub(crate) fn export_type_to<T: TS + ?Sized + 'static, P: AsRef<Path>>(
     Ok(())
 }
 
-#[doc(hidden)]
-pub mod __private {
-    use super::*;
-
-    const EXPORT_DIR_ENV_VAR: &str = "TS_RS_EXPORT_DIR";
-    fn provided_default_dir() -> Option<&'static str> {
-        static EXPORT_TO: OnceLock<Option<String>> = OnceLock::new();
-        EXPORT_TO
-            .get_or_init(|| std::env::var(EXPORT_DIR_ENV_VAR).ok())
-            .as_deref()
-    }
-
-    /// Returns the path to where `T` should be exported using the `TS_RS_EXPORT_DIR` environment variable.
-    ///
-    /// This should only be used by the TS derive macro; the `get_export_to` trait method should not
-    /// be overridden if the `#[ts(export_to = ..)]` attribute exists.
-    pub fn get_export_to_path<T: TS + ?Sized>() -> Option<String> {
-        provided_default_dir().map_or_else(
-            || T::EXPORT_TO.map(ToString::to_string),
-            |path| Some(format!("{path}/{}.ts", T::ident())),
-        )
-    }
-}
-
 /// Returns the generated defintion for `T`.
 pub(crate) fn export_type_to_string<T: TS + ?Sized + 'static>() -> Result<String, ExportError> {
     let mut buffer = String::with_capacity(1024);
@@ -158,9 +134,16 @@ pub(crate) fn export_type_to_string<T: TS + ?Sized + 'static>() -> Result<String
 }
 
 /// Compute the output path to where `T` should be exported.
-fn output_path<T: TS + ?Sized>() -> Result<PathBuf, ExportError> {
-    path::absolute(Path::new(
-        &T::get_export_to()
+pub fn output_path<T: TS + ?Sized>() -> Result<PathBuf, ExportError> {
+    let path = std::env::var("TS_RS_EXPORT_DIR")
+        .ok()
+        .as_deref()
+        .map(Path::new)
+        .unwrap_or_else(|| Path::new("."))
+        .to_owned();
+
+    Ok(path.join(
+        T::EXPORT_TO
             .ok_or_else(|| std::any::type_name::<T>())
             .map_err(ExportError::CannotBeExported)?,
     ))
@@ -181,9 +164,15 @@ fn generate_decl<T: TS + ?Sized>(out: &mut String) {
 
 /// Push an import statement for all dependencies of `T`
 fn generate_imports<T: TS + ?Sized + 'static>(out: &mut String) -> Result<(), ExportError> {
+    let base = std::env::var("TS_RS_EXPORT_DIR")
+        .ok()
+        .as_deref()
+        .map(Path::new)
+        .unwrap_or_else(|| Path::new("."))
+        .to_owned();
     let export_to =
-        T::get_export_to().ok_or(ExportError::CannotBeExported(std::any::type_name::<T>()))?;
-    let path = Path::new(&export_to);
+        T::EXPORT_TO.ok_or(ExportError::CannotBeExported(std::any::type_name::<T>()))?;
+    let path = base.join(export_to);
 
     let deps = T::dependencies();
     let deduplicated_deps = deps
@@ -193,7 +182,7 @@ fn generate_imports<T: TS + ?Sized + 'static>(out: &mut String) -> Result<(), Ex
         .collect::<BTreeMap<_, _>>();
 
     for (_, dep) in deduplicated_deps {
-        let rel_path = import_path(path, Path::new(&dep.exported_to));
+        let rel_path = import_path(&path, Path::new(&dep.exported_to));
         writeln!(
             out,
             "import type {{ {} }} from {:?};",
diff --git a/ts-rs/src/lib.rs b/ts-rs/src/lib.rs
index 4f99ed9..4db38ee 100644
--- a/ts-rs/src/lib.rs
+++ b/ts-rs/src/lib.rs
@@ -162,12 +162,10 @@ use std::{
     path::{Path, PathBuf},
 };
 
+pub use export::output_path;
 pub use ts_rs_macros::TS;
 
 pub use crate::export::ExportError;
-// Used in generated code. Not public API
-#[doc(hidden)]
-pub use crate::export::__private;
 use crate::typelist::TypeList;
 
 #[cfg(feature = "chrono-impl")]
@@ -289,6 +287,7 @@ pub trait TS {
     /// ```
     type WithoutGenerics: TS + ?Sized;
 
+    /// The path given to `#[ts(export_to = "...")]`
     const EXPORT_TO: Option<&'static str> = None;
     const DOCS: Option<&'static str> = None;
 
@@ -304,10 +303,6 @@ pub trait TS {
         }
     }
 
-    fn get_export_to() -> Option<String> {
-        Self::EXPORT_TO.map(ToString::to_string)
-    }
-
     /// Declaration of this type, e.g. `interface User { user_id: number, ... }`.
     /// This function will panic if the type has no declaration.
     ///
@@ -428,7 +423,11 @@ impl Dependency {
     /// If `T` is not exportable (meaning `T::EXPORT_TO` is `None`), this function will return
     /// `None`
     pub fn from_ty<T: TS + 'static + ?Sized>() -> Option<Self> {
-        let exported_to = T::get_export_to()?;
+        let exported_to = output_path::<T>()
+            .ok()
+            .as_deref()
+            .and_then(Path::to_str)
+            .map(ToOwned::to_owned)?;
         Some(Dependency {
             type_id: TypeId::of::<T>(),
             ts_name: T::ident(),


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 39b0e76..133d37b 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -18,7 +18,8 @@ jobs:
         working-directory: e2e/dependencies/consumer
         run: |
           TS_RS_EXPORT_DIR=custom-bindings cargo t 
-          tsc custom-bindings/* --noEmit --noUnusedLocals --strict
+          shopt -s globstar
+          tsc custom-bindings/**/*.ts --noEmit --noUnusedLocals --strict
   e2e-workspace:
     name: Run 'workspace' end-to-end test
     runs-on: ubuntu-latest
@@ -36,7 +37,8 @@ jobs:
         working-directory: e2e/workspace
         run: |
           TS_RS_EXPORT_DIR=custom-bindings cargo t 
-          tsc parent/custom-bindings/* --noEmit --noUnusedLocals --strict
+          shopt -s globstar
+          tsc parent/custom-bindings/**/*.ts --noEmit --noUnusedLocals --strict
   e2e-example:
     name: End-to-end test example
     runs-on: ubuntu-latest
@@ -54,7 +56,8 @@ jobs:
         working-directory: example
         run: |
           TS_RS_EXPORT_DIR=custom-bindings cargo t 
-          tsc custom-bindings/* --noEmit --noUnusedLocals --strict
+          shopt -s globstar
+          tsc custom-bindings/**/*.ts --noEmit --noUnusedLocals --strict
 
   readme-up-to-date:
     name: Check that README.md is up-to-date
@@ -78,6 +81,11 @@ jobs:
       - uses: actions-rs/toolchain@v1
         with:
           toolchain: stable
+      - name: Test with export env
+        run: |
+          TS_RS_EXPORT_DIR=output cargo test --no-default-features
+          shopt -s globstar
+          tsc ts-rs/output/tests-out/**/*.ts --noEmit --noUnusedLocals --strict
       - name: No features
         run: |
           cargo test --no-default-features
diff --git a/ts-rs/tests/docs.rs b/ts-rs/tests/docs.rs
index d007ec4..6473c36 100644
--- a/ts-rs/tests/docs.rs
+++ b/ts-rs/tests/docs.rs
@@ -2,7 +2,8 @@
 
 use std::{concat, fs};
 
-use ts_rs::TS;
+use ts_rs::{TS, output_path};
+
 
 /* ============================================================================================== */
 
@@ -136,7 +137,7 @@ fn export_a() {
         )
     };
 
-    let actual_content = fs::read_to_string("tests-out/docs/A.ts").unwrap();
+    let actual_content = fs::read_to_string(output_path::<A>().unwrap()).unwrap();
 
     assert_eq!(actual_content, expected_content);
 }
@@ -182,7 +183,7 @@ fn export_b() {
         )
     };
 
-    let actual_content = fs::read_to_string("tests-out/docs/B.ts").unwrap();
+    let actual_content = fs::read_to_string(output_path::<B>().unwrap()).unwrap();
 
     assert_eq!(actual_content, expected_content);
 }
@@ -215,7 +216,7 @@ fn export_c() {
         )
     };
 
-    let actual_content = fs::read_to_string("tests-out/docs/C.ts").unwrap();
+    let actual_content = fs::read_to_string(output_path::<C>().unwrap()).unwrap();
 
     assert_eq!(actual_content, expected_content);
 }
@@ -247,7 +248,7 @@ fn export_d() {
             "export type D = null;"
         )
     };
-    let actual_content = fs::read_to_string("tests-out/docs/D.ts").unwrap();
+    let actual_content = fs::read_to_string(output_path::<D>().unwrap()).unwrap();
 
     assert_eq!(actual_content, expected_content);
 }
@@ -280,7 +281,7 @@ fn export_e() {
         )
     };
 
-    let actual_content = fs::read_to_string("tests-out/docs/E.ts").unwrap();
+    let actual_content = fs::read_to_string(output_path::<E>().unwrap()).unwrap();
 
     assert_eq!(actual_content, expected_content);
 }
@@ -328,7 +329,7 @@ fn export_f() {
         )
     };
 
-    let actual_content = fs::read_to_string("tests-out/docs/F.ts").unwrap();
+    let actual_content = fs::read_to_string(output_path::<F>().unwrap()).unwrap();
 
     assert_eq!(actual_content, expected_content);
 }
@@ -376,7 +377,7 @@ fn export_g() {
         )
     };
 
-    let actual_content = fs::read_to_string("tests-out/docs/G.ts").unwrap();
+    let actual_content = fs::read_to_string(output_path::<G>().unwrap()).unwrap();
 
     assert_eq!(actual_content, expected_content);
 }
diff --git a/ts-rs/tests/export_manually.rs b/ts-rs/tests/export_manually.rs
index 6bf0e3b..d0491d9 100644
--- a/ts-rs/tests/export_manually.rs
+++ b/ts-rs/tests/export_manually.rs
@@ -2,7 +2,7 @@
 
 use std::{concat, fs};
 
-use ts_rs::TS;
+use ts_rs::{output_path, TS};
 
 #[derive(TS)]
 #[ts(export_to = "tests-out/export_manually/UserFile.ts")]
@@ -36,7 +36,7 @@ fn export_manually() {
         )
     };
 
-    let actual_content = fs::read_to_string("tests-out/export_manually/UserFile.ts").unwrap();
+    let actual_content = fs::read_to_string(output_path::<User>().unwrap()).unwrap();
 
     assert_eq!(actual_content, expected_content);
 }
@@ -57,7 +57,7 @@ fn export_manually_dir() {
         )
     };
 
-    let actual_content = fs::read_to_string("tests-out/export_manually/dir/UserDir.ts").unwrap();
+    let actual_content = fs::read_to_string(output_path::<UserDir>().unwrap()).unwrap();
 
     assert_eq!(actual_content, expected_content);
 }
diff --git a/ts-rs/tests/imports.rs b/ts-rs/tests/imports.rs
index 0451e17..8f958dd 100644
--- a/ts-rs/tests/imports.rs
+++ b/ts-rs/tests/imports.rs
@@ -1,4 +1,6 @@
 #![allow(dead_code)]
+use std::path::Path;
+
 use ts_rs::TS;
 
 #[derive(TS)]
@@ -25,7 +27,14 @@ pub enum TestEnum {
 fn test_def() {
     // The only way to get access to how the imports look is to export the type and load the exported file
     TestEnum::export().unwrap();
-    let text = std::fs::read_to_string(TestEnum::EXPORT_TO.unwrap()).unwrap();
+    let path = std::env::var("TS_RS_EXPORT_DIR")
+        .ok()
+        .as_deref()
+        .map(Path::new)
+        .unwrap_or_else(|| Path::new("."))
+        .to_owned()
+        .join(TestEnum::EXPORT_TO.unwrap());
+    let text = std::fs::read_to_string(&path).unwrap();
 
     let expected = match (cfg!(feature = "format"), cfg!(feature = "import-esm")) {
         (true, true) => concat!(
@@ -65,5 +74,5 @@ fn test_def() {
     };
 
     assert_eq!(text, expected);
-    std::fs::remove_file(TestEnum::EXPORT_TO.unwrap()).unwrap();
+    std::fs::remove_file(path).unwrap();
 }
diff --git a/ts-rs/tests/path_bug.rs b/ts-rs/tests/path_bug.rs
index cadfe15..0b5d905 100644
--- a/ts-rs/tests/path_bug.rs
+++ b/ts-rs/tests/path_bug.rs
@@ -1,5 +1,5 @@
 #![allow(dead_code)]
-use ts_rs::TS;
+use ts_rs::{TS, output_path};
 
 #[derive(TS)]
 #[ts(export, export_to = "../ts-rs/tests-out/path_bug/")]
@@ -15,10 +15,8 @@ struct Bar {
 
 #[test]
 fn path_bug() {
-    Foo::export().unwrap();
-    Bar::export().unwrap();
+    export_bindings_foo();
 
-    let base = std::env::current_dir().unwrap();
-    assert!(base.join("./tests-out/path_bug/Foo.ts").is_file());
-    assert!(base.join("./tests-out/path_bug/aaa/Bar.ts").is_file());
-}
\ No newline at end of file
+    assert!(output_path::<Foo>().unwrap().is_file());
+    assert!(output_path::<Bar>().unwrap().is_file());
+}