Repository: GREsau/schemars
Pull request #327 (2024-08-24 13:04:55)

Allow arbitrary expressions in doc/title/description attributes
Addresses #204


Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7c72306..b1260e7 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -5,18 +5,15 @@ on: [push, pull_request, workflow_dispatch]
 jobs:
   ci:
     runs-on: ubuntu-latest
-    env:
-      # work-around https://github.com/rust-lang/cargo/issues/10303
-      CARGO_NET_GIT_FETCH_WITH_CLI: ${{ matrix.rust == '1.60.0' }}
     strategy:
       matrix:
         rust:
-          - 1.60.0
+          - 1.65.0
           - stable
           - beta
           - nightly
         include:
-          - rust: 1.60.0
+          - rust: 1.65.0
             test_features: ""
             allow_failure: false
           - rust: stable
diff --git a/README.md b/README.md
index 8f7fcef..7026666 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@
 [![CI Build](https://img.shields.io/github/actions/workflow/status/GREsau/schemars/ci.yml?branch=master&logo=GitHub)](https://github.com/GREsau/schemars/actions)
 [![Crates.io](https://img.shields.io/crates/v/schemars)](https://crates.io/crates/schemars)
 [![Docs](https://img.shields.io/docsrs/schemars/1.0.0--latest?label=docs)](https://docs.rs/schemars/1.0.0--latest)
-[![MSRV 1.60+](https://img.shields.io/crates/msrv/schemars)](https://blog.rust-lang.org/2022/04/07/Rust-1.60.0.html)
+[![MSRV 1.65+](https://img.shields.io/crates/msrv/schemars)](https://blog.rust-lang.org/2022/11/03/Rust-1.65.0.html)
 
 Generate JSON Schema documents from Rust code
 
diff --git a/schemars/Cargo.toml b/schemars/Cargo.toml
index 8b3d78d..719fa2b 100644
--- a/schemars/Cargo.toml
+++ b/schemars/Cargo.toml
@@ -10,7 +10,7 @@ license = "MIT"
 readme = "README.md"
 keywords = ["rust", "json-schema", "serde"]
 categories = ["encoding", "no-std"]
-rust-version = "1.60"
+rust-version = "1.65"
 
 [dependencies]
 schemars_derive = { version = "=1.0.0-alpha.10", optional = true, path = "../schemars_derive" }
diff --git a/schemars/src/_private.rs b/schemars/src/_private/mod.rs
similarity index 96%
rename from schemars/src/_private.rs
rename to schemars/src/_private/mod.rs
index 3f96641..f2ebc05 100644
--- a/schemars/src/_private.rs
+++ b/schemars/src/_private/mod.rs
@@ -4,6 +4,10 @@ use crate::{JsonSchema, Schema, SchemaGenerator};
 use serde::Serialize;
 use serde_json::{json, map::Entry, Map, Value};
 
+mod rustdoc;
+
+pub use rustdoc::get_title_and_description;
+
 // Helper for generating schemas for flattened `Option` fields.
 pub fn json_schema_for_flatten<T: ?Sized + JsonSchema>(
     generator: &mut SchemaGenerator,
@@ -161,6 +165,17 @@ pub fn insert_metadata_property(schema: &mut Schema, key: &str, value: impl Into
     schema.ensure_object().insert(key.to_owned(), value.into());
 }
 
+pub fn insert_metadata_property_if_nonempty(
+    schema: &mut Schema,
+    key: &str,
+    value: impl Into<String>,
+) {
+    let value: String = value.into();
+    if !value.is_empty() {
+        insert_metadata_property(schema, key, value);
+    }
+}
+
 pub fn insert_validation_property(
     schema: &mut Schema,
     required_type: &str,
diff --git a/schemars/src/_private/rustdoc.rs b/schemars/src/_private/rustdoc.rs
new file mode 100644
index 0000000..8fcdaca
--- /dev/null
+++ b/schemars/src/_private/rustdoc.rs
@@ -0,0 +1,94 @@
+pub const fn get_title_and_description(doc: &str) -> (&str, &str) {
+    let doc_bytes = trim_ascii(doc.as_bytes());
+
+    if !doc_bytes.is_empty() && doc_bytes[0] == b'#' {
+        let title_end_index = match strchr(doc_bytes, b'\n') {
+            Some(i) => i,
+            None => doc_bytes.len(),
+        };
+
+        let title = trim_ascii(trim_start(subslice(doc_bytes, 0, title_end_index), b'#'));
+        let description = trim_ascii(subslice(doc_bytes, title_end_index, doc_bytes.len()));
+
+        (to_utf8(title), to_utf8(description))
+    } else {
+        ("", to_utf8(doc_bytes))
+    }
+}
+
+const fn strchr(bytes: &[u8], chr: u8) -> Option<usize> {
+    let len = bytes.len();
+    let mut i = 0;
+    while i < len {
+        if bytes[i] == chr {
+            return Some(i);
+        }
+        i += 1;
+    }
+    None
+}
+
+const fn subslice(mut bytes: &[u8], mut start: usize, end: usize) -> &[u8] {
+    let mut trim_end_count = bytes.len() - end;
+    if trim_end_count > 0 {
+        while let [rest @ .., _last] = bytes {
+            bytes = rest;
+
+            trim_end_count -= 1;
+            if trim_end_count == 0 {
+                break;
+            }
+        }
+    }
+
+    if start > 0 {
+        while let [_first, rest @ ..] = bytes {
+            bytes = rest;
+
+            start -= 1;
+            if start == 0 {
+                break;
+            }
+        }
+    }
+
+    bytes
+}
+
+const fn to_utf8(bytes: &[u8]) -> &str {
+    match core::str::from_utf8(bytes) {
+        Ok(x) => x,
+        Err(_) => panic!("Invalid UTF-8"),
+    }
+}
+
+const fn trim_start(mut bytes: &[u8], chr: u8) -> &[u8] {
+    while let [first, rest @ ..] = bytes {
+        if *first == chr {
+            bytes = rest;
+        } else {
+            break;
+        }
+    }
+    bytes
+}
+
+const fn trim_ascii(mut bytes: &[u8]) -> &[u8] {
+    while let [first, rest @ ..] = bytes {
+        if first.is_ascii_whitespace() {
+            bytes = rest;
+        } else {
+            break;
+        }
+    }
+
+    while let [rest @ .., last] = bytes {
+        if last.is_ascii_whitespace() {
+            bytes = rest;
+        } else {
+            break;
+        }
+    }
+
+    bytes
+}
diff --git a/schemars/src/generate.rs b/schemars/src/generate.rs
index 4e0e8f3..2e8da07 100644
--- a/schemars/src/generate.rs
+++ b/schemars/src/generate.rs
@@ -458,9 +458,8 @@ impl SchemaGenerator {
         }
 
         let pointer = self.definitions_path_stripped();
-        let target = match json_pointer_mut(schema_object, pointer, true) {
-            Some(d) => d,
-            None => return,
+        let Some(target) = json_pointer_mut(schema_object, pointer, true) else {
+            return;
         };
 
         target.append(&mut definitions);
diff --git a/schemars/src/transform.rs b/schemars/src/transform.rs
index 8d984d5..902655e 100644
--- a/schemars/src/transform.rs
+++ b/schemars/src/transform.rs
@@ -315,16 +315,13 @@ impl Transform for RemoveRefSiblings {
     fn transform(&mut self, schema: &mut Schema) {
         transform_subschemas(self, schema);
 
-        if let Some(obj) = schema.as_object_mut() {
-            if obj.len() > 1 {
-                if let Some(ref_value) = obj.remove("$ref") {
-                    if let Value::Array(all_of) =
-                        obj.entry("allOf").or_insert(Value::Array(Vec::new()))
-                    {
-                        all_of.push(json!({
-                            "$ref": ref_value
-                        }));
-                    }
+        if let Some(obj) = schema.as_object_mut().filter(|o| o.len() > 1) {
+            if let Some(ref_value) = obj.remove("$ref") {
+                if let Value::Array(all_of) = obj.entry("allOf").or_insert(Value::Array(Vec::new()))
+                {
+                    all_of.push(json!({
+                        "$ref": ref_value
+                    }));
                 }
             }
         }
@@ -403,15 +400,14 @@ impl Transform for ReplaceUnevaluatedProperties {
     fn transform(&mut self, schema: &mut Schema) {
         transform_subschemas(self, schema);
 
-        if let Some(obj) = schema.as_object_mut() {
-            if let Some(up) = obj.remove("unevaluatedProperties") {
-                obj.insert("additionalProperties".to_owned(), up);
-            } else {
-                return;
-            }
-        } else {
+        let Some(obj) = schema.as_object_mut() else {
             return;
-        }
+        };
+        let Some(up) = obj.remove("unevaluatedProperties") else {
+            return;
+        };
+
+        obj.insert("additionalProperties".to_owned(), up);
 
         let mut gather_property_names = GatherPropertyNames::default();
         gather_property_names.transform(schema);
diff --git a/schemars_derive/Cargo.toml b/schemars_derive/Cargo.toml
index ab18155..6dafb8d 100644
--- a/schemars_derive/Cargo.toml
+++ b/schemars_derive/Cargo.toml
@@ -10,7 +10,7 @@ license = "MIT"
 readme = "README.md"
 keywords = ["rust", "json-schema", "serde"]
 categories = ["encoding", "no-std"]
-rust-version = "1.60"
+rust-version = "1.65"
 
 [lib]
 proc-macro = true
diff --git a/schemars_derive/src/ast/from_serde.rs b/schemars_derive/src/ast/from_serde.rs
index 453c247..8406712 100644
--- a/schemars_derive/src/ast/from_serde.rs
+++ b/schemars_derive/src/ast/from_serde.rs
@@ -26,7 +26,6 @@ impl<'a> FromSerde for Container<'a> {
             serde_attrs: serde.attrs,
             data: Data::from_serde(errors, serde.data)?,
             generics: serde.generics.clone(),
-            original: serde.original,
             // FIXME this allows with/schema_with attribute on containers
             attrs: Attrs::new(&serde.original.attrs, errors),
         })
diff --git a/schemars_derive/src/ast/mod.rs b/schemars_derive/src/ast/mod.rs
index 32cb122..642721e 100644
--- a/schemars_derive/src/ast/mod.rs
+++ b/schemars_derive/src/ast/mod.rs
@@ -10,7 +10,6 @@ pub struct Container<'a> {
     pub serde_attrs: serde_derive_internals::attr::Container,
     pub data: Data<'a>,
     pub generics: syn::Generics,
-    pub original: &'a syn::DeriveInput,
     pub attrs: Attrs,
 }
 
diff --git a/schemars_derive/src/attr/doc.rs b/schemars_derive/src/attr/doc.rs
index df9daca..fafd546 100644
--- a/schemars_derive/src/attr/doc.rs
+++ b/schemars_derive/src/attr/doc.rs
@@ -1,54 +1,25 @@
+use proc_macro2::TokenStream;
+use quote::ToTokens;
 use syn::Attribute;
 
-pub fn get_title_and_desc_from_doc(attrs: &[Attribute]) -> (Option<String>, Option<String>) {
-    let doc = match get_doc(attrs) {
-        None => return (None, None),
-        Some(doc) => doc,
-    };
+pub fn get_doc(attrs: &[Attribute]) -> Option<syn::Expr> {
+    let joiner = quote! {, "\n",};
+    let mut macro_args: TokenStream = TokenStream::new();
 
-    if doc.starts_with('#') {
-        let mut split = doc.splitn(2, '\n');
-        let title = split
-            .next()
-            .unwrap()
-            .trim_start_matches('#')
-            .trim()
-            .to_owned();
-        let maybe_desc = split.next().map(|s| s.trim().to_owned());
-        (none_if_empty(title), maybe_desc)
-    } else {
-        (None, Some(doc))
-    }
-}
-
-fn get_doc(attrs: &[Attribute]) -> Option<String> {
-    let lines = attrs
+    for nv in attrs
         .iter()
-        .filter_map(|attr| {
-            if !attr.path().is_ident("doc") {
-                return None;
-            }
-
-            let meta = attr.meta.require_name_value().ok()?;
-            if let syn::Expr::Lit(syn::ExprLit {
-                lit: syn::Lit::Str(lit_str),
-                ..
-            }) = &meta.value
-            {
-                return Some(lit_str.value());
-            }
-
-            None
-        })
-        .collect::<Vec<_>>();
-
-    none_if_empty(lines.join("\n").trim().to_owned())
-}
+        .filter(|a| a.path().is_ident("doc"))
+        .filter_map(|a| a.meta.require_name_value().ok())
+    {
+        if !macro_args.is_empty() {
+            macro_args.extend(joiner.clone());
+        }
+        macro_args.extend(nv.value.to_token_stream());
+    }
 
-fn none_if_empty(s: String) -> Option<String> {
-    if s.is_empty() {
+    if macro_args.is_empty() {
         None
     } else {
-        Some(s)
+        Some(parse_quote!(::core::concat!(#macro_args)))
     }
 }
diff --git a/schemars_derive/src/attr/mod.rs b/schemars_derive/src/attr/mod.rs
index 6b6e676..e99e4f2 100644
--- a/schemars_derive/src/attr/mod.rs
+++ b/schemars_derive/src/attr/mod.rs
@@ -10,7 +10,7 @@ use proc_macro2::{Group, Span, TokenStream, TokenTree};
 use quote::ToTokens;
 use serde_derive_internals::Ctxt;
 use syn::parse::{self, Parse};
-use syn::{LitStr, Meta, MetaNameValue};
+use syn::{Expr, LitStr, Meta, MetaNameValue};
 
 // FIXME using the same struct for containers+variants+fields means that
 //  with/schema_with are accepted (but ignored) on containers, and
@@ -19,15 +19,16 @@ use syn::{LitStr, Meta, MetaNameValue};
 #[derive(Debug, Default)]
 pub struct Attrs {
     pub with: Option<WithAttr>,
-    pub title: Option<String>,
-    pub description: Option<String>,
+    pub title: Option<Expr>,
+    pub description: Option<Expr>,
+    pub doc: Option<Expr>,
     pub deprecated: bool,
     pub examples: Vec<syn::Path>,
     pub repr: Option<syn::Type>,
     pub crate_name: Option<syn::Path>,
     pub is_renamed: bool,
     pub extensions: Vec<(String, TokenStream)>,
-    pub transforms: Vec<syn::Expr>,
+    pub transforms: Vec<Expr>,
 }
 
 #[derive(Debug)]
@@ -48,26 +49,15 @@ impl Attrs {
             .find(|a| a.path().is_ident("repr"))
             .and_then(|a| a.parse_args().ok());
 
-        let (doc_title, doc_description) = doc::get_title_and_desc_from_doc(attrs);
-        result.title = result.title.or(doc_title);
-        result.description = result.description.or(doc_description);
-
+        result.doc = doc::get_doc(attrs);
         result
     }
 
     pub fn as_metadata(&self) -> SchemaMetadata<'_> {
-        #[allow(clippy::ptr_arg)]
-        fn none_if_empty(s: &String) -> Option<&str> {
-            if s.is_empty() {
-                None
-            } else {
-                Some(s)
-            }
-        }
-
         SchemaMetadata {
-            title: self.title.as_ref().and_then(none_if_empty),
-            description: self.description.as_ref().and_then(none_if_empty),
+            doc: self.doc.as_ref(),
+            title: self.title.as_ref(),
+            description: self.description.as_ref(),
             deprecated: self.deprecated,
             examples: &self.examples,
             extensions: &self.extensions,
@@ -128,25 +118,15 @@ impl Attrs {
                     }
                 }
 
-                Meta::NameValue(m) if m.path.is_ident("title") => {
-                    if let Ok(title) = expr_as_lit_str(errors, attr_type, "title", &m.value) {
-                        match self.title {
-                            Some(_) => duplicate_error(m),
-                            None => self.title = Some(title.value()),
-                        }
-                    }
-                }
+                Meta::NameValue(m) if m.path.is_ident("title") => match self.title {
+                    Some(_) => duplicate_error(m),
+                    None => self.title = Some(m.value.clone()),
+                },
 
-                Meta::NameValue(m) if m.path.is_ident("description") => {
-                    if let Ok(description) =
-                        expr_as_lit_str(errors, attr_type, "description", &m.value)
-                    {
-                        match self.description {
-                            Some(_) => duplicate_error(m),
-                            None => self.description = Some(description.value()),
-                        }
-                    }
-                }
+                Meta::NameValue(m) if m.path.is_ident("description") => match self.description {
+                    Some(_) => duplicate_error(m),
+                    None => self.description = Some(m.value.clone()),
+                },
 
                 Meta::NameValue(m) if m.path.is_ident("example") => {
                     if let Ok(fun) = parse_lit_into_path(errors, attr_type, "example", &m.value) {
@@ -239,6 +219,7 @@ impl Attrs {
                 with: None,
                 title: None,
                 description: None,
+                doc: None,
                 deprecated: false,
                 examples,
                 repr: None,
diff --git a/schemars_derive/src/metadata.rs b/schemars_derive/src/metadata.rs
index 1cae499..0f390c6 100644
--- a/schemars_derive/src/metadata.rs
+++ b/schemars_derive/src/metadata.rs
@@ -1,17 +1,18 @@
 use proc_macro2::TokenStream;
-use syn::spanned::Spanned;
+use syn::{spanned::Spanned, Expr};
 
 #[derive(Debug, Clone)]
 pub struct SchemaMetadata<'a> {
-    pub title: Option<&'a str>,
-    pub description: Option<&'a str>,
+    pub title: Option<&'a Expr>,
+    pub description: Option<&'a Expr>,
+    pub doc: Option<&'a Expr>,
     pub deprecated: bool,
     pub read_only: bool,
     pub write_only: bool,
     pub examples: &'a [syn::Path],
     pub default: Option<TokenStream>,
     pub extensions: &'a [(String, TokenStream)],
-    pub transforms: &'a [syn::Expr],
+    pub transforms: &'a [Expr],
 }
 
 impl<'a> SchemaMetadata<'a> {
@@ -35,14 +36,29 @@ impl<'a> SchemaMetadata<'a> {
     fn make_setters(&self) -> Vec<TokenStream> {
         let mut setters = Vec::<TokenStream>::new();
 
+        if let Some(doc) = &self.doc {
+            if self.title.is_none() || self.description.is_none() {
+                setters.push(quote!{
+                    const title_and_description: (&str, &str) = schemars::_private::get_title_and_description(#doc);
+                });
+            }
+        }
         if let Some(title) = &self.title {
             setters.push(quote! {
-                schemars::_private::insert_metadata_property(&mut schema, "title", #title);
+                schemars::_private::insert_metadata_property_if_nonempty(&mut schema, "title", #title);
+            });
+        } else if self.doc.is_some() {
+            setters.push(quote! {
+                schemars::_private::insert_metadata_property_if_nonempty(&mut schema, "title", title_and_description.0);
             });
         }
         if let Some(description) = &self.description {
             setters.push(quote! {
-                schemars::_private::insert_metadata_property(&mut schema, "description", #description);
+                schemars::_private::insert_metadata_property_if_nonempty(&mut schema, "description", #description);
+            });
+        } else if self.doc.is_some() {
+            setters.push(quote! {
+                schemars::_private::insert_metadata_property_if_nonempty(&mut schema, "description", title_and_description.1);
             });
         }
 


Test patch:
diff --git a/schemars/tests/docs.rs b/schemars/tests/docs.rs
index c46cb89..36ab317 100644
--- a/schemars/tests/docs.rs
+++ b/schemars/tests/docs.rs
@@ -1,5 +1,5 @@
 mod util;
-use schemars::{generate::SchemaSettings, JsonSchema};
+use schemars::JsonSchema;
 use util::*;
 
 #[allow(dead_code)]
@@ -15,6 +15,9 @@ struct MyStruct {
     my_undocumented_bool: bool,
     /// A unit struct instance
     my_unit: MyUnitStruct,
+    #[doc = concat!("# Documented ", "bool")]
+    #[doc = concat!("This bool is documented")]
+    my_documented_bool: bool,
 }
 
 /// # A Unit
@@ -57,12 +60,6 @@ fn doc_comments_struct() -> TestResult {
     test_default_generated_schema::<MyStruct>("doc_comments_struct")
 }
 
-#[test]
-fn doc_comments_struct_ref_siblings() -> TestResult {
-    let settings = SchemaSettings::draft2019_09();
-    test_generated_schema::<MyStruct>("doc_comments_struct_ref_siblings", settings)
-}
-
 #[test]
 fn doc_comments_enum() -> TestResult {
     test_default_generated_schema::<MyEnum>("doc_comments_enum")
@@ -81,6 +78,8 @@ struct OverrideDocs {
     /// Also overridden
     #[schemars(title = "", description = "")]
     my_undocumented_bool: bool,
+    #[schemars(title = concat!("Documented ", "bool"), description = "Capitalized".to_uppercase())]
+    my_documented_bool: bool,
 }
 
 #[test]
diff --git a/schemars/tests/expected/doc_comments_override.json b/schemars/tests/expected/doc_comments_override.json
index b184d2a..00eebe5 100644
--- a/schemars/tests/expected/doc_comments_override.json
+++ b/schemars/tests/expected/doc_comments_override.json
@@ -12,10 +12,16 @@
     },
     "my_undocumented_bool": {
       "type": "boolean"
+    },
+    "my_documented_bool": {
+      "title": "Documented bool",
+      "description": "CAPITALIZED",
+      "type": "boolean"
     }
   },
   "required": [
     "my_int",
-    "my_undocumented_bool"
+    "my_undocumented_bool",
+    "my_documented_bool"
   ]
 }
\ No newline at end of file
diff --git a/schemars/tests/expected/doc_comments_struct.json b/schemars/tests/expected/doc_comments_struct.json
index 655121a..670a4d2 100644
--- a/schemars/tests/expected/doc_comments_struct.json
+++ b/schemars/tests/expected/doc_comments_struct.json
@@ -15,12 +15,18 @@
     "my_unit": {
       "description": "A unit struct instance",
       "$ref": "#/$defs/MyUnitStruct"
+    },
+    "my_documented_bool": {
+      "title": "Documented bool",
+      "description": "This bool is documented",
+      "type": "boolean"
     }
   },
   "required": [
     "my_int",
     "my_undocumented_bool",
-    "my_unit"
+    "my_unit",
+    "my_documented_bool"
   ],
   "$defs": {
     "MyUnitStruct": {
diff --git a/schemars/tests/expected/doc_comments_struct_ref_siblings.json b/schemars/tests/expected/doc_comments_struct_ref_siblings.json
deleted file mode 100644
index 6b6caeb..0000000
--- a/schemars/tests/expected/doc_comments_struct_ref_siblings.json
+++ /dev/null
@@ -1,31 +0,0 @@
-{
-  "$schema": "https://json-schema.org/draft/2019-09/schema",
-  "title": "This is the struct's title",
-  "description": "This is the struct's description.",
-  "type": "object",
-  "properties": {
-    "my_int": {
-      "title": "An integer",
-      "type": "integer",
-      "format": "int32"
-    },
-    "my_undocumented_bool": {
-      "type": "boolean"
-    },
-    "my_unit": {
-      "description": "A unit struct instance",
-      "$ref": "#/$defs/MyUnitStruct"
-    }
-  },
-  "required": [
-    "my_int",
-    "my_undocumented_bool",
-    "my_unit"
-  ],
-  "$defs": {
-    "MyUnitStruct": {
-      "title": "A Unit",
-      "type": "null"
-    }
-  }
-}
\ No newline at end of file