Repository: Aleph-Alpha/ts-rs
Pull request #229 (2024-02-10 12:26:02)

feat: Add functionality to export doc strings on struct fields and enum variants
This is an "extension" of #187 and tries to also export doc strings not only on "full types" but also on the fields/variants of structs/enums.

This is also a new PR as a replacement for #228, because that PR had a "wrong commit base".

## Example

```rust
#[derive(TS)]
#[ts(export_to = "tests-out/docs/")]
struct A {
    /// Doc of field
    ///
    /// Testing
    name: String,
}
```

is exported as (with `format` feature)

```ts
export type A = {
  /**
   * Doc of field
   *
   * Testing
   */
  name: string;
};
```

and 

```rust
enum F {
    /// Doc of variant
    ///
    /// Testing
    VarA,
    /// Doc of variant
    ///
    /// Testing
    VarB(),
    /// Doc of variant
    ///
    /// Testing
    VarC {
        /// Doc of field of variant
        ///
        /// Testing
        variant_vield: i32,
    },
}
```

is exported as (with `format` feature)

```ts
export type F = "VarA" | { "VarB": never[] } | {
  "VarC": {
    /**
     * Doc of field of variant
     *
     * Testing
     */
    variant_vield: number;
  };
};
```

## Notes

These are the "problems" that have to be thought of.

Following notes are a combination of my own observations + input from @escritorio-gustavo.

### JSDocs are only shown when starting in a new line (solved)

<img width="281" alt="image" src="https://github.com/Aleph-Alpha/ts-rs/assets/28571656/176994dd-516b-4faf-a6ac-9c85465abead">
<img width="311" alt="image" src="https://github.com/Aleph-Alpha/ts-rs/assets/28571656/dc145984-9a3c-4f67-b385-07763c936184">

Inserted doc strings always have a leading newline to solve this.

### JSDocs are not applicable to unions (enums)

See https://github.com/microsoft/tsdoc/issues/164

It seems like this is currently not possible, not even depending on the tagging-kind of enums.


timephy: I would like to ask for your help @escritorio-gustavo to solve these issues 🙏

- I am unsure about how or even if we should handle docs for newtypes and tuples...
- I have copied the function `format_docs` because I needed it inside the `macros` module, but it is defined inside the `ts-rs` module, and therefore not importable..

Should we create a new `utils` crate?

I believe there is no way to refactor the usage of `format_docs` from inside `ts-rs` to inside `macros` for the doc generation for "full types", because the doc string needs to be in front of typescript's `"export"`, which is generated from inside `ts-ts`... But maybe you can find a way - although we can of course just keep it this way.

<img width="363" alt="image" src="https://github.com/Aleph-Alpha/ts-rs/assets/28571656/bf095f21-9e82-42b8-bf50-5537ce2a6c9b">
timephy: (the commit message "exporting docs on struct fields not works" should of course be "*now* works", lol)
escritorio-gustavo: > I am unsure about how or even if we should handle docs for newtypes and tuples...

Good question, I assume they currently don't export the docs right? I think we can skip documenting fields for these types as documenting the field of a newtype is kinda pointless and I'm not sure if TSDoc even renders tuple member documentation

> You may also please have a look how this implementation behaves when using flatten, which I did not look into yet

Sure, I'll write some tests and push them to the PR

> I have copied the function format_docs because I needed it inside the macros module, but it is defined inside the ts-rs module, and therefore not importable..
> Should we create a new utils crate?

That could work, but we need to check with @NyxCode.

It's really annoying that we can't just move the function to `macros` and export it. Maybe we could turn format_docs into a proc_macro to do this? Seems like overkill but could also be an option 


escritorio-gustavo: I think we can make `parse_docs` return the properly formatted docs as a `Result<String>` and eliminate `format_docs` entirely
escritorio-gustavo: I think this is now ready for merging. Thank you so much for your work on this and #187 @timephy!

Patch:
diff --git a/macros/src/attr/enum.rs b/macros/src/attr/enum.rs
index 61b2305..dad4fdf 100644
--- a/macros/src/attr/enum.rs
+++ b/macros/src/attr/enum.rs
@@ -12,7 +12,7 @@ pub struct EnumAttr {
     pub rename: Option<String>,
     pub export_to: Option<String>,
     pub export: bool,
-    pub docs: Vec<String>,
+    pub docs: String,
     tag: Option<String>,
     untagged: bool,
     content: Option<String>,
diff --git a/macros/src/attr/field.rs b/macros/src/attr/field.rs
index c66b299..09d1b0d 100644
--- a/macros/src/attr/field.rs
+++ b/macros/src/attr/field.rs
@@ -1,7 +1,7 @@
 use syn::{spanned::Spanned, Attribute, Ident, Result};
 
 use super::parse_assign_str;
-use crate::utils::parse_attrs;
+use crate::utils::{parse_attrs, parse_docs};
 
 #[derive(Default)]
 pub struct FieldAttr {
@@ -12,6 +12,7 @@ pub struct FieldAttr {
     pub skip: bool,
     pub optional: Optional,
     pub flatten: bool,
+    pub docs: String,
 }
 
 /// Indicates whether the field is marked with `#[ts(optional)]`.
@@ -31,6 +32,7 @@ impl FieldAttr {
     pub fn from_attrs(attrs: &[Attribute]) -> Result<Self> {
         let mut result = Self::default();
         parse_attrs(attrs)?.for_each(|a| result.merge(a));
+        result.docs = parse_docs(attrs)?;
         #[cfg(feature = "serde-compat")]
         if !result.skip {
             crate::utils::parse_serde_attrs::<SerdeFieldAttr>(attrs)
@@ -49,6 +51,7 @@ impl FieldAttr {
             skip,
             optional: Optional { optional, nullable },
             flatten,
+            docs,
         }: FieldAttr,
     ) {
         self.rename = self.rename.take().or(rename);
@@ -61,6 +64,7 @@ impl FieldAttr {
             nullable: self.optional.nullable || nullable,
         };
         self.flatten |= flatten;
+        self.docs.push_str(&docs);
     }
 }
 
diff --git a/macros/src/attr/struct.rs b/macros/src/attr/struct.rs
index 7ad7c28..1177aa9 100644
--- a/macros/src/attr/struct.rs
+++ b/macros/src/attr/struct.rs
@@ -14,7 +14,7 @@ pub struct StructAttr {
     pub export_to: Option<String>,
     pub export: bool,
     pub tag: Option<String>,
-    pub docs: Vec<String>,
+    pub docs: String,
 }
 
 #[cfg(feature = "serde-compat")]
diff --git a/macros/src/attr/variant.rs b/macros/src/attr/variant.rs
index 52bcbfa..c6db337 100644
--- a/macros/src/attr/variant.rs
+++ b/macros/src/attr/variant.rs
@@ -46,7 +46,7 @@ impl VariantAttr {
         self.rename_all = self.rename_all.take().or(rename_all);
         self.inline = self.inline || inline;
         self.skip = self.skip || skip;
-        self.untagged = self.untagged || untagged
+        self.untagged = self.untagged || untagged;
     }
 }
 
diff --git a/macros/src/lib.rs b/macros/src/lib.rs
index 5d0b6ac..11b87a8 100644
--- a/macros/src/lib.rs
+++ b/macros/src/lib.rs
@@ -18,7 +18,7 @@ mod types;
 
 struct DerivedTS {
     name: String,
-    docs: Vec<String>,
+    docs: String,
     inline: TokenStream,
     decl: TokenStream,
     inline_flattened: Option<TokenStream>,
@@ -82,8 +82,7 @@ impl DerivedTS {
         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);))
+                Some(quote!(const DOCS: Option<&'static str> = Some(#docs);))
             }
         };
 
diff --git a/macros/src/types/named.rs b/macros/src/types/named.rs
index d787842..10080d3 100644
--- a/macros/src/types/named.rs
+++ b/macros/src/types/named.rs
@@ -87,6 +87,7 @@ fn format_field(
         skip,
         optional,
         flatten,
+        docs,
     } = FieldAttr::from_attrs(&field.attrs)?;
 
     if skip {
@@ -149,8 +150,14 @@ fn format_field(
     };
     let valid_name = raw_name_to_ts_field(name);
 
+    // Start every doc string with a newline, because when other characters are in front, it is not "understood" by VSCode
+    let docs = match docs.is_empty() {
+        true => "".to_string(),
+        false => format!("\n{}", &docs),
+    };
+
     formatted_fields.push(quote! {
-        format!("{}{}: {},", #valid_name, #optional_annotation, #formatted_ty)
+        format!("{}{}{}: {},", #docs, #valid_name, #optional_annotation, #formatted_ty)
     });
 
     Ok(())
diff --git a/macros/src/types/newtype.rs b/macros/src/types/newtype.rs
index 8b37fd3..e1d0463 100644
--- a/macros/src/types/newtype.rs
+++ b/macros/src/types/newtype.rs
@@ -29,6 +29,7 @@ pub(crate) fn newtype(
         skip,
         optional,
         flatten,
+        docs: _,
     } = FieldAttr::from_attrs(&inner.attrs)?;
 
     match (&rename_inner, skip, optional.optional, flatten) {
diff --git a/macros/src/types/tuple.rs b/macros/src/types/tuple.rs
index 5c01b10..0459c4a 100644
--- a/macros/src/types/tuple.rs
+++ b/macros/src/types/tuple.rs
@@ -67,6 +67,7 @@ fn format_field(
         skip,
         optional,
         flatten,
+        docs: _,
     } = FieldAttr::from_attrs(&field.attrs)?;
 
     if skip {
diff --git a/macros/src/utils.rs b/macros/src/utils.rs
index 1ffab72..da95c8c 100644
--- a/macros/src/utils.rs
+++ b/macros/src/utils.rs
@@ -117,9 +117,9 @@ pub fn parse_serde_attrs<'a, A: TryFrom<&'a Attribute, Error = Error>>(
 }
 
 /// 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()
+pub fn parse_docs(attrs: &[Attribute]) -> Result<String> {
+    let lines = attrs
+        .iter()
         .filter_map(|a| match a.meta {
             Meta::NameValue(ref x) if x.path.is_ident("doc") => Some(x),
             _ => None,
@@ -131,7 +131,18 @@ pub fn parse_docs(attrs: &[Attribute]) -> Result<Vec<String>> {
             }) => Ok(str.value()),
             _ => syn_err!(attr.span(); "doc attribute with non literal expression found"),
         })
-        .collect::<Result<Vec<_>>>()
+        .map(|attr| {
+            attr.map(|line| match line.trim() {
+                "" => " *".to_owned(),
+                _ => format!(" *{}", line.trim_end())
+            })
+        })
+        .collect::<Result<Vec<_>>>()?;
+
+    Ok(match lines.is_empty() {
+        true => "".to_owned(),
+        false => format!("/**\n{}\n */\n", lines.join("\n")),
+    })
 }
 
 #[cfg(feature = "serde-compat")]
diff --git a/ts-rs/src/export.rs b/ts-rs/src/export.rs
index 23d96df..724129b 100644
--- a/ts-rs/src/export.rs
+++ b/ts-rs/src/export.rs
@@ -174,7 +174,7 @@ 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));
+        out.push_str(docs);
     }
 
     // Type Definition
@@ -282,16 +282,3 @@ where
     }
 }
 
-/// 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"))
-        }
-    }
-}


Test patch:
diff --git a/ts-rs/tests/docs.rs b/ts-rs/tests/docs.rs
index bf034f1..41e5e66 100644
--- a/ts-rs/tests/docs.rs
+++ b/ts-rs/tests/docs.rs
@@ -13,6 +13,9 @@ use ts_rs::TS;
 #[derive(TS)]
 #[ts(export_to = "tests-out/docs/")]
 struct A {
+    /// Doc of field
+    ///
+    /// Testing
     name: String,
 }
 
@@ -23,6 +26,9 @@ struct A {
 ///
 /// Testing
 struct B {
+    /// Doc of field
+    ///
+    /// Testing
     name: String,
 }
 
@@ -57,7 +63,34 @@ enum E {}
 ///
 /// Testing
 enum F {
+    /// Doc of variant
+    ///
+    /// Testing
     VarA,
+    /// Doc of variant
+    ///
+    /// Testing
+    VarB(),
+    /// Doc of variant
+    ///
+    /// Testing
+    VarC {
+        /// Doc of field of variant
+        ///
+        /// Testing
+        variant_field: i32,
+    },
+}
+
+#[derive(TS)]
+#[ts(export_to = "tests-out/docs/")]
+struct G {
+    /// Docs
+    some_other_field: i32,
+
+    /// Some documentation that should probably NOT be exported
+    #[ts(flatten)]
+    f: F,
 }
 
 /* ============================================================================================== */
@@ -75,7 +108,14 @@ fn export_a() {
             " *\n",
             " * Testing\n",
             " */\n",
-            "export type A = { name: string };\n"
+            "export type A = {\n",
+            "  /**\n",
+            "   * Doc of field\n",
+            "   *\n",
+            "   * Testing\n",
+            "   */\n",
+            "  name: string;\n",
+            "};\n",
         )
     } else {
         concat!(
@@ -86,7 +126,13 @@ fn export_a() {
             " *\n",
             " * Testing\n",
             " */\n",
-            "export type A = { name: string, }"
+            "export type A = { \n",
+            "/**\n",
+            " * Doc of field\n",
+            " *\n",
+            " * Testing\n",
+            " */\n",
+            "name: string, }"
         )
     };
 
@@ -108,7 +154,14 @@ fn export_b() {
             " *\n",
             " * Testing\n",
             " */\n",
-            "export type B = { name: string };\n"
+            "export type B = {\n",
+            "  /**\n",
+            "   * Doc of field\n",
+            "   *\n",
+            "   * Testing\n",
+            "   */\n",
+            "  name: string;\n",
+            "};\n",
         )
     } else {
         concat!(
@@ -119,7 +172,13 @@ fn export_b() {
             " *\n",
             " * Testing\n",
             " */\n",
-            "export type B = { name: string, }"
+            "export type B = { \n",
+            "/**\n",
+            " * Doc of field\n",
+            " *\n",
+            " * Testing\n",
+            " */\n",
+            "name: string, }",
         )
     };
 
@@ -239,7 +298,16 @@ fn export_f() {
             " *\n",
             " * Testing\n",
             " */\n",
-            "export type F = \"VarA\";\n"
+            "export type F = \"VarA\" | { \"VarB\": never[] } | {\n",
+            "  \"VarC\": {\n",
+            "    /**\n",
+            "     * Doc of field of variant\n",
+            "     *\n",
+            "     * Testing\n",
+            "     */\n",
+            "    variant_field: number;\n",
+            "  };\n",
+            "};\n"
         )
     } else {
         concat!(
@@ -250,7 +318,13 @@ fn export_f() {
             " *\n",
             " * Testing\n",
             " */\n",
-            "export type F = \"VarA\";"
+            "export type F = \"VarA\" | { \"VarB\": never[] } | { \"VarC\": { \n",
+            "/**\n",
+            " * Doc of field of variant\n",
+            " *\n",
+            " * Testing\n",
+            " */\n",
+            "variant_field: number, } };"
         )
     };
 
@@ -258,3 +332,54 @@ fn export_f() {
 
     assert_eq!(actual_content, expected_content);
 }
+
+#[test]
+fn export_g() {
+    G::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",
+            "export type G =\n",
+            "  & {\n",
+            "    /**\n",
+            "     * Docs\n",
+            "     */\n",
+            "    some_other_field: number;\n",
+            "  }\n",
+            "  & (\"VarA\" | { \"VarB\": never[] } | {\n",
+            "    \"VarC\": {\n",
+            "      /**\n",
+            "       * Doc of field of variant\n",
+            "       *\n",
+            "       * Testing\n",
+            "       */\n",
+            "      variant_field: number;\n",
+            "    };\n",
+            "  });\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",
+            "export type G = { \n",
+            "/**\n",
+            " * Docs\n",
+            " */\n",
+            "some_other_field: number, } & (\"VarA\" | { \"VarB\": never[] } | { \"VarC\": { \n",
+            "/**\n",
+            " * Doc of field of variant\n",
+            " *\n",
+            " * Testing\n",
+            " */\n",
+            "variant_field: number, } })",
+        )
+
+
+
+    };
+
+    let actual_content = fs::read_to_string("tests-out/docs/G.ts").unwrap();
+
+    assert_eq!(actual_content, expected_content);
+}