Skip to content

Commit 0ffbc17

Browse files
committed
Support description
1 parent 90a00a2 commit 0ffbc17

6 files changed

Lines changed: 142 additions & 20 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"changes":{"crates/vespera_macro/Cargo.toml":"Patch","crates/vespera/Cargo.toml":"Patch","crates/vespera_core/Cargo.toml":"Patch"},"note":"Support description","date":"2026-02-03T18:15:43.339445900Z"}

Cargo.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/vespera_core/src/schema.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ pub enum StringFormat {
7474
}
7575

7676
/// JSON Schema definition
77-
#[derive(Debug, Clone, Serialize, Deserialize)]
77+
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
7878
#[serde(rename_all = "camelCase")]
7979
pub struct Schema {
8080
/// Schema reference ($ref) - if present, other fields are ignored

crates/vespera_macro/src/parser/schema.rs

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,33 @@ use std::collections::{BTreeMap, HashMap};
33
use syn::{Fields, Type};
44
use vespera_core::schema::{Reference, Schema, SchemaRef, SchemaType};
55

6+
/// Extract doc comments from attributes.
7+
/// Returns concatenated doc comment string or None if no doc comments.
8+
pub fn extract_doc_comment(attrs: &[syn::Attribute]) -> Option<String> {
9+
let mut doc_lines = Vec::new();
10+
11+
for attr in attrs {
12+
if attr.path().is_ident("doc")
13+
&& let syn::Meta::NameValue(meta_nv) = &attr.meta
14+
&& let syn::Expr::Lit(syn::ExprLit {
15+
lit: syn::Lit::Str(lit_str),
16+
..
17+
}) = &meta_nv.value
18+
{
19+
let line = lit_str.value();
20+
// Trim leading space that rustdoc adds
21+
let trimmed = line.strip_prefix(' ').unwrap_or(&line);
22+
doc_lines.push(trimmed.to_string());
23+
}
24+
}
25+
26+
if doc_lines.is_empty() {
27+
None
28+
} else {
29+
Some(doc_lines.join("\n"))
30+
}
31+
}
32+
633
/// Strips the `r#` prefix from raw identifiers.
734
/// E.g., `r#type` becomes `type`.
835
pub fn strip_raw_prefix(ident: &str) -> &str {
@@ -437,6 +464,9 @@ pub fn parse_enum_to_schema(
437464
known_schemas: &HashMap<String, String>,
438465
struct_definitions: &HashMap<String, String>,
439466
) -> Schema {
467+
// Extract enum-level doc comment for schema description
468+
let enum_description = extract_doc_comment(&enum_item.attrs);
469+
440470
// Extract rename_all attribute from enum
441471
let rename_all = extract_rename_all(&enum_item.attrs);
442472

@@ -466,6 +496,7 @@ pub fn parse_enum_to_schema(
466496

467497
Schema {
468498
schema_type: Some(SchemaType::String),
499+
description: enum_description,
469500
r#enum: if enum_values.is_empty() {
470501
None
471502
} else {
@@ -488,10 +519,14 @@ pub fn parse_enum_to_schema(
488519
rename_field(&variant_name, rename_all.as_deref())
489520
};
490521

522+
// Extract variant-level doc comment
523+
let variant_description = extract_doc_comment(&variant.attrs);
524+
491525
let variant_schema = match &variant.fields {
492526
syn::Fields::Unit => {
493527
// Unit variant: {"const": "VariantName"}
494528
Schema {
529+
description: variant_description,
495530
r#enum: Some(vec![serde_json::Value::String(variant_key)]),
496531
..Schema::string()
497532
}
@@ -510,6 +545,7 @@ pub fn parse_enum_to_schema(
510545
properties.insert(variant_key.clone(), inner_schema);
511546

512547
Schema {
548+
description: variant_description.clone(),
513549
properties: Some(properties),
514550
required: Some(vec![variant_key]),
515551
..Schema::object()
@@ -546,6 +582,7 @@ pub fn parse_enum_to_schema(
546582
);
547583

548584
Schema {
585+
description: variant_description.clone(),
549586
properties: Some(properties),
550587
required: Some(vec![variant_key]),
551588
..Schema::object()
@@ -577,9 +614,31 @@ pub fn parse_enum_to_schema(
577614
};
578615

579616
let field_type = &field.ty;
580-
let schema_ref =
617+
let mut schema_ref =
581618
parse_type_to_schema_ref(field_type, known_schemas, struct_definitions);
582619

620+
// Extract doc comment from field and set as description
621+
if let Some(doc) = extract_doc_comment(&field.attrs) {
622+
match &mut schema_ref {
623+
SchemaRef::Inline(schema) => {
624+
schema.description = Some(doc);
625+
}
626+
SchemaRef::Ref(_) => {
627+
let ref_schema = std::mem::replace(
628+
&mut schema_ref,
629+
SchemaRef::Inline(Box::new(Schema::object())),
630+
);
631+
if let SchemaRef::Ref(reference) = ref_schema {
632+
schema_ref = SchemaRef::Inline(Box::new(Schema {
633+
description: Some(doc),
634+
all_of: Some(vec![SchemaRef::Ref(reference)]),
635+
..Default::default()
636+
}));
637+
}
638+
}
639+
}
640+
}
641+
583642
variant_properties.insert(field_name.clone(), schema_ref);
584643

585644
// Check if field is Option<T>
@@ -621,6 +680,7 @@ pub fn parse_enum_to_schema(
621680
);
622681

623682
Schema {
683+
description: variant_description,
624684
properties: Some(properties),
625685
required: Some(vec![variant_key]),
626686
..Schema::object()
@@ -633,6 +693,7 @@ pub fn parse_enum_to_schema(
633693

634694
Schema {
635695
schema_type: None, // oneOf doesn't have a single type
696+
description: enum_description,
636697
one_of: if one_of_schemas.is_empty() {
637698
None
638699
} else {
@@ -651,6 +712,9 @@ pub fn parse_struct_to_schema(
651712
let mut properties = BTreeMap::new();
652713
let mut required = Vec::new();
653714

715+
// Extract struct-level doc comment for schema description
716+
let struct_description = extract_doc_comment(&struct_item.attrs);
717+
654718
// Extract rename_all attribute from struct
655719
let rename_all = extract_rename_all(&struct_item.attrs);
656720

@@ -681,6 +745,31 @@ pub fn parse_struct_to_schema(
681745
let mut schema_ref =
682746
parse_type_to_schema_ref(field_type, known_schemas, struct_definitions);
683747

748+
// Extract doc comment from field and set as description
749+
if let Some(doc) = extract_doc_comment(&field.attrs) {
750+
match &mut schema_ref {
751+
SchemaRef::Inline(schema) => {
752+
schema.description = Some(doc);
753+
}
754+
SchemaRef::Ref(_) => {
755+
// For $ref schemas, we need to wrap in an allOf to add description
756+
// OpenAPI 3.1 allows siblings to $ref, so we can add description directly
757+
// by converting to inline schema with description + allOf[$ref]
758+
let ref_schema = std::mem::replace(
759+
&mut schema_ref,
760+
SchemaRef::Inline(Box::new(Schema::object())),
761+
);
762+
if let SchemaRef::Ref(reference) = ref_schema {
763+
schema_ref = SchemaRef::Inline(Box::new(Schema {
764+
description: Some(doc),
765+
all_of: Some(vec![SchemaRef::Ref(reference)]),
766+
..Default::default()
767+
}));
768+
}
769+
}
770+
}
771+
}
772+
684773
// Check for default attribute
685774
let has_default = extract_default(&field.attrs).is_some();
686775

@@ -727,6 +816,7 @@ pub fn parse_struct_to_schema(
727816

728817
Schema {
729818
schema_type: Some(SchemaType::Object),
819+
description: struct_description,
730820
properties: if properties.is_empty() {
731821
None
732822
} else {

crates/vespera_macro/src/schema_macro.rs

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1396,19 +1396,19 @@ fn generate_inline_relation_type(
13961396
continue;
13971397
}
13981398

1399-
// Keep only serde attributes
1400-
let serde_attrs: Vec<syn::Attribute> = field
1399+
// Keep serde and doc attributes
1400+
let kept_attrs: Vec<syn::Attribute> = field
14011401
.attrs
14021402
.iter()
1403-
.filter(|attr| attr.path().is_ident("serde"))
1403+
.filter(|attr| attr.path().is_ident("serde") || attr.path().is_ident("doc"))
14041404
.cloned()
14051405
.collect();
14061406

14071407
let field_ty = &field.ty;
14081408
fields.push(InlineField {
14091409
name: field_ident.clone(),
14101410
ty: quote::quote!(#field_ty),
1411-
attrs: serde_attrs,
1411+
attrs: kept_attrs,
14121412
});
14131413
}
14141414
}
@@ -1472,19 +1472,19 @@ fn generate_inline_relation_type_no_relations(
14721472
continue;
14731473
}
14741474

1475-
// Keep only serde attributes
1476-
let serde_attrs: Vec<syn::Attribute> = field
1475+
// Keep serde and doc attributes
1476+
let kept_attrs: Vec<syn::Attribute> = field
14771477
.attrs
14781478
.iter()
1479-
.filter(|attr| attr.path().is_ident("serde"))
1479+
.filter(|attr| attr.path().is_ident("serde") || attr.path().is_ident("doc"))
14801480
.cloned()
14811481
.collect();
14821482

14831483
let field_ty = &field.ty;
14841484
fields.push(InlineField {
14851485
name: field_ident.clone(),
14861486
ty: quote::quote!(#field_ty),
1487-
attrs: serde_attrs,
1487+
attrs: kept_attrs,
14881488
});
14891489
}
14901490
}
@@ -2683,6 +2683,13 @@ pub fn generate_schema_type_code(
26832683
})
26842684
.collect();
26852685

2686+
// Extract doc comments from source struct to carry over to generated struct
2687+
let struct_doc_attrs: Vec<_> = parsed_struct
2688+
.attrs
2689+
.iter()
2690+
.filter(|attr| attr.path().is_ident("doc"))
2691+
.collect();
2692+
26862693
// Determine the rename_all strategy:
26872694
// 1. If input.rename_all is specified, use it
26882695
// 2. Else if source has rename_all, use it
@@ -2838,7 +2845,7 @@ pub fn generate_schema_type_code(
28382845
let vis = &field.vis;
28392846
let source_field_ident = field.ident.clone().unwrap();
28402847

2841-
// Filter field attributes: only keep serde attributes, remove sea_orm and others
2848+
// Filter field attributes: keep serde and doc attributes, remove sea_orm and others
28422849
// This is important when using schema_type! with models from other files
28432850
// that may have ORM-specific attributes we don't want in the generated struct
28442851
let serde_field_attrs: Vec<_> = field
@@ -2847,6 +2854,13 @@ pub fn generate_schema_type_code(
28472854
.filter(|attr| attr.path().is_ident("serde"))
28482855
.collect();
28492856

2857+
// Extract doc attributes to carry over comments to the generated struct
2858+
let doc_attrs: Vec<_> = field
2859+
.attrs
2860+
.iter()
2861+
.filter(|attr| attr.path().is_ident("doc"))
2862+
.collect();
2863+
28502864
// Check if field should be renamed
28512865
if let Some(new_name) = rename_map.get(&rust_field_name) {
28522866
// Create new identifier for the field
@@ -2874,6 +2888,7 @@ pub fn generate_schema_type_code(
28742888
extract_field_rename(&field.attrs).unwrap_or_else(|| rust_field_name.clone());
28752889

28762890
field_tokens.push(quote! {
2891+
#(#doc_attrs)*
28772892
#(#filtered_attrs)*
28782893
#[serde(rename = #json_name)]
28792894
#vis #new_field_ident: #field_ty
@@ -2887,10 +2902,11 @@ pub fn generate_schema_type_code(
28872902
is_relation,
28882903
));
28892904
} else {
2890-
// No rename, keep field with only serde attrs
2905+
// No rename, keep field with serde and doc attrs
28912906
let field_ident = field.ident.clone().unwrap();
28922907

28932908
field_tokens.push(quote! {
2909+
#(#doc_attrs)*
28942910
#(#serde_field_attrs)*
28952911
#vis #field_ident: #field_ty
28962912
});
@@ -2989,6 +3005,7 @@ pub fn generate_schema_type_code(
29893005
// Inline types for circular relation references
29903006
#(#inline_type_definitions)*
29913007

3008+
#(#struct_doc_attrs)*
29923009
#[derive(serde::Serialize, serde::Deserialize, #clone_derive #schema_derive)]
29933010
#schema_name_attr
29943011
#[serde(rename_all = #effective_rename_all)]

0 commit comments

Comments
 (0)