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( 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, +) { + 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 { + 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/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::("doc_comments_struct") } -#[test] -fn doc_comments_struct_ref_siblings() -> TestResult { - let settings = SchemaSettings::draft2019_09(); - test_generated_schema::("doc_comments_struct_ref_siblings", settings) -} - #[test] fn doc_comments_enum() -> TestResult { test_default_generated_schema::("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 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, Option) { - let doc = match get_doc(attrs) { - None => return (None, None), - Some(doc) => doc, - }; +pub fn get_doc(attrs: &[Attribute]) -> Option { + 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 { - let lines = attrs + for nv in attrs .iter() - .filter_map(|attr| { - if !attr.path().is_ident("doc") { - return None; - } + .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()); + } - 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::>(); - - none_if_empty(lines.join("\n").trim().to_owned()) -} - -fn none_if_empty(s: String) -> Option { - 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, - pub title: Option, - pub description: Option, + pub title: Option, + pub description: Option, + pub doc: Option, pub deprecated: bool, pub examples: Vec, pub repr: Option, pub crate_name: Option, pub is_renamed: bool, pub extensions: Vec<(String, TokenStream)>, - pub transforms: Vec, + pub transforms: Vec, } #[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, 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 { let mut setters = Vec::::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); }); }