Repository: Aleph-Alpha/ts-rs
Pull request #187 (2024-01-06 18:10:21)

feat: Add functionality to export doc strings on types
# Overview

I added functionality (and tests) to export doc strings on exported types.

# Example

```rust
/// Doc comment.
/// Supports new lines.
///
/// Testing
#[derive(TS)]
#[ts(export_to = "tests-out/docs/")]
struct A {
    name: String,
}
```

is exported as

```ts
/**
 * Doc comment.
 * Supports new lines.
 *
 * Testing
 */
export interface A { name: string, }
```

# How

This is achieved by parsing the `Vec<Attribute>` of an the exported type `Item` by filtering for `"doc"` , and then passing that data down to the `export` layer where `generate_decl` prepends the doc string before the export definition in TypeScript.

# Comments

- The only thing I am unsure about is how this interacts with the `type_def` function which is used both for exported types and field types.
- Only works with "normal doc comments", not with `include_str` doc comments or other "doc formats".

# Related

#52
#102 

bennyhodl: Concept ACK. Would love to see this merged

https://github.com/Aleph-Alpha/ts-rs/issues/188
escritorio-gustavo: Fixes #188 
Just so GitHub tracks the issue with the PR
NyxCode: Thanks for the PR! 
The general structure of the PR looks good, though there are some minor problems I'd like to resolve before merging this.

escritorio-gustavo: Hey @NyxCode! What are those minor problems? I can take a look when I have time if you want
NyxCode: > Hey @NyxCode! What are those minor problems? I can take a look when I have time if you want

oh, seems like i didnt submit the review, sorry.
escritorio-gustavo: Well this is annoying, locally editing this PR gets me 131 commits behind main, so tests still expect `interface`, but on CI they expect `type`
escritorio-gustavo: @timephy, can you merge main into your fork? I tried to do it but it just turns into a PR to your fork
escritorio-gustavo: Nevermind, figured out how to do it!
Sorry, still new to managing PRs

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3b4188a..eb3d25a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,7 +17,7 @@
 - Support `HashMap` with custom hashers ([#173](https://github.com/Aleph-Alpha/ts-rs/pull/173))
 - Add `import-esm` cargo feature to import files with a `.js` extension ([#192](https://github.com/Aleph-Alpha/ts-rs/pull/192))
 - Implement `#[ts(...)]` equivalents for `#[serde(tag = "...")]`, `#[serde(tag = "...", content = "...")]` and `#[serde(untagged)]` ([#227](https://github.com/Aleph-Alpha/ts-rs/pull/227))
-- Support `#[serde(untagged)]` on individual enum variants ((#226)[https://github.com/Aleph-Alpha/ts-rs/pull/226])
+- Support `#[serde(untagged)]` on individual enum variants ([#226](https://github.com/Aleph-Alpha/ts-rs/pull/226))
 - Support for `#[serde(rename_all_fields = "...")]` ([#225](https://github.com/Aleph-Alpha/ts-rs/pull/225))
 
 ### Fixes
diff --git a/macros/src/attr/enum.rs b/macros/src/attr/enum.rs
index 4ed6c0f..61b2305 100644
--- a/macros/src/attr/enum.rs
+++ b/macros/src/attr/enum.rs
@@ -2,7 +2,7 @@ use syn::{Attribute, Ident, Result};
 
 use crate::{
     attr::{parse_assign_inflection, parse_assign_str, Inflection},
-    utils::parse_attrs,
+    utils::{parse_attrs, parse_docs},
 };
 
 #[derive(Default)]
@@ -12,6 +12,7 @@ pub struct EnumAttr {
     pub rename: Option<String>,
     pub export_to: Option<String>,
     pub export: bool,
+    pub docs: Vec<String>,
     tag: Option<String>,
     untagged: bool,
     content: Option<String>,
@@ -45,6 +46,10 @@ impl EnumAttr {
     pub fn from_attrs(attrs: &[Attribute]) -> Result<Self> {
         let mut result = Self::default();
         parse_attrs(attrs)?.for_each(|a| result.merge(a));
+
+        let docs = parse_docs(attrs)?;
+        result.docs = docs;
+
         #[cfg(feature = "serde-compat")]
         crate::utils::parse_serde_attrs::<SerdeEnumAttr>(attrs).for_each(|a| result.merge(a.0));
         Ok(result)
@@ -61,6 +66,7 @@ impl EnumAttr {
             untagged,
             export_to,
             export,
+            docs,
         }: EnumAttr,
     ) {
         self.rename = self.rename.take().or(rename);
@@ -71,6 +77,7 @@ impl EnumAttr {
         self.content = self.content.take().or(content);
         self.export = self.export || export;
         self.export_to = self.export_to.take().or(export_to);
+        self.docs = docs;
     }
 }
 
diff --git a/macros/src/attr/struct.rs b/macros/src/attr/struct.rs
index e3539e2..7ad7c28 100644
--- a/macros/src/attr/struct.rs
+++ b/macros/src/attr/struct.rs
@@ -4,7 +4,7 @@ use syn::{Attribute, Ident, Result};
 
 use crate::{
     attr::{parse_assign_str, Inflection, VariantAttr},
-    utils::parse_attrs,
+    utils::{parse_attrs, parse_docs},
 };
 
 #[derive(Default, Clone)]
@@ -14,6 +14,7 @@ pub struct StructAttr {
     pub export_to: Option<String>,
     pub export: bool,
     pub tag: Option<String>,
+    pub docs: Vec<String>,
 }
 
 #[cfg(feature = "serde-compat")]
@@ -24,6 +25,10 @@ impl StructAttr {
     pub fn from_attrs(attrs: &[Attribute]) -> Result<Self> {
         let mut result = Self::default();
         parse_attrs(attrs)?.for_each(|a| result.merge(a));
+
+        let docs = parse_docs(attrs)?;
+        result.docs = docs;
+
         #[cfg(feature = "serde-compat")]
         crate::utils::parse_serde_attrs::<SerdeStructAttr>(attrs).for_each(|a| result.merge(a.0));
         Ok(result)
@@ -37,6 +42,7 @@ impl StructAttr {
             export,
             export_to,
             tag,
+            docs,
         }: StructAttr,
     ) {
         self.rename = self.rename.take().or(rename);
@@ -44,6 +50,7 @@ impl StructAttr {
         self.export_to = self.export_to.take().or(export_to);
         self.export = self.export || export;
         self.tag = self.tag.take().or(tag);
+        self.docs = docs;
     }
 }
 
diff --git a/macros/src/lib.rs b/macros/src/lib.rs
index 452d992..9c5e4bd 100644
--- a/macros/src/lib.rs
+++ b/macros/src/lib.rs
@@ -18,6 +18,7 @@ mod types;
 
 struct DerivedTS {
     name: String,
+    docs: Vec<String>,
     inline: TokenStream,
     decl: TokenStream,
     inline_flattened: Option<TokenStream>,
@@ -64,12 +65,22 @@ impl DerivedTS {
 
         let DerivedTS {
             name,
+            docs,
             inline,
             decl,
             inline_flattened,
             dependencies,
             ..
         } = self;
+
+        let docs = match docs.is_empty() {
+            true => None,
+            false => {
+                let docs_str = docs.join("\n");
+                Some(quote!(const DOCS: Option<&'static str> = Some(#docs_str);))
+            }
+        };
+
         let inline_flattened = inline_flattened
             .map(|t| {
                 quote! {
@@ -84,6 +95,7 @@ impl DerivedTS {
         quote! {
             #impl_start {
                 const EXPORT_TO: Option<&'static str> = Some(#export_to);
+                #docs
 
                 fn decl() -> String {
                     #decl
diff --git a/macros/src/types/enum.rs b/macros/src/types/enum.rs
index 0c78f64..6aaf0d4 100644
--- a/macros/src/types/enum.rs
+++ b/macros/src/types/enum.rs
@@ -25,6 +25,7 @@ pub(crate) fn r#enum_def(s: &ItemEnum) -> syn::Result<DerivedTS> {
     if s.variants.is_empty() {
         return Ok(DerivedTS {
             name,
+            docs: enum_attr.docs,
             inline: quote!("never".to_owned()),
             decl: quote!("type {} = never;"),
             inline_flattened: None,
@@ -55,6 +56,7 @@ pub(crate) fn r#enum_def(s: &ItemEnum) -> syn::Result<DerivedTS> {
         )),
         dependencies,
         name,
+        docs: enum_attr.docs,
         export: enum_attr.export,
         export_to: enum_attr.export_to,
     })
@@ -194,6 +196,7 @@ fn empty_enum(name: impl Into<String>, enum_attr: EnumAttr) -> DerivedTS {
         inline: quote!("never".to_owned()),
         decl: quote!(format!("type {} = never;", #name)),
         name,
+        docs: enum_attr.docs,
         inline_flattened: None,
         dependencies: Dependencies::default(),
         export: enum_attr.export,
diff --git a/macros/src/types/named.rs b/macros/src/types/named.rs
index 8f0375d..d787842 100644
--- a/macros/src/types/named.rs
+++ b/macros/src/types/named.rs
@@ -54,6 +54,7 @@ pub(crate) fn named(
         decl: quote!(format!("type {}{} = {}", #name, #generic_args, Self::inline())),
         inline_flattened: Some(quote!(format!("{{ {} }}", #fields))),
         name: name.to_owned(),
+        docs: attr.docs.clone(),
         dependencies,
         export: attr.export,
         export_to: attr.export_to.clone(),
diff --git a/macros/src/types/newtype.rs b/macros/src/types/newtype.rs
index f1038b6..8b37fd3 100644
--- a/macros/src/types/newtype.rs
+++ b/macros/src/types/newtype.rs
@@ -69,6 +69,7 @@ pub(crate) fn newtype(
         inline: inline_def,
         inline_flattened: None,
         name: name.to_owned(),
+        docs: attr.docs.clone(),
         dependencies,
         export: attr.export,
         export_to: attr.export_to.clone(),
diff --git a/macros/src/types/tuple.rs b/macros/src/types/tuple.rs
index bc98005..5c01b10 100644
--- a/macros/src/types/tuple.rs
+++ b/macros/src/types/tuple.rs
@@ -46,6 +46,7 @@ pub(crate) fn tuple(
         },
         inline_flattened: None,
         name: name.to_owned(),
+        docs: attr.docs.clone(),
         dependencies,
         export: attr.export,
         export_to: attr.export_to.clone(),
diff --git a/macros/src/types/unit.rs b/macros/src/types/unit.rs
index 1c31d99..64ce68c 100644
--- a/macros/src/types/unit.rs
+++ b/macros/src/types/unit.rs
@@ -11,6 +11,7 @@ pub(crate) fn empty_object(attr: &StructAttr, name: &str) -> Result<DerivedTS> {
         decl: quote!(format!("type {} = Record<string, never>;", #name)),
         inline_flattened: None,
         name: name.to_owned(),
+        docs: attr.docs.clone(),
         dependencies: Dependencies::default(),
         export: attr.export,
         export_to: attr.export_to.clone(),
@@ -25,6 +26,7 @@ pub(crate) fn empty_array(attr: &StructAttr, name: &str) -> Result<DerivedTS> {
         decl: quote!(format!("type {} = never[];", #name)),
         inline_flattened: None,
         name: name.to_owned(),
+        docs: attr.docs.clone(),
         dependencies: Dependencies::default(),
         export: attr.export,
         export_to: attr.export_to.clone(),
@@ -39,6 +41,7 @@ pub(crate) fn null(attr: &StructAttr, name: &str) -> Result<DerivedTS> {
         decl: quote!(format!("type {} = null;", #name)),
         inline_flattened: None,
         name: name.to_owned(),
+        docs: attr.docs.clone(),
         dependencies: Dependencies::default(),
         export: attr.export,
         export_to: attr.export_to.clone(),
diff --git a/macros/src/utils.rs b/macros/src/utils.rs
index ad7f063..1ffab72 100644
--- a/macros/src/utils.rs
+++ b/macros/src/utils.rs
@@ -1,7 +1,7 @@
 use std::convert::TryFrom;
 
 use proc_macro2::Ident;
-use syn::{Attribute, Error, Result};
+use syn::{spanned::Spanned, Attribute, Error, Expr, ExprLit, Lit, Meta, Result};
 
 macro_rules! syn_err {
     ($l:literal $(, $a:expr)*) => {
@@ -116,6 +116,24 @@ pub fn parse_serde_attrs<'a, A: TryFrom<&'a Attribute, Error = Error>>(
         .into_iter()
 }
 
+/// Return a vector of all lines of doc comments in the given vector of attributes.
+pub fn parse_docs(attrs: &[Attribute]) -> Result<Vec<String>> {
+    attrs
+        .into_iter()
+        .filter_map(|a| match a.meta {
+            Meta::NameValue(ref x) if x.path.is_ident("doc") => Some(x),
+            _ => None,
+        })
+        .map(|attr| match attr.value {
+            Expr::Lit(ExprLit {
+                lit: Lit::Str(ref str),
+                ..
+            }) => Ok(str.value()),
+            _ => syn_err!(attr.span(); "doc attribute with non literal expression found"),
+        })
+        .collect::<Result<Vec<_>>>()
+}
+
 #[cfg(feature = "serde-compat")]
 mod warning {
     use std::{fmt::Display, io::Write};
diff --git a/ts-rs/src/export.rs b/ts-rs/src/export.rs
index ce1c511..5022072 100644
--- a/ts-rs/src/export.rs
+++ b/ts-rs/src/export.rs
@@ -147,6 +147,13 @@ fn output_path<T: TS + ?Sized>() -> Result<PathBuf, ExportError> {
 
 /// Push the declaration of `T`
 fn generate_decl<T: TS + ?Sized>(out: &mut String) {
+    // Type Docs
+    let docs = &T::DOCS;
+    if let Some(docs) = docs {
+        out.push_str(&format_docs(docs));
+    }
+
+    // Type Definition
     out.push_str("export ");
     out.push_str(&T::decl());
 }
@@ -249,3 +256,17 @@ where
         Some(comps.iter().map(|c| c.as_os_str()).collect())
     }
 }
+
+/// Returns an unindented docstring that has a newline at the end if it has content.
+fn format_docs(docs: &str) -> String {
+    match docs.is_empty() {
+        true => "".to_string(),
+        false => {
+            let lines = docs
+                .lines()
+                .map(|doc| format!(" *{doc}"))
+                .collect::<Vec<_>>();
+            format!("/**\n{}\n */\n", lines.join("\n"))
+        }
+    }
+}
diff --git a/ts-rs/src/lib.rs b/ts-rs/src/lib.rs
index 4d391be..30f9d28 100644
--- a/ts-rs/src/lib.rs
+++ b/ts-rs/src/lib.rs
@@ -269,6 +269,7 @@ pub mod typelist;
 ///   Skip this variant  
 pub trait TS {
     const EXPORT_TO: Option<&'static str> = None;
+    const DOCS: Option<&'static str> = None;
 
     /// Declaration of this type, e.g. `interface User { user_id: number, ... }`.
     /// This function will panic if the type has no declaration.


Test patch:
diff --git a/ts-rs/tests/docs.rs b/ts-rs/tests/docs.rs
new file mode 100644
index 0000000..bf034f1
--- /dev/null
+++ b/ts-rs/tests/docs.rs
@@ -0,0 +1,260 @@
+#![allow(dead_code)]
+
+use std::{concat, fs};
+
+use ts_rs::TS;
+
+/* ============================================================================================== */
+
+/// Doc comment.
+/// Supports new lines.
+///
+/// Testing
+#[derive(TS)]
+#[ts(export_to = "tests-out/docs/")]
+struct A {
+    name: String,
+}
+
+#[derive(TS)]
+#[ts(export_to = "tests-out/docs/")]
+/// Doc comment.
+/// Supports new lines.
+///
+/// Testing
+struct B {
+    name: String,
+}
+
+#[derive(TS)]
+#[ts(export_to = "tests-out/docs/")]
+/// Doc comment.
+/// Supports new lines.
+///
+/// Testing
+struct C {}
+
+#[derive(TS)]
+#[ts(export_to = "tests-out/docs/")]
+/// Doc comment.
+/// Supports new lines.
+///
+/// Testing
+struct D;
+
+#[derive(TS)]
+#[ts(export_to = "tests-out/docs/")]
+/// Doc comment.
+/// Supports new lines.
+///
+/// Testing
+enum E {}
+
+#[derive(TS)]
+#[ts(export_to = "tests-out/docs/")]
+/// Doc comment.
+/// Supports new lines.
+///
+/// Testing
+enum F {
+    VarA,
+}
+
+/* ============================================================================================== */
+
+#[test]
+fn export_a() {
+    A::export().unwrap();
+
+    let expected_content = if cfg!(feature = "format") {
+        concat!(
+            "// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.\n\n",
+            "/**\n",
+            " * Doc comment.\n",
+            " * Supports new lines.\n",
+            " *\n",
+            " * Testing\n",
+            " */\n",
+            "export type A = { name: string };\n"
+        )
+    } else {
+        concat!(
+            "// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.\n\n",
+            "/**\n",
+            " * Doc comment.\n",
+            " * Supports new lines.\n",
+            " *\n",
+            " * Testing\n",
+            " */\n",
+            "export type A = { name: string, }"
+        )
+    };
+
+    let actual_content = fs::read_to_string("tests-out/docs/A.ts").unwrap();
+
+    assert_eq!(actual_content, expected_content);
+}
+
+#[test]
+fn export_b() {
+    B::export().unwrap();
+
+    let expected_content = if cfg!(feature = "format") {
+        concat!(
+            "// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.\n\n",
+            "/**\n",
+            " * Doc comment.\n",
+            " * Supports new lines.\n",
+            " *\n",
+            " * Testing\n",
+            " */\n",
+            "export type B = { name: string };\n"
+        )
+    } else {
+        concat!(
+            "// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.\n\n",
+            "/**\n",
+            " * Doc comment.\n",
+            " * Supports new lines.\n",
+            " *\n",
+            " * Testing\n",
+            " */\n",
+            "export type B = { name: string, }"
+        )
+    };
+
+    let actual_content = fs::read_to_string("tests-out/docs/B.ts").unwrap();
+
+    assert_eq!(actual_content, expected_content);
+}
+
+#[test]
+fn export_c() {
+    C::export().unwrap();
+
+    let expected_content = if cfg!(feature = "format") {
+        concat!(
+            "// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.\n\n",
+            "/**\n",
+            " * Doc comment.\n",
+            " * Supports new lines.\n",
+            " *\n",
+            " * Testing\n",
+            " */\n",
+            "export type C = Record<string, never>;\n"
+        )
+    } else {
+        concat!(
+            "// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.\n\n",
+            "/**\n",
+            " * Doc comment.\n",
+            " * Supports new lines.\n",
+            " *\n",
+            " * Testing\n",
+            " */\n",
+            "export type C = Record<string, never>;"
+        )
+    };
+
+    let actual_content = fs::read_to_string("tests-out/docs/C.ts").unwrap();
+
+    assert_eq!(actual_content, expected_content);
+}
+
+#[test]
+fn export_d() {
+    D::export().unwrap();
+
+    let expected_content = if cfg!(feature = "format") {
+        concat!(
+            "// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.\n\n",
+            "/**\n",
+            " * Doc comment.\n",
+            " * Supports new lines.\n",
+            " *\n",
+            " * Testing\n",
+            " */\n",
+            "export type D = null;\n"
+        )
+    } else {
+        concat!(
+            "// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.\n\n",
+            "/**\n",
+            " * Doc comment.\n",
+            " * Supports new lines.\n",
+            " *\n",
+            " * Testing\n",
+            " */\n",
+            "export type D = null;"
+        )
+    };
+    let actual_content = fs::read_to_string("tests-out/docs/D.ts").unwrap();
+
+    assert_eq!(actual_content, expected_content);
+}
+
+#[test]
+fn export_e() {
+    E::export().unwrap();
+
+    let expected_content = if cfg!(feature = "format") {
+        concat!(
+            "// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.\n\n",
+            "/**\n",
+            " * Doc comment.\n",
+            " * Supports new lines.\n",
+            " *\n",
+            " * Testing\n",
+            " */\n",
+            "export type E = never;\n"
+        )
+    } else {
+        concat!(
+            "// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.\n\n",
+            "/**\n",
+            " * Doc comment.\n",
+            " * Supports new lines.\n",
+            " *\n",
+            " * Testing\n",
+            " */\n",
+            "export type E = never;"
+        )
+    };
+
+    let actual_content = fs::read_to_string("tests-out/docs/E.ts").unwrap();
+
+    assert_eq!(actual_content, expected_content);
+}
+
+#[test]
+fn export_f() {
+    F::export().unwrap();
+
+    let expected_content = if cfg!(feature = "format") {
+        concat!(
+            "// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.\n\n",
+            "/**\n",
+            " * Doc comment.\n",
+            " * Supports new lines.\n",
+            " *\n",
+            " * Testing\n",
+            " */\n",
+            "export type F = \"VarA\";\n"
+        )
+    } else {
+        concat!(
+            "// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.\n\n",
+            "/**\n",
+            " * Doc comment.\n",
+            " * Supports new lines.\n",
+            " *\n",
+            " * Testing\n",
+            " */\n",
+            "export type F = \"VarA\";"
+        )
+    };
+
+    let actual_content = fs::read_to_string("tests-out/docs/F.ts").unwrap();
+
+    assert_eq!(actual_content, expected_content);
+}