From e7a462a43a4d9813daa5e6eb56ca737795e5a06c Mon Sep 17 00:00:00 2001 From: Tim Guggenmos Date: Fri, 9 Feb 2024 12:21:02 +0100 Subject: [PATCH] feat: Add functionality to export doc strings on types (#187) * Added implementation and tests for type docs * Added `docs` to `EnumAttr` and `StrucAttr` * Added `DOCS` to `TS` --- macros/src/attr/enum.rs | 9 +- macros/src/attr/struct.rs | 9 +- macros/src/lib.rs | 12 ++ macros/src/types/enum.rs | 3 + macros/src/types/named.rs | 1 + macros/src/types/newtype.rs | 1 + macros/src/types/tuple.rs | 1 + macros/src/types/unit.rs | 3 + macros/src/utils.rs | 20 ++- ts-rs/src/export.rs | 21 +++ ts-rs/src/lib.rs | 1 + ts-rs/tests/docs.rs | 260 ++++++++++++++++++++++++++++++++++++ 12 files changed, 338 insertions(+), 3 deletions(-) create mode 100644 ts-rs/tests/docs.rs diff --git a/macros/src/attr/enum.rs b/macros/src/attr/enum.rs index 4ed6c0f99..61b23059d 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, pub export_to: Option, pub export: bool, + pub docs: Vec, tag: Option, untagged: bool, content: Option, @@ -45,6 +46,10 @@ impl EnumAttr { pub fn from_attrs(attrs: &[Attribute]) -> Result { 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::(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 e3539e2e1..7ad7c2886 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, pub export: bool, pub tag: Option, + pub docs: Vec, } #[cfg(feature = "serde-compat")] @@ -24,6 +25,10 @@ impl StructAttr { pub fn from_attrs(attrs: &[Attribute]) -> Result { 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::(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 452d9922e..9c5e4bde5 100644 --- a/macros/src/lib.rs +++ b/macros/src/lib.rs @@ -18,6 +18,7 @@ mod types; struct DerivedTS { name: String, + docs: Vec, inline: TokenStream, decl: TokenStream, inline_flattened: Option, @@ -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 0c78f64ad..6aaf0d49b 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 { 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 { )), 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, 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 8f0375d4f..d7878426d 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 f1038b6f4..8b37fd3c4 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 bc98005b7..5c01b105a 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 1c31d9982..64ce68c1c 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 { decl: quote!(format!("type {} = Record;", #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 { 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 { 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 ad7f06324..1ffab72cd 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> { + 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::>>() +} + #[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 ce1c51157..502207288 100644 --- a/ts-rs/src/export.rs +++ b/ts-rs/src/export.rs @@ -147,6 +147,13 @@ fn output_path() -> Result { /// Push the declaration of `T` fn generate_decl(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::>(); + format!("/**\n{}\n */\n", lines.join("\n")) + } + } +} diff --git a/ts-rs/src/lib.rs b/ts-rs/src/lib.rs index 4d391be60..30f9d2824 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. diff --git a/ts-rs/tests/docs.rs b/ts-rs/tests/docs.rs new file mode 100644 index 000000000..bf034f15f --- /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;\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;" + ) + }; + + 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); +}