Skip to content

Commit 034c776

Browse files
Andre Smithclaude
andcommitted
feat(gts-macros): implement GtsSchema derive macro with gts(...) struct/field attributes
Replaces the prior plan for a future attribute-macro rewrite with a shipped derive macro. `#[derive(GtsSchema)]` + `#[gts(...)]` helper attributes live alongside the existing `#[struct_to_gts_schema]` attribute macro; coexistence is the steady state, deprecation and removal are out of scope. Highlights: - Struct-level `#[gts(dir_path, schema_id, description, extends)]` and field-level `#[gts(type_field | instance_id | skip)]` attribute grammar. - Identity-field rule enforced at compile time: every root struct declares exactly one of `type_field` / `instance_id`; derived structs (`extends = Parent`) declare neither. `extends = None` is an equivalent explicit root marker. - Generated `pub fn new(...)` constructor on every named-field struct. `type_field` auto-populated from `GtsSchemaId::new(<P as GtsSchema>::SCHEMA_ID)` (generic root) or `Self::gts_schema_id().clone()` (non-generic root); `instance_id` passed by the caller. - Unconditional block on direct `Serialize` / `Deserialize` on derived structs via the `GtsNoDirectSerialize` / `GtsNoDirectDeserialize` marker traits. No opt-out attribute. - Spec-correct `"x-gts-ref": "/$id"` on identity fields; generic `"x-gts-ref": "gts.*"` retained for other `GtsSchemaId` fields. - `description` now emitted into runtime schemas via `gts_schema_with_refs()`. - Per-field serde rename in the nested deserializer (replaces the prior `rename_all = "snake_case"` blanket). Docs: - New ADR (`docs/001-gts-schema-derive-macro-adr.md`). - Migration guide (`docs/002-struct-to-gts-schema-migration.md`) describing the old/new diff, schema-output parity, compile-fail fixture mapping, and the coexistence stance. - Implementation plan (`docs/002-macro-migration-implementation-plan.md`). - Old `001-macro-proposal.md` / `001-macro-alignment-*.md` superseded and removed. - `gts-macros/README.md` rewritten around the derive macro, with the old macro presented as legacy. Tests (all suites green, ~104 tests + 24 trybuild fixtures): - `v2_compile_fail/` — compile-time rejection of invalid configurations (missing identity, wrong field types, schema-ID format, inheritance mismatches, direct-serde on nested, etc.). - `v2_integration_tests` — runtime API + schema output for base structs, incl. generated-constructor behavior. - `v2_inheritance_tests` — multi-level inheritance chains. - `v2_serialization_tests` — `Serialize` / `Deserialize` round-trips through the `GtsSerialize` bridge. - `v2_serde_rename_tests` — per-field `#[serde(rename)]` handling. - `v2_parity_tests` — identical output vs `#[struct_to_gts_schema]` on equivalent structs (with the `x-gts-ref: "/$id"` improvement noted above). - `v2_inheritance_tests_mixed` — interop between old and new macros. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Andre Smith <andre.smith@acronis.com>
1 parent 5376ad5 commit 034c776

71 files changed

Lines changed: 5765 additions & 2566 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/001-gts-schema-derive-macro-adr.md

Lines changed: 271 additions & 0 deletions
Large diffs are not rendered by default.

docs/001-macro-alignment-adr.md

Lines changed: 0 additions & 708 deletions
This file was deleted.

docs/001-macro-alignment-implementation-plan.md

Lines changed: 0 additions & 441 deletions
This file was deleted.

docs/001-macro-proposal.md

Lines changed: 0 additions & 483 deletions
This file was deleted.

docs/002-macro-migration-implementation-plan.md

Lines changed: 199 additions & 0 deletions
Large diffs are not rendered by default.

docs/002-struct-to-gts-schema-migration.md

Lines changed: 434 additions & 0 deletions
Large diffs are not rendered by default.

gts-macros/README.md

Lines changed: 237 additions & 897 deletions
Large diffs are not rendered by default.

gts-macros/src/gts_attrs.rs

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
//! Parsing for `#[gts(...)]` struct-level attributes.
2+
//!
3+
//! Parses the top-level `#[gts(...)]` attribute on a struct into [`GtsAttrs`],
4+
//! which contains the schema metadata needed for `#[derive(GtsSchema)]`.
5+
6+
use syn::{LitStr, Token, parse::ParseStream};
7+
8+
/// Parsed struct-level `#[gts(...)]` attributes.
9+
pub struct GtsAttrs {
10+
pub dir_path: String,
11+
pub schema_id: String,
12+
pub description: String,
13+
pub extends: Option<syn::Ident>,
14+
}
15+
16+
impl GtsAttrs {
17+
/// Parse `#[gts(...)]` from the attributes on a `DeriveInput`.
18+
///
19+
/// Returns an error if:
20+
/// - No `#[gts(...)]` attribute is found
21+
/// - Required keys (`dir_path`, `schema_id`, `description`) are missing
22+
/// - Unknown keys are present
23+
pub fn from_derive_input(input: &syn::DeriveInput) -> syn::Result<Self> {
24+
let gts_attr = input
25+
.attrs
26+
.iter()
27+
.find(|attr| attr.path().is_ident("gts"))
28+
.ok_or_else(|| {
29+
syn::Error::new_spanned(
30+
&input.ident,
31+
"GtsSchema: missing #[gts(...)] attribute. Add #[gts(dir_path = \"...\", schema_id = \"...\", description = \"...\")]",
32+
)
33+
})?;
34+
35+
gts_attr.parse_args_with(|stream: ParseStream| Self::parse_inner(stream, &input.ident))
36+
}
37+
38+
fn parse_inner(input: ParseStream, struct_ident: &syn::Ident) -> syn::Result<Self> {
39+
let mut dir_path: Option<String> = None;
40+
let mut schema_id: Option<String> = None;
41+
let mut description: Option<String> = None;
42+
let mut extends: Option<syn::Ident> = None;
43+
44+
// Tracks which keys have already been parsed so duplicates emit a clear error
45+
// rather than silently overwriting.
46+
let mut seen_extends = false;
47+
48+
while !input.is_empty() {
49+
let key: syn::Ident = input.parse()?;
50+
let key_str = key.to_string();
51+
52+
match key_str.as_str() {
53+
"dir_path" => {
54+
if dir_path.is_some() {
55+
return Err(syn::Error::new_spanned(
56+
key,
57+
"GtsSchema: duplicate attribute 'dir_path'",
58+
));
59+
}
60+
input.parse::<Token![=]>()?;
61+
let value: LitStr = input.parse()?;
62+
dir_path = Some(value.value());
63+
}
64+
"schema_id" => {
65+
if schema_id.is_some() {
66+
return Err(syn::Error::new_spanned(
67+
key,
68+
"GtsSchema: duplicate attribute 'schema_id'",
69+
));
70+
}
71+
input.parse::<Token![=]>()?;
72+
let value: LitStr = input.parse()?;
73+
schema_id = Some(value.value());
74+
}
75+
"description" => {
76+
if description.is_some() {
77+
return Err(syn::Error::new_spanned(
78+
key,
79+
"GtsSchema: duplicate attribute 'description'",
80+
));
81+
}
82+
input.parse::<Token![=]>()?;
83+
let value: LitStr = input.parse()?;
84+
description = Some(value.value());
85+
}
86+
"extends" => {
87+
if seen_extends {
88+
return Err(syn::Error::new_spanned(
89+
key,
90+
"GtsSchema: duplicate attribute 'extends'",
91+
));
92+
}
93+
seen_extends = true;
94+
input.parse::<Token![=]>()?;
95+
let ident: syn::Ident = input.parse()?;
96+
// `extends = None` is an explicit root-marker equivalent to omitting
97+
// `extends` — ADR §Struct-Level Attributes. Both forms leave `extends`
98+
// unset and are treated identically downstream.
99+
if ident == "None" {
100+
extends = None;
101+
} else {
102+
extends = Some(ident);
103+
}
104+
}
105+
_ => {
106+
return Err(syn::Error::new_spanned(
107+
key,
108+
format!(
109+
"GtsSchema: unknown attribute '{key_str}'. \
110+
Expected: dir_path, schema_id, description, or extends"
111+
),
112+
));
113+
}
114+
}
115+
116+
if !input.is_empty() {
117+
if !input.peek(Token![,]) {
118+
return Err(syn::Error::new(
119+
input.span(),
120+
"GtsSchema: expected `,` between attributes",
121+
));
122+
}
123+
input.parse::<Token![,]>()?;
124+
}
125+
}
126+
127+
Ok(GtsAttrs {
128+
dir_path: dir_path.ok_or_else(|| {
129+
syn::Error::new_spanned(
130+
struct_ident,
131+
"GtsSchema: missing required attribute 'dir_path' in #[gts(...)]",
132+
)
133+
})?,
134+
schema_id: schema_id.ok_or_else(|| {
135+
syn::Error::new_spanned(
136+
struct_ident,
137+
"GtsSchema: missing required attribute 'schema_id' in #[gts(...)]",
138+
)
139+
})?,
140+
description: description.ok_or_else(|| {
141+
syn::Error::new_spanned(
142+
struct_ident,
143+
"GtsSchema: missing required attribute 'description' in #[gts(...)]",
144+
)
145+
})?,
146+
extends,
147+
})
148+
}
149+
}

0 commit comments

Comments
 (0)