Skip to content

Commit 2d5acfb

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 2d5acfb

71 files changed

Lines changed: 5619 additions & 2535 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: 234 additions & 897 deletions
Large diffs are not rendered by default.

gts-macros/src/gts_attrs.rs

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
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+
while !input.is_empty() {
45+
let key: syn::Ident = input.parse()?;
46+
let key_str = key.to_string();
47+
48+
match key_str.as_str() {
49+
"dir_path" => {
50+
input.parse::<Token![=]>()?;
51+
let value: LitStr = input.parse()?;
52+
dir_path = Some(value.value());
53+
}
54+
"schema_id" => {
55+
input.parse::<Token![=]>()?;
56+
let value: LitStr = input.parse()?;
57+
schema_id = Some(value.value());
58+
}
59+
"description" => {
60+
input.parse::<Token![=]>()?;
61+
let value: LitStr = input.parse()?;
62+
description = Some(value.value());
63+
}
64+
"extends" => {
65+
input.parse::<Token![=]>()?;
66+
let ident: syn::Ident = input.parse()?;
67+
// `extends = None` is an explicit root-marker equivalent to omitting
68+
// `extends` — ADR §Struct-Level Attributes. Both forms leave `extends`
69+
// unset and are treated identically downstream.
70+
if ident == "None" {
71+
extends = None;
72+
} else {
73+
extends = Some(ident);
74+
}
75+
}
76+
_ => {
77+
return Err(syn::Error::new_spanned(
78+
key,
79+
format!(
80+
"GtsSchema: unknown attribute '{key_str}'. \
81+
Expected: dir_path, schema_id, description, or extends"
82+
),
83+
));
84+
}
85+
}
86+
87+
if input.peek(Token![,]) {
88+
input.parse::<Token![,]>()?;
89+
}
90+
}
91+
92+
Ok(GtsAttrs {
93+
dir_path: dir_path.ok_or_else(|| {
94+
syn::Error::new_spanned(
95+
struct_ident,
96+
"GtsSchema: missing required attribute 'dir_path' in #[gts(...)]",
97+
)
98+
})?,
99+
schema_id: schema_id.ok_or_else(|| {
100+
syn::Error::new_spanned(
101+
struct_ident,
102+
"GtsSchema: missing required attribute 'schema_id' in #[gts(...)]",
103+
)
104+
})?,
105+
description: description.ok_or_else(|| {
106+
syn::Error::new_spanned(
107+
struct_ident,
108+
"GtsSchema: missing required attribute 'description' in #[gts(...)]",
109+
)
110+
})?,
111+
extends,
112+
})
113+
}
114+
}

0 commit comments

Comments
 (0)