+11
Cargo.lock
+11
Cargo.lock
···
2416
2416
name = "jacquard-derive"
2417
2417
version = "0.8.0"
2418
2418
dependencies = [
2419
+
"heck 0.5.0",
2420
+
"inventory",
2419
2421
"jacquard-common 0.8.0",
2420
2422
"jacquard-lexicon 0.8.0",
2421
2423
"proc-macro2",
···
2423
2425
"serde",
2424
2426
"serde_json",
2425
2427
"syn 2.0.108",
2428
+
"unicode-segmentation",
2426
2429
]
2427
2430
2428
2431
[[package]]
···
2513
2516
dependencies = [
2514
2517
"glob",
2515
2518
"heck 0.5.0",
2519
+
"inventory",
2516
2520
"jacquard-common 0.8.0",
2517
2521
"miette",
2518
2522
"prettyplease",
···
2525
2529
"syn 2.0.108",
2526
2530
"tempfile",
2527
2531
"thiserror 2.0.17",
2532
+
"unicode-segmentation",
2528
2533
"walkdir",
2529
2534
]
2530
2535
···
5343
5348
version = "0.1.5"
5344
5349
source = "registry+https://github.com/rust-lang/crates.io-index"
5345
5350
checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
5351
+
5352
+
[[package]]
5353
+
name = "unicode-segmentation"
5354
+
version = "1.12.0"
5355
+
source = "registry+https://github.com/rust-lang/crates.io-index"
5356
+
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
5346
5357
5347
5358
[[package]]
5348
5359
name = "unicode-width"
+1
-1
crates/jacquard-axum/tests/extractor_tests.rs
+1
-1
crates/jacquard-axum/tests/extractor_tests.rs
+2
-2
crates/jacquard-axum/tests/service_auth_tests.rs
+2
-2
crates/jacquard-axum/tests/service_auth_tests.rs
···
88
88
r#type: CowStr::new_static("Multikey"),
89
89
controller: Some(CowStr::Owned(did.into())),
90
90
public_key_multibase: Some(CowStr::Owned(multibase_key.into())),
91
-
extra_data: None,
91
+
extra_data: BTreeMap::new(),
92
92
}]),
93
93
service: None,
94
-
extra_data: None,
94
+
extra_data: BTreeMap::new(),
95
95
}
96
96
}
97
97
+4
crates/jacquard-derive/Cargo.toml
+4
crates/jacquard-derive/Cargo.toml
···
15
15
proc-macro = true
16
16
17
17
[dependencies]
18
+
heck.workspace = true
18
19
jacquard-lexicon = { version = "0.8", path = "../jacquard-lexicon" }
19
20
proc-macro2.workspace = true
20
21
quote.workspace = true
21
22
syn.workspace = true
22
23
23
24
[dev-dependencies]
25
+
inventory = "0.3"
24
26
jacquard-common = { version = "0.8", path = "../jacquard-common" }
27
+
jacquard-lexicon = { version = "0.8", path = "../jacquard-lexicon" }
25
28
serde.workspace = true
26
29
serde_json.workspace = true
30
+
unicode-segmentation = "1.12"
+64
-2
crates/jacquard-derive/src/lib.rs
+64
-2
crates/jacquard-derive/src/lib.rs
···
2
2
//!
3
3
//! This crate provides attribute and derive macros for working with Jacquard types.
4
4
//! The code generator uses `#[lexicon]` and `#[open_union]` to add lexicon-specific behavior.
5
-
//! You'll use `#[derive(IntoStatic)]` frequently, and `#[derive(XrpcRequest)]` when defining
6
-
//! custom XRPC endpoints.
5
+
//! You'll use `#[derive(IntoStatic)]` frequently, `#[derive(XrpcRequest)]` when defining
6
+
//! custom XRPC endpoints, and `#[derive(LexiconSchema)]` for reverse codegen (Rust → lexicon).
7
7
//!
8
8
//! ## Macros
9
9
//!
···
76
76
//! // - impl XrpcResp for GetThingResponse
77
77
//! // - impl XrpcRequest for GetThing
78
78
//! ```
79
+
//!
80
+
//! ### `#[derive(LexiconSchema)]`
81
+
//!
82
+
//! Derives `LexiconSchema` trait for reverse codegen (Rust → lexicon JSON). Generate
83
+
//! lexicon schemas from your Rust types for rapid prototyping and custom lexicons.
84
+
//!
85
+
//! **Type-level attributes** (`#[lexicon(...)]`):
86
+
//! - `nsid = "..."`: The lexicon NSID (required)
87
+
//! - `record`: Mark as a record type (requires `key`)
88
+
//! - `object`: Mark as an object type (default if neither record/procedure/query)
89
+
//! - `key = "..."`: Record key type (`"tid"`, `"literal:self"`, or custom)
90
+
//! - `fragment = "..."`: Fragment name for non-main defs
91
+
//!
92
+
//! **Field-level attributes** (`#[lexicon(...)]`):
93
+
//! - `max_length = N`: Max byte length for strings
94
+
//! - `max_graphemes = N`: Max grapheme count for strings
95
+
//! - `min_length = N`, `min_graphemes = N`: Minimum constraints
96
+
//! - `minimum = N`, `maximum = N`: Integer range constraints
97
+
//!
98
+
//! **Serde integration**: Respects `#[serde(rename)]`, `#[serde(rename_all)]`, and
99
+
//! `#[serde(skip)]`. Defaults to camelCase for field names (lexicon standard).
100
+
//!
101
+
//! **Enums**: Closed unions by default. Add `#[open_union]` for open unions. Variant
102
+
//! refs resolved via `#[nsid = "..."]` > `#[serde(rename = "...")]` > fragment inference.
103
+
//!
104
+
//! ```ignore
105
+
//! // Record with constraints
106
+
//! #[derive(LexiconSchema)]
107
+
//! #[lexicon(nsid = "app.bsky.feed.post", record, key = "tid")]
108
+
//! struct Post<'a> {
109
+
//! #[lexicon(max_graphemes = 300, max_length = 3000)]
110
+
//! pub text: CowStr<'a>,
111
+
//! pub created_at: Datetime, // -> "createdAt" (camelCase)
112
+
//! }
113
+
//!
114
+
//! // Closed union
115
+
//! #[derive(LexiconSchema)]
116
+
//! #[lexicon(nsid = "app.bsky.feed.defs")]
117
+
//! enum FeedViewPref {
118
+
//! #[nsid = "app.bsky.feed.defs#feedViewPref"]
119
+
//! Feed,
120
+
//! #[nsid = "app.bsky.feed.defs#threadViewPref"]
121
+
//! Thread,
122
+
//! }
123
+
//! ```
79
124
80
125
use proc_macro::TokenStream;
81
126
···
113
158
pub fn derive_xrpc_request(input: TokenStream) -> TokenStream {
114
159
jacquard_lexicon::derive_impl::impl_derive_xrpc_request(input.into()).into()
115
160
}
161
+
162
+
/// Derive macro for `LexiconSchema` trait.
163
+
///
164
+
/// Generates `LexiconSchema` trait impl from Rust types for reverse codegen (Rust → lexicon JSON).
165
+
/// Produces lexicon schema definitions and runtime validation code from your type definitions.
166
+
///
167
+
/// **What it generates:**
168
+
/// - `impl LexiconSchema` with `nsid()`, `schema_id()`, and `lexicon_doc()` methods
169
+
/// - `validate()` method that checks constraints at runtime
170
+
/// - `inventory::submit!` registration for schema discovery (Phase 3)
171
+
///
172
+
/// **Attributes:** `#[lexicon(...)]` and `#[nsid = "..."]` on types and fields.
173
+
/// See crate docs for full attribute reference and examples.
174
+
#[proc_macro_derive(LexiconSchema, attributes(lexicon, nsid))]
175
+
pub fn derive_lexicon_schema(input: TokenStream) -> TokenStream {
176
+
jacquard_lexicon::derive_impl::impl_derive_lexicon_schema(input.into()).into()
177
+
}
+234
crates/jacquard-derive/tests/lexicon_schema_derive.rs
+234
crates/jacquard-derive/tests/lexicon_schema_derive.rs
···
1
+
use jacquard_common::CowStr;
2
+
use jacquard_common::types::string::Datetime;
3
+
use jacquard_derive::{LexiconSchema, open_union};
4
+
use jacquard_lexicon::schema::{LexiconGenerator, LexiconSchema as LexiconSchemaTrait};
5
+
use serde::{Deserialize, Serialize};
6
+
7
+
#[test]
8
+
fn test_simple_struct() {
9
+
#[derive(LexiconSchema)]
10
+
#[lexicon(nsid = "com.example.simple", record, key = "tid")]
11
+
struct SimpleRecord<'a> {
12
+
pub text: CowStr<'a>,
13
+
pub created_at: Datetime,
14
+
}
15
+
16
+
assert_eq!(SimpleRecord::nsid(), "com.example.simple");
17
+
assert_eq!(SimpleRecord::schema_id().as_ref(), "com.example.simple");
18
+
19
+
let mut generator = LexiconGenerator::new(SimpleRecord::nsid());
20
+
let doc = SimpleRecord::lexicon_doc(&mut generator);
21
+
22
+
assert_eq!(doc.id.as_ref(), "com.example.simple");
23
+
assert!(doc.defs.contains_key("main"));
24
+
25
+
// Serialize to JSON to verify structure
26
+
let json = serde_json::to_string_pretty(&doc).unwrap();
27
+
println!("{}", json);
28
+
29
+
// Should contain record type
30
+
assert!(json.contains("\"type\": \"record\""));
31
+
// Should have camelCase field names (default)
32
+
assert!(json.contains("\"createdAt\""));
33
+
}
34
+
35
+
#[test]
36
+
fn test_struct_with_constraints() {
37
+
#[derive(LexiconSchema)]
38
+
#[lexicon(nsid = "com.example.constrained", record)]
39
+
struct ConstrainedRecord<'a> {
40
+
#[lexicon(max_graphemes = 300, max_length = 3000)]
41
+
pub text: CowStr<'a>,
42
+
43
+
#[lexicon(minimum = 0, maximum = 100)]
44
+
pub score: i64,
45
+
}
46
+
47
+
let mut generator = LexiconGenerator::new(ConstrainedRecord::nsid());
48
+
let doc = ConstrainedRecord::lexicon_doc(&mut generator);
49
+
50
+
let json = serde_json::to_string_pretty(&doc).unwrap();
51
+
println!("{}", json);
52
+
53
+
// Verify constraints are in schema
54
+
assert!(json.contains("\"maxGraphemes\": 300"));
55
+
assert!(json.contains("\"maxLength\": 3000"));
56
+
assert!(json.contains("\"minimum\": 0"));
57
+
assert!(json.contains("\"maximum\": 100"));
58
+
}
59
+
60
+
#[test]
61
+
fn test_validation() {
62
+
#[derive(LexiconSchema)]
63
+
#[lexicon(nsid = "com.example.validated", record)]
64
+
struct ValidatedRecord<'a> {
65
+
#[lexicon(max_length = 100)]
66
+
pub text: CowStr<'a>,
67
+
68
+
#[lexicon(minimum = 0, maximum = 10)]
69
+
pub count: i64,
70
+
}
71
+
72
+
// Valid
73
+
let valid = ValidatedRecord {
74
+
text: "hello".into(),
75
+
count: 5,
76
+
};
77
+
assert!(valid.validate().is_ok());
78
+
79
+
// Text too long
80
+
let invalid_text = ValidatedRecord {
81
+
text: "a".repeat(150).into(),
82
+
count: 5,
83
+
};
84
+
assert!(invalid_text.validate().is_err());
85
+
86
+
// Count too high
87
+
let invalid_count = ValidatedRecord {
88
+
text: "hello".into(),
89
+
count: 15,
90
+
};
91
+
assert!(invalid_count.validate().is_err());
92
+
93
+
// Count too low
94
+
let invalid_low = ValidatedRecord {
95
+
text: "hello".into(),
96
+
count: -5,
97
+
};
98
+
assert!(invalid_low.validate().is_err());
99
+
}
100
+
101
+
#[test]
102
+
fn test_serde_rename() {
103
+
#[derive(Serialize, Deserialize, LexiconSchema)]
104
+
#[lexicon(nsid = "com.example.renamed", record)]
105
+
#[serde(rename_all = "snake_case")]
106
+
struct RenamedRecord {
107
+
pub some_field: i64,
108
+
pub another_field: i64,
109
+
}
110
+
111
+
let mut generator = LexiconGenerator::new(RenamedRecord::nsid());
112
+
let doc = RenamedRecord::lexicon_doc(&mut generator);
113
+
114
+
let json = serde_json::to_string_pretty(&doc).unwrap();
115
+
println!("{}", json);
116
+
117
+
// Should use snake_case not camelCase
118
+
assert!(json.contains("\"some_field\""));
119
+
assert!(json.contains("\"another_field\""));
120
+
}
121
+
122
+
#[test]
123
+
fn test_default_camel_case() {
124
+
#[derive(LexiconSchema)]
125
+
#[lexicon(nsid = "com.example.camel", record)]
126
+
struct CamelCaseRecord {
127
+
pub field_one: i64,
128
+
pub field_two: i64,
129
+
}
130
+
131
+
let mut generator = LexiconGenerator::new(CamelCaseRecord::nsid());
132
+
let doc = CamelCaseRecord::lexicon_doc(&mut generator);
133
+
134
+
let json = serde_json::to_string_pretty(&doc).unwrap();
135
+
println!("{}", json);
136
+
137
+
// Should default to camelCase
138
+
assert!(json.contains("\"fieldOne\""));
139
+
assert!(json.contains("\"fieldTwo\""));
140
+
}
141
+
142
+
#[test]
143
+
fn test_basic_enum() {
144
+
#[derive(LexiconSchema)]
145
+
#[lexicon(nsid = "com.example.union")]
146
+
enum BasicUnion {
147
+
#[nsid = "com.example.variant.one"]
148
+
VariantOne,
149
+
150
+
#[nsid = "com.example.variant.two"]
151
+
VariantTwo,
152
+
}
153
+
154
+
let mut generator = LexiconGenerator::new(BasicUnion::nsid());
155
+
let doc = BasicUnion::lexicon_doc(&mut generator);
156
+
157
+
let json = serde_json::to_string_pretty(&doc).unwrap();
158
+
println!("{}", json);
159
+
160
+
// Should be a union type
161
+
assert!(json.contains("\"type\": \"union\""));
162
+
// Should have refs
163
+
assert!(json.contains("com.example.variant.one"));
164
+
assert!(json.contains("com.example.variant.two"));
165
+
// Should be closed by default
166
+
assert!(json.contains("\"closed\": true"));
167
+
}
168
+
169
+
#[test]
170
+
fn test_open_union() {
171
+
#[derive(LexiconSchema)]
172
+
#[lexicon(nsid = "com.example.open")]
173
+
#[open_union]
174
+
enum OpenUnion<'a> {
175
+
#[nsid = "com.example.variant"]
176
+
Variant,
177
+
178
+
Unknown(jacquard_common::types::value::Data<'a>),
179
+
}
180
+
181
+
let mut generator = LexiconGenerator::new(OpenUnion::nsid());
182
+
let doc = OpenUnion::lexicon_doc(&mut generator);
183
+
184
+
let json = serde_json::to_string_pretty(&doc).unwrap();
185
+
println!("{}", json);
186
+
187
+
// Should be open (closed field omitted, defaults to open)
188
+
assert!(!json.contains("\"closed\""));
189
+
}
190
+
191
+
#[test]
192
+
fn test_enum_with_serde_rename() {
193
+
#[derive(Serialize, Deserialize, LexiconSchema)]
194
+
#[lexicon(nsid = "com.example.renamed_union")]
195
+
enum RenamedUnion {
196
+
#[serde(rename = "app.bsky.embed.images")]
197
+
Images,
198
+
199
+
#[serde(rename = "app.bsky.embed.video")]
200
+
Video,
201
+
}
202
+
203
+
let mut generator = LexiconGenerator::new(RenamedUnion::nsid());
204
+
let doc = RenamedUnion::lexicon_doc(&mut generator);
205
+
206
+
let json = serde_json::to_string_pretty(&doc).unwrap();
207
+
println!("{}", json);
208
+
209
+
// Should use serde rename values
210
+
assert!(json.contains("app.bsky.embed.images"));
211
+
assert!(json.contains("app.bsky.embed.video"));
212
+
}
213
+
214
+
#[test]
215
+
fn test_enum_fragment_inference() {
216
+
#[derive(LexiconSchema)]
217
+
#[lexicon(nsid = "com.example.fragments")]
218
+
enum FragmentUnion {
219
+
// Should generate com.example.fragments#variantOne
220
+
VariantOne,
221
+
// Should generate com.example.fragments#variantTwo
222
+
VariantTwo,
223
+
}
224
+
225
+
let mut generator = LexiconGenerator::new(FragmentUnion::nsid());
226
+
let doc = FragmentUnion::lexicon_doc(&mut generator);
227
+
228
+
let json = serde_json::to_string_pretty(&doc).unwrap();
229
+
println!("{}", json);
230
+
231
+
// Should have fragment refs
232
+
assert!(json.contains("com.example.fragments#variantOne"));
233
+
assert!(json.contains("com.example.fragments#variantTwo"));
234
+
}
+4
-4
crates/jacquard-identity/src/resolver.rs
+4
-4
crates/jacquard-identity/src/resolver.rs
···
97
97
service_endpoint: Some(Data::String(AtprotoStr::Uri(Uri::Https(
98
98
Url::from_str(&mini_doc.pds).unwrap(),
99
99
)))),
100
-
extra_data: None,
100
+
extra_data: BTreeMap::new(),
101
101
}]),
102
-
extra_data: None,
102
+
extra_data: BTreeMap::new(),
103
103
})
104
104
} else {
105
105
Err(IdentityError::missing_pds_endpoint())
···
141
141
service_endpoint: Some(Data::String(AtprotoStr::Uri(Uri::Https(
142
142
Url::from_str(&mini_doc.pds).unwrap(),
143
143
)))),
144
-
extra_data: None,
144
+
extra_data: BTreeMap::new(),
145
145
}]),
146
-
extra_data: None,
146
+
extra_data: BTreeMap::new(),
147
147
}
148
148
.into_static())
149
149
} else {
+2
crates/jacquard-lexicon/Cargo.toml
+2
crates/jacquard-lexicon/Cargo.toml
···
14
14
[dependencies]
15
15
glob = "0.3"
16
16
heck.workspace = true
17
+
inventory = "0.3"
17
18
jacquard-common = { version = "0.8", path = "../jacquard-common" }
18
19
miette = { workspace = true, features = ["fancy"] }
19
20
prettyplease.workspace = true
···
25
26
serde_with.workspace = true
26
27
syn.workspace = true
27
28
thiserror.workspace = true
29
+
unicode-segmentation = "1.12"
28
30
walkdir = "2.5"
29
31
30
32
[dev-dependencies]
+6
crates/jacquard-lexicon/src/codegen.rs
+6
crates/jacquard-lexicon/src/codegen.rs
···
175
175
self.subscription_files.borrow_mut().insert(file_path);
176
176
self.generate_subscription(nsid, def_name, sub)
177
177
}
178
+
LexUserType::Union(union) => {
179
+
// Top-level union generates an enum
180
+
let type_name = self.def_to_type_name(nsid, def_name);
181
+
let refs: Vec<_> = union.refs.iter().cloned().collect();
182
+
self.generate_union(nsid, &type_name, &refs, union.description.as_ref().map(|d| d.as_ref()), union.closed)
183
+
}
178
184
}
179
185
}
180
186
}
+6
-5
crates/jacquard-lexicon/src/codegen/lifetime.rs
+6
-5
crates/jacquard-lexicon/src/codegen/lifetime.rs
···
1
1
use super::CodeGenerator;
2
-
use crate::lexicon::{
3
-
LexArrayItem, LexObjectProperty, LexString, LexStringFormat, LexUserType,
4
-
};
2
+
use crate::lexicon::{LexArrayItem, LexObjectProperty, LexString, LexStringFormat, LexUserType};
5
3
6
4
impl<'c> CodeGenerator<'c> {
7
5
/// Check if a property type needs a lifetime parameter
···
60
58
/// Check if a lexicon def needs a lifetime parameter
61
59
pub(super) fn def_needs_lifetime(&self, def: &LexUserType<'static>) -> bool {
62
60
match def {
63
-
// Records and Objects always have lifetimes now since they get #[lexicon] attribute
64
61
LexUserType::Record(_) => true,
65
62
LexUserType::Object(_) => true,
66
63
LexUserType::Token(_) => false,
···
85
82
// Shouldn't be referenced directly
86
83
true
87
84
}
85
+
LexUserType::Union(_) => false, // Unions are just refs, no lifetime needed
88
86
}
89
87
}
90
88
91
89
/// Check if xrpc params need a lifetime parameter
92
-
pub(super) fn params_need_lifetime(&self, params: &crate::lexicon::LexXrpcParameters<'static>) -> bool {
90
+
pub(super) fn params_need_lifetime(
91
+
&self,
92
+
params: &crate::lexicon::LexXrpcParameters<'static>,
93
+
) -> bool {
93
94
params.properties.values().any(|prop| {
94
95
use crate::lexicon::LexXrpcParametersProperty;
95
96
match prop {
+1298
crates/jacquard-lexicon/src/derive_impl/lexicon_schema.rs
+1298
crates/jacquard-lexicon/src/derive_impl/lexicon_schema.rs
···
1
+
//! Implementation of #[derive(LexiconSchema)] macro
2
+
3
+
use crate::lexicon::{
4
+
LexArray, LexBlob, LexBoolean, LexBytes, LexCidLink, LexInteger, LexObject, LexObjectProperty,
5
+
LexRef, LexRefUnion, LexString, LexStringFormat, LexUnknown, LexUserType,
6
+
};
7
+
use crate::schema::type_mapping::{LexiconPrimitiveType, StringFormat, rust_type_to_lexicon_type};
8
+
use heck::{ToKebabCase, ToLowerCamelCase, ToPascalCase, ToShoutySnakeCase, ToSnakeCase};
9
+
use jacquard_common::smol_str::{SmolStr, ToSmolStr};
10
+
use proc_macro2::TokenStream;
11
+
use quote::{ToTokens, quote};
12
+
use syn::{Attribute, Data, DeriveInput, Fields, Ident, LitStr, Type, parse2};
13
+
14
+
/// Implementation for the LexiconSchema derive macro
15
+
pub fn impl_derive_lexicon_schema(input: TokenStream) -> TokenStream {
16
+
let input = match parse2::<DeriveInput>(input) {
17
+
Ok(input) => input,
18
+
Err(e) => return e.to_compile_error(),
19
+
};
20
+
21
+
match lexicon_schema_impl(&input) {
22
+
Ok(tokens) => tokens,
23
+
Err(e) => e.to_compile_error(),
24
+
}
25
+
}
26
+
27
+
fn lexicon_schema_impl(input: &DeriveInput) -> syn::Result<TokenStream> {
28
+
// Parse type-level attributes
29
+
let type_attrs = parse_type_attrs(&input.attrs)?;
30
+
31
+
// Determine NSID
32
+
let nsid = determine_nsid(&type_attrs, input)?;
33
+
34
+
// Generate based on data type
35
+
match &input.data {
36
+
Data::Struct(data_struct) => impl_for_struct(input, &type_attrs, &nsid, data_struct),
37
+
Data::Enum(data_enum) => impl_for_enum(input, &type_attrs, &nsid, data_enum),
38
+
Data::Union(_) => Err(syn::Error::new_spanned(
39
+
input,
40
+
"LexiconSchema cannot be derived for unions",
41
+
)),
42
+
}
43
+
}
44
+
45
+
/// Parsed lexicon attributes from type
46
+
#[derive(Debug, Default)]
47
+
struct LexiconTypeAttrs {
48
+
/// NSID for this type (required for primary types)
49
+
nsid: Option<String>,
50
+
51
+
/// Fragment name (None = not a fragment, Some("") = infer from type name)
52
+
fragment: Option<String>,
53
+
54
+
/// Type kind
55
+
kind: Option<LexiconTypeKind>,
56
+
57
+
/// Record key type (for records)
58
+
key: Option<String>,
59
+
}
60
+
61
+
#[derive(Debug, Clone, Copy)]
62
+
enum LexiconTypeKind {
63
+
Record,
64
+
Query,
65
+
Procedure,
66
+
Subscription,
67
+
Object,
68
+
Union,
69
+
}
70
+
71
+
/// Parse type-level lexicon attributes
72
+
fn parse_type_attrs(attrs: &[Attribute]) -> syn::Result<LexiconTypeAttrs> {
73
+
let mut result = LexiconTypeAttrs::default();
74
+
75
+
for attr in attrs {
76
+
if !attr.path().is_ident("lexicon") {
77
+
continue;
78
+
}
79
+
80
+
attr.parse_nested_meta(|meta| {
81
+
if meta.path.is_ident("nsid") {
82
+
let value = meta.value()?;
83
+
let lit: LitStr = value.parse()?;
84
+
result.nsid = Some(lit.value());
85
+
Ok(())
86
+
} else if meta.path.is_ident("fragment") {
87
+
// Two forms: #[lexicon(fragment)] or #[lexicon(fragment = "name")]
88
+
if meta.input.peek(syn::Token![=]) {
89
+
let value = meta.value()?;
90
+
let lit: LitStr = value.parse()?;
91
+
result.fragment = Some(lit.value());
92
+
} else {
93
+
result.fragment = Some(String::new()); // Infer from type name
94
+
}
95
+
Ok(())
96
+
} else if meta.path.is_ident("record") {
97
+
result.kind = Some(LexiconTypeKind::Record);
98
+
Ok(())
99
+
} else if meta.path.is_ident("query") {
100
+
result.kind = Some(LexiconTypeKind::Query);
101
+
Ok(())
102
+
} else if meta.path.is_ident("procedure") {
103
+
result.kind = Some(LexiconTypeKind::Procedure);
104
+
Ok(())
105
+
} else if meta.path.is_ident("subscription") {
106
+
result.kind = Some(LexiconTypeKind::Subscription);
107
+
Ok(())
108
+
} else if meta.path.is_ident("key") {
109
+
let value = meta.value()?;
110
+
let lit: LitStr = value.parse()?;
111
+
result.key = Some(lit.value());
112
+
Ok(())
113
+
} else {
114
+
Err(meta.error("unknown lexicon attribute"))
115
+
}
116
+
})?;
117
+
}
118
+
119
+
Ok(result)
120
+
}
121
+
122
+
/// Parsed lexicon attributes from field
123
+
#[derive(Debug, Default)]
124
+
struct LexiconFieldAttrs {
125
+
max_length: Option<usize>,
126
+
max_graphemes: Option<usize>,
127
+
min_length: Option<usize>,
128
+
min_graphemes: Option<usize>,
129
+
minimum: Option<i64>,
130
+
maximum: Option<i64>,
131
+
explicit_ref: Option<String>,
132
+
format: Option<String>,
133
+
}
134
+
135
+
/// Parse field-level lexicon attributes
136
+
fn parse_field_attrs(attrs: &[Attribute]) -> syn::Result<LexiconFieldAttrs> {
137
+
let mut result = LexiconFieldAttrs::default();
138
+
139
+
for attr in attrs {
140
+
if !attr.path().is_ident("lexicon") {
141
+
continue;
142
+
}
143
+
144
+
attr.parse_nested_meta(|meta| {
145
+
if meta.path.is_ident("max_length") {
146
+
let value = meta.value()?;
147
+
let lit: syn::LitInt = value.parse()?;
148
+
result.max_length = Some(lit.base10_parse()?);
149
+
Ok(())
150
+
} else if meta.path.is_ident("max_graphemes") {
151
+
let value = meta.value()?;
152
+
let lit: syn::LitInt = value.parse()?;
153
+
result.max_graphemes = Some(lit.base10_parse()?);
154
+
Ok(())
155
+
} else if meta.path.is_ident("min_length") {
156
+
let value = meta.value()?;
157
+
let lit: syn::LitInt = value.parse()?;
158
+
result.min_length = Some(lit.base10_parse()?);
159
+
Ok(())
160
+
} else if meta.path.is_ident("min_graphemes") {
161
+
let value = meta.value()?;
162
+
let lit: syn::LitInt = value.parse()?;
163
+
result.min_graphemes = Some(lit.base10_parse()?);
164
+
Ok(())
165
+
} else if meta.path.is_ident("minimum") {
166
+
let value = meta.value()?;
167
+
let lit: syn::LitInt = value.parse()?;
168
+
result.minimum = Some(lit.base10_parse()?);
169
+
Ok(())
170
+
} else if meta.path.is_ident("maximum") {
171
+
let value = meta.value()?;
172
+
let lit: syn::LitInt = value.parse()?;
173
+
result.maximum = Some(lit.base10_parse()?);
174
+
Ok(())
175
+
} else if meta.path.is_ident("ref") {
176
+
let value = meta.value()?;
177
+
let lit: LitStr = value.parse()?;
178
+
result.explicit_ref = Some(lit.value());
179
+
Ok(())
180
+
} else if meta.path.is_ident("format") {
181
+
let value = meta.value()?;
182
+
let lit: LitStr = value.parse()?;
183
+
result.format = Some(lit.value());
184
+
Ok(())
185
+
} else {
186
+
Err(meta.error("unknown lexicon field attribute"))
187
+
}
188
+
})?;
189
+
}
190
+
191
+
Ok(result)
192
+
}
193
+
194
+
/// Parsed serde attributes relevant to lexicon schema
195
+
#[derive(Debug, Default)]
196
+
struct SerdeAttrs {
197
+
rename: Option<String>,
198
+
skip: bool,
199
+
}
200
+
201
+
/// Parse serde attributes for a field
202
+
fn parse_serde_attrs(attrs: &[Attribute]) -> syn::Result<SerdeAttrs> {
203
+
let mut result = SerdeAttrs::default();
204
+
205
+
for attr in attrs {
206
+
if !attr.path().is_ident("serde") {
207
+
continue;
208
+
}
209
+
210
+
attr.parse_nested_meta(|meta| {
211
+
if meta.path.is_ident("rename") {
212
+
let value = meta.value()?;
213
+
let lit: LitStr = value.parse()?;
214
+
result.rename = Some(lit.value());
215
+
Ok(())
216
+
} else if meta.path.is_ident("skip") {
217
+
result.skip = true;
218
+
Ok(())
219
+
} else {
220
+
// Ignore other serde attributes
221
+
Ok(())
222
+
}
223
+
})?;
224
+
}
225
+
226
+
Ok(result)
227
+
}
228
+
229
+
/// Parse container-level serde rename_all
230
+
fn parse_serde_rename_all(attrs: &[Attribute]) -> syn::Result<Option<RenameRule>> {
231
+
for attr in attrs {
232
+
if !attr.path().is_ident("serde") {
233
+
continue;
234
+
}
235
+
236
+
let mut found_rule = None;
237
+
attr.parse_nested_meta(|meta| {
238
+
if meta.path.is_ident("rename_all") {
239
+
let value = meta.value()?;
240
+
let lit: LitStr = value.parse()?;
241
+
found_rule = RenameRule::from_str(&lit.value());
242
+
Ok(())
243
+
} else {
244
+
Ok(())
245
+
}
246
+
})?;
247
+
248
+
if found_rule.is_some() {
249
+
return Ok(found_rule);
250
+
}
251
+
}
252
+
253
+
// Default to camelCase (lexicon standard)
254
+
Ok(Some(RenameRule::CamelCase))
255
+
}
256
+
257
+
#[derive(Debug, Clone, Copy)]
258
+
enum RenameRule {
259
+
CamelCase,
260
+
SnakeCase,
261
+
PascalCase,
262
+
ScreamingSnakeCase,
263
+
KebabCase,
264
+
}
265
+
266
+
impl RenameRule {
267
+
fn from_str(s: &str) -> Option<Self> {
268
+
match s {
269
+
"camelCase" => Some(RenameRule::CamelCase),
270
+
"snake_case" => Some(RenameRule::SnakeCase),
271
+
"PascalCase" => Some(RenameRule::PascalCase),
272
+
"SCREAMING_SNAKE_CASE" => Some(RenameRule::ScreamingSnakeCase),
273
+
"kebab-case" => Some(RenameRule::KebabCase),
274
+
_ => None,
275
+
}
276
+
}
277
+
278
+
fn apply(&self, input: &str) -> String {
279
+
match self {
280
+
RenameRule::CamelCase => input.to_lower_camel_case(),
281
+
RenameRule::SnakeCase => input.to_snake_case(),
282
+
RenameRule::PascalCase => input.to_pascal_case(),
283
+
RenameRule::ScreamingSnakeCase => input.to_shouty_snake_case(),
284
+
RenameRule::KebabCase => input.to_kebab_case(),
285
+
}
286
+
}
287
+
}
288
+
289
+
/// Determine NSID from attributes and context
290
+
fn determine_nsid(attrs: &LexiconTypeAttrs, input: &DeriveInput) -> syn::Result<String> {
291
+
// Explicit NSID in lexicon attribute
292
+
if let Some(nsid) = &attrs.nsid {
293
+
return Ok(nsid.clone());
294
+
}
295
+
296
+
// Fragment - need to find module NSID (not implemented yet)
297
+
if attrs.fragment.is_some() {
298
+
return Err(syn::Error::new_spanned(
299
+
input,
300
+
"fragments require explicit nsid or module-level primary type (not yet implemented)",
301
+
));
302
+
}
303
+
304
+
// Check for XrpcRequest derive with NSID
305
+
if let Some(nsid) = extract_xrpc_nsid(&input.attrs)? {
306
+
return Ok(nsid);
307
+
}
308
+
309
+
Err(syn::Error::new_spanned(
310
+
input,
311
+
"missing required `nsid` attribute (use #[lexicon(nsid = \"...\")] or #[xrpc(nsid = \"...\")])",
312
+
))
313
+
}
314
+
315
+
/// Extract NSID from XrpcRequest attributes (cross-derive coordination)
316
+
fn extract_xrpc_nsid(attrs: &[Attribute]) -> syn::Result<Option<String>> {
317
+
for attr in attrs {
318
+
if !attr.path().is_ident("xrpc") {
319
+
continue;
320
+
}
321
+
322
+
let mut nsid = None;
323
+
attr.parse_nested_meta(|meta| {
324
+
if meta.path.is_ident("nsid") {
325
+
let value = meta.value()?;
326
+
let lit: LitStr = value.parse()?;
327
+
nsid = Some(lit.value());
328
+
}
329
+
Ok(())
330
+
})?;
331
+
332
+
if let Some(nsid) = nsid {
333
+
return Ok(Some(nsid));
334
+
}
335
+
}
336
+
Ok(None)
337
+
}
338
+
339
+
/// Struct implementation
340
+
fn impl_for_struct(
341
+
input: &DeriveInput,
342
+
type_attrs: &LexiconTypeAttrs,
343
+
nsid: &str,
344
+
data_struct: &syn::DataStruct,
345
+
) -> syn::Result<TokenStream> {
346
+
let name = &input.ident;
347
+
let generics = &input.generics;
348
+
349
+
// Detect lifetime
350
+
let has_lifetime = generics.lifetimes().next().is_some();
351
+
let lifetime = if has_lifetime {
352
+
quote! { <'_> }
353
+
} else {
354
+
quote! {}
355
+
};
356
+
357
+
// Parse fields
358
+
let fields = match &data_struct.fields {
359
+
Fields::Named(fields) => &fields.named,
360
+
_ => {
361
+
return Err(syn::Error::new_spanned(
362
+
input,
363
+
"LexiconSchema only supports structs with named fields",
364
+
));
365
+
}
366
+
};
367
+
368
+
// Parse serde container attributes (defaults to camelCase)
369
+
let rename_all = parse_serde_rename_all(&input.attrs)?;
370
+
371
+
// Generate field definitions
372
+
let field_defs = generate_field_definitions(fields, rename_all)?;
373
+
374
+
// Generate validation code
375
+
let validation_code = generate_validation(fields, rename_all)?;
376
+
377
+
// Build lexicon_doc() implementation
378
+
let doc_impl = generate_doc_impl(nsid, type_attrs, &field_defs)?;
379
+
380
+
// Determine schema_id (add fragment suffix if needed)
381
+
let schema_id = if let Some(fragment) = &type_attrs.fragment {
382
+
let frag_name = if fragment.is_empty() {
383
+
// Infer from type name
384
+
name.to_string().to_lower_camel_case()
385
+
} else {
386
+
fragment.clone()
387
+
};
388
+
quote! {
389
+
format_smolstr!("{}#{}", #nsid, #frag_name).to_string()
390
+
}
391
+
} else {
392
+
quote! {
393
+
::jacquard_common::CowStr::new_static(#nsid)
394
+
}
395
+
};
396
+
397
+
// Generate trait impl
398
+
Ok(quote! {
399
+
impl #generics ::jacquard_lexicon::schema::LexiconSchema for #name #lifetime {
400
+
fn nsid() -> &'static str {
401
+
#nsid
402
+
}
403
+
404
+
fn schema_id() -> ::jacquard_common::CowStr<'static> {
405
+
#schema_id
406
+
}
407
+
408
+
fn lexicon_doc(
409
+
generator: &mut ::jacquard_lexicon::schema::LexiconGenerator
410
+
) -> ::jacquard_lexicon::lexicon::LexiconDoc<'static> {
411
+
#doc_impl
412
+
}
413
+
414
+
fn validate(&self) -> ::std::result::Result<(), ::jacquard_lexicon::schema::ValidationError> {
415
+
#validation_code
416
+
}
417
+
}
418
+
419
+
// Generate inventory submission for Phase 3 discovery
420
+
::inventory::submit! {
421
+
::jacquard_lexicon::schema::LexiconSchemaRef {
422
+
nsid: #nsid,
423
+
provider: || {
424
+
let mut generator = ::jacquard_lexicon::schema::LexiconGenerator::new(#nsid);
425
+
#name::lexicon_doc(&mut generator)
426
+
},
427
+
}
428
+
}
429
+
})
430
+
}
431
+
432
+
struct FieldDef {
433
+
name: String, // Rust field name
434
+
schema_name: String, // JSON field name (after serde rename)
435
+
rust_type: Type, // Rust type
436
+
lex_type: TokenStream, // LexObjectProperty tokens
437
+
required: bool,
438
+
}
439
+
440
+
fn generate_field_definitions(
441
+
fields: &syn::punctuated::Punctuated<syn::Field, syn::Token![,]>,
442
+
rename_all: Option<RenameRule>,
443
+
) -> syn::Result<Vec<FieldDef>> {
444
+
let mut defs = Vec::new();
445
+
446
+
for field in fields {
447
+
let field_name = field.ident.as_ref().unwrap().to_string();
448
+
449
+
// Skip extra_data field (added by #[lexicon] attribute macro)
450
+
if field_name == "extra_data" {
451
+
continue;
452
+
}
453
+
454
+
// Parse attributes
455
+
let serde_attrs = parse_serde_attrs(&field.attrs)?;
456
+
let lex_attrs = parse_field_attrs(&field.attrs)?;
457
+
458
+
// Skip if serde(skip)
459
+
if serde_attrs.skip {
460
+
continue;
461
+
}
462
+
463
+
// Determine schema name
464
+
let schema_name = if let Some(rename) = serde_attrs.rename {
465
+
rename
466
+
} else if let Some(rule) = rename_all {
467
+
rule.apply(&field_name)
468
+
} else {
469
+
field_name.clone()
470
+
};
471
+
472
+
// Determine if required (Option<T> = optional)
473
+
let (inner_type, required) = extract_option_inner(&field.ty);
474
+
let rust_type = inner_type.clone();
475
+
476
+
// Generate LexObjectProperty based on type + constraints
477
+
let lex_type = generate_lex_property(&rust_type, &lex_attrs)?;
478
+
479
+
defs.push(FieldDef {
480
+
name: field_name,
481
+
schema_name,
482
+
rust_type,
483
+
lex_type,
484
+
required,
485
+
});
486
+
}
487
+
488
+
Ok(defs)
489
+
}
490
+
491
+
/// Extract T from Option<T>, return (type, is_required)
492
+
fn extract_option_inner(ty: &Type) -> (&Type, bool) {
493
+
if let Type::Path(type_path) = ty {
494
+
if let Some(segment) = type_path.path.segments.last() {
495
+
if segment.ident == "Option" {
496
+
if let syn::PathArguments::AngleBracketed(args) = &segment.arguments {
497
+
if let Some(syn::GenericArgument::Type(inner)) = args.args.first() {
498
+
return (inner, false);
499
+
}
500
+
}
501
+
}
502
+
}
503
+
}
504
+
(ty, true)
505
+
}
506
+
507
+
/// Generate LexObjectProperty tokens for a field
508
+
fn generate_lex_property(
509
+
rust_type: &Type,
510
+
constraints: &LexiconFieldAttrs,
511
+
) -> syn::Result<TokenStream> {
512
+
// Try to detect primitive type
513
+
let lex_type = rust_type_to_lexicon_type(rust_type);
514
+
515
+
match lex_type {
516
+
Some(LexiconPrimitiveType::Boolean) => Ok(quote! {
517
+
::jacquard_lexicon::lexicon::LexObjectProperty::Boolean(
518
+
::jacquard_lexicon::lexicon::LexBoolean {
519
+
description: None,
520
+
default: None,
521
+
r#const: None,
522
+
}
523
+
)
524
+
}),
525
+
Some(LexiconPrimitiveType::Integer) => {
526
+
let minimum = constraints
527
+
.minimum
528
+
.map(|v| quote! { Some(#v) })
529
+
.unwrap_or(quote! { None });
530
+
let maximum = constraints
531
+
.maximum
532
+
.map(|v| quote! { Some(#v) })
533
+
.unwrap_or(quote! { None });
534
+
535
+
Ok(quote! {
536
+
::jacquard_lexicon::lexicon::LexObjectProperty::Integer(
537
+
::jacquard_lexicon::lexicon::LexInteger {
538
+
description: None,
539
+
default: None,
540
+
minimum: #minimum,
541
+
maximum: #maximum,
542
+
r#enum: None,
543
+
r#const: None,
544
+
}
545
+
)
546
+
})
547
+
}
548
+
Some(LexiconPrimitiveType::String(format)) => generate_string_property(format, constraints),
549
+
Some(LexiconPrimitiveType::Bytes) => {
550
+
let max_length = constraints
551
+
.max_length
552
+
.map(|v| quote! { Some(#v) })
553
+
.unwrap_or(quote! { None });
554
+
let min_length = constraints
555
+
.min_length
556
+
.map(|v| quote! { Some(#v) })
557
+
.unwrap_or(quote! { None });
558
+
559
+
Ok(quote! {
560
+
::jacquard_lexicon::lexicon::LexObjectProperty::Bytes(
561
+
::jacquard_lexicon::lexicon::LexBytes {
562
+
description: None,
563
+
max_length: #max_length,
564
+
min_length: #min_length,
565
+
}
566
+
)
567
+
})
568
+
}
569
+
Some(LexiconPrimitiveType::CidLink) => Ok(quote! {
570
+
::jacquard_lexicon::lexicon::LexObjectProperty::CidLink(
571
+
::jacquard_lexicon::lexicon::LexCidLink {
572
+
description: None,
573
+
}
574
+
)
575
+
}),
576
+
Some(LexiconPrimitiveType::Blob) => Ok(quote! {
577
+
::jacquard_lexicon::lexicon::LexObjectProperty::Blob(
578
+
::jacquard_lexicon::lexicon::LexBlob {
579
+
description: None,
580
+
accept: None,
581
+
max_size: None,
582
+
}
583
+
)
584
+
}),
585
+
Some(LexiconPrimitiveType::Unknown) => Ok(quote! {
586
+
::jacquard_lexicon::lexicon::LexObjectProperty::Unknown(
587
+
::jacquard_lexicon::lexicon::LexUnknown {
588
+
description: None,
589
+
}
590
+
)
591
+
}),
592
+
Some(LexiconPrimitiveType::Array(item_type)) => {
593
+
let item_prop = generate_array_item(*item_type, constraints)?;
594
+
let max_length = constraints
595
+
.max_length
596
+
.map(|v| quote! { Some(#v) })
597
+
.unwrap_or(quote! { None });
598
+
let min_length = constraints
599
+
.min_length
600
+
.map(|v| quote! { Some(#v) })
601
+
.unwrap_or(quote! { None });
602
+
603
+
Ok(quote! {
604
+
::jacquard_lexicon::lexicon::LexObjectProperty::Array(
605
+
::jacquard_lexicon::lexicon::LexArray {
606
+
description: None,
607
+
items: #item_prop,
608
+
min_length: #min_length,
609
+
max_length: #max_length,
610
+
}
611
+
)
612
+
})
613
+
}
614
+
None => {
615
+
// Not a recognized primitive - check for explicit ref or trait bound
616
+
if let Some(ref_nsid) = &constraints.explicit_ref {
617
+
Ok(quote! {
618
+
::jacquard_lexicon::lexicon::LexObjectProperty::Ref(
619
+
::jacquard_lexicon::lexicon::LexRef {
620
+
description: None,
621
+
r#ref: #ref_nsid.into(),
622
+
}
623
+
)
624
+
})
625
+
} else {
626
+
// Try to use type's LexiconSchema impl
627
+
Ok(quote! {
628
+
{
629
+
// Use the type's schema_id method
630
+
let ref_nsid = <#rust_type as ::jacquard_lexicon::schema::LexiconSchema>::schema_id();
631
+
::jacquard_lexicon::lexicon::LexObjectProperty::Ref(
632
+
::jacquard_lexicon::lexicon::LexRef {
633
+
description: None,
634
+
r#ref: ref_nsid.to_string().into(),
635
+
}
636
+
)
637
+
}
638
+
})
639
+
}
640
+
}
641
+
_ => Err(syn::Error::new_spanned(
642
+
rust_type,
643
+
"unsupported type for lexicon schema generation",
644
+
)),
645
+
}
646
+
}
647
+
648
+
fn generate_array_item(
649
+
item_type: LexiconPrimitiveType,
650
+
_constraints: &LexiconFieldAttrs,
651
+
) -> syn::Result<TokenStream> {
652
+
match item_type {
653
+
LexiconPrimitiveType::String(format) => {
654
+
let format_token = string_format_token(format);
655
+
Ok(quote! {
656
+
::jacquard_lexicon::lexicon::LexArrayItem::String(
657
+
::jacquard_lexicon::lexicon::LexString {
658
+
description: None,
659
+
format: #format_token,
660
+
default: None,
661
+
min_length: None,
662
+
max_length: None,
663
+
min_graphemes: None,
664
+
max_graphemes: None,
665
+
r#enum: None,
666
+
r#const: None,
667
+
known_values: None,
668
+
}
669
+
)
670
+
})
671
+
}
672
+
LexiconPrimitiveType::Integer => Ok(quote! {
673
+
::jacquard_lexicon::lexicon::LexArrayItem::Integer(
674
+
::jacquard_lexicon::lexicon::LexInteger {
675
+
description: None,
676
+
default: None,
677
+
minimum: None,
678
+
maximum: None,
679
+
r#enum: None,
680
+
r#const: None,
681
+
}
682
+
)
683
+
}),
684
+
_ => Ok(quote! {
685
+
::jacquard_lexicon::lexicon::LexArrayItem::Unknown(
686
+
::jacquard_lexicon::lexicon::LexUnknown {
687
+
description: None,
688
+
}
689
+
)
690
+
}),
691
+
}
692
+
}
693
+
694
+
fn generate_string_property(
695
+
format: StringFormat,
696
+
constraints: &LexiconFieldAttrs,
697
+
) -> syn::Result<TokenStream> {
698
+
let format_token = string_format_token(format);
699
+
700
+
let max_length = constraints
701
+
.max_length
702
+
.map(|v| quote! { Some(#v) })
703
+
.unwrap_or(quote! { None });
704
+
let max_graphemes = constraints
705
+
.max_graphemes
706
+
.map(|v| quote! { Some(#v) })
707
+
.unwrap_or(quote! { None });
708
+
let min_length = constraints
709
+
.min_length
710
+
.map(|v| quote! { Some(#v) })
711
+
.unwrap_or(quote! { None });
712
+
let min_graphemes = constraints
713
+
.min_graphemes
714
+
.map(|v| quote! { Some(#v) })
715
+
.unwrap_or(quote! { None });
716
+
717
+
Ok(quote! {
718
+
::jacquard_lexicon::lexicon::LexObjectProperty::String(
719
+
::jacquard_lexicon::lexicon::LexString {
720
+
description: None,
721
+
format: #format_token,
722
+
default: None,
723
+
min_length: #min_length,
724
+
max_length: #max_length,
725
+
min_graphemes: #min_graphemes,
726
+
max_graphemes: #max_graphemes,
727
+
r#enum: None,
728
+
r#const: None,
729
+
known_values: None,
730
+
}
731
+
)
732
+
})
733
+
}
734
+
735
+
fn string_format_token(format: StringFormat) -> TokenStream {
736
+
match format {
737
+
StringFormat::Plain => quote! { None },
738
+
StringFormat::Did => {
739
+
quote! { Some(::jacquard_lexicon::lexicon::LexStringFormat::Did) }
740
+
}
741
+
StringFormat::Handle => {
742
+
quote! { Some(::jacquard_lexicon::lexicon::LexStringFormat::Handle) }
743
+
}
744
+
StringFormat::AtUri => {
745
+
quote! { Some(::jacquard_lexicon::lexicon::LexStringFormat::AtUri) }
746
+
}
747
+
StringFormat::Nsid => {
748
+
quote! { Some(::jacquard_lexicon::lexicon::LexStringFormat::Nsid) }
749
+
}
750
+
StringFormat::Cid => {
751
+
quote! { Some(::jacquard_lexicon::lexicon::LexStringFormat::Cid) }
752
+
}
753
+
StringFormat::Datetime => {
754
+
quote! { Some(::jacquard_lexicon::lexicon::LexStringFormat::Datetime) }
755
+
}
756
+
StringFormat::Language => {
757
+
quote! { Some(::jacquard_lexicon::lexicon::LexStringFormat::Language) }
758
+
}
759
+
StringFormat::Tid => {
760
+
quote! { Some(::jacquard_lexicon::lexicon::LexStringFormat::Tid) }
761
+
}
762
+
StringFormat::RecordKey => {
763
+
quote! { Some(::jacquard_lexicon::lexicon::LexStringFormat::RecordKey) }
764
+
}
765
+
StringFormat::AtIdentifier => {
766
+
quote! { Some(::jacquard_lexicon::lexicon::LexStringFormat::AtIdentifier) }
767
+
}
768
+
StringFormat::Uri => {
769
+
quote! { Some(::jacquard_lexicon::lexicon::LexStringFormat::Uri) }
770
+
}
771
+
}
772
+
}
773
+
774
+
fn generate_doc_impl(
775
+
nsid: &str,
776
+
type_attrs: &LexiconTypeAttrs,
777
+
field_defs: &[FieldDef],
778
+
) -> syn::Result<TokenStream> {
779
+
// Build properties map
780
+
let properties: Vec<_> = field_defs
781
+
.iter()
782
+
.map(|def| {
783
+
let name = &def.schema_name;
784
+
let lex_type = &def.lex_type;
785
+
quote! {
786
+
(#name.into(), #lex_type)
787
+
}
788
+
})
789
+
.collect();
790
+
791
+
// Build required array
792
+
let required: Vec<_> = field_defs
793
+
.iter()
794
+
.filter(|def| def.required)
795
+
.map(|def| {
796
+
let name = &def.schema_name;
797
+
quote! { #name.into() }
798
+
})
799
+
.collect();
800
+
801
+
let required_field = if required.is_empty() {
802
+
quote! { None }
803
+
} else {
804
+
quote! { Some(vec![#(#required),*]) }
805
+
};
806
+
807
+
// Determine user type based on kind
808
+
let user_type = match type_attrs.kind {
809
+
Some(LexiconTypeKind::Record) => {
810
+
let key = type_attrs
811
+
.key
812
+
.as_ref()
813
+
.map(|k| quote! { Some(#k.into()) })
814
+
.unwrap_or(quote! { None });
815
+
816
+
quote! {
817
+
::jacquard_lexicon::lexicon::LexUserType::Record(
818
+
::jacquard_lexicon::lexicon::LexRecord {
819
+
description: None,
820
+
key: #key,
821
+
record: ::jacquard_lexicon::lexicon::LexRecordRecord::Object(
822
+
::jacquard_lexicon::lexicon::LexObject {
823
+
description: None,
824
+
required: #required_field,
825
+
nullable: None,
826
+
properties: [#(#properties),*].into(),
827
+
}
828
+
),
829
+
}
830
+
)
831
+
}
832
+
}
833
+
Some(LexiconTypeKind::Query) => {
834
+
quote! {
835
+
::jacquard_lexicon::lexicon::LexUserType::Query(
836
+
::jacquard_lexicon::lexicon::LexQuery {
837
+
description: None,
838
+
parameters: Some(::jacquard_lexicon::lexicon::LexObject {
839
+
description: None,
840
+
required: #required_field,
841
+
nullable: None,
842
+
properties: [#(#properties),*].into(),
843
+
}),
844
+
output: None,
845
+
errors: None,
846
+
}
847
+
)
848
+
}
849
+
}
850
+
Some(LexiconTypeKind::Procedure) => {
851
+
quote! {
852
+
::jacquard_lexicon::lexicon::LexUserType::Procedure(
853
+
::jacquard_lexicon::lexicon::LexProcedure {
854
+
description: None,
855
+
input: Some(::jacquard_lexicon::lexicon::LexProcedureIO {
856
+
description: None,
857
+
encoding: "application/json".into(),
858
+
schema: Some(::jacquard_lexicon::lexicon::LexProcedureSchema::Object(
859
+
::jacquard_lexicon::lexicon::LexObject {
860
+
description: None,
861
+
required: #required_field,
862
+
nullable: None,
863
+
properties: [#(#properties),*].into(),
864
+
}
865
+
)),
866
+
}),
867
+
output: None,
868
+
errors: None,
869
+
}
870
+
)
871
+
}
872
+
}
873
+
_ => {
874
+
// Default: Object type
875
+
quote! {
876
+
::jacquard_lexicon::lexicon::LexUserType::Object(
877
+
::jacquard_lexicon::lexicon::LexObject {
878
+
description: None,
879
+
required: #required_field,
880
+
nullable: None,
881
+
properties: [#(#properties),*].into(),
882
+
}
883
+
)
884
+
}
885
+
}
886
+
};
887
+
888
+
Ok(quote! {
889
+
{
890
+
let mut defs = ::std::collections::BTreeMap::new();
891
+
defs.insert("main".into(), #user_type);
892
+
893
+
::jacquard_lexicon::lexicon::LexiconDoc {
894
+
lexicon: ::jacquard_lexicon::lexicon::Lexicon::Lexicon1,
895
+
id: #nsid.into(),
896
+
revision: None,
897
+
description: None,
898
+
defs,
899
+
}
900
+
}
901
+
})
902
+
}
903
+
904
+
fn generate_validation(
905
+
fields: &syn::punctuated::Punctuated<syn::Field, syn::Token![,]>,
906
+
rename_all: Option<RenameRule>,
907
+
) -> syn::Result<TokenStream> {
908
+
let mut checks = Vec::new();
909
+
910
+
for field in fields {
911
+
let field_name = field.ident.as_ref().unwrap();
912
+
let field_name_str = field_name.to_string();
913
+
914
+
// Skip extra_data
915
+
if field_name_str == "extra_data" {
916
+
continue;
917
+
}
918
+
919
+
let lex_attrs = parse_field_attrs(&field.attrs)?;
920
+
let serde_attrs = parse_serde_attrs(&field.attrs)?;
921
+
922
+
if serde_attrs.skip {
923
+
continue;
924
+
}
925
+
926
+
// Get actual field name for errors
927
+
let display_name = if let Some(rename) = serde_attrs.rename {
928
+
rename
929
+
} else if let Some(rule) = rename_all {
930
+
rule.apply(&field_name_str)
931
+
} else {
932
+
field_name_str.clone()
933
+
};
934
+
935
+
// Extract inner type if Option
936
+
let (inner_type, is_required) = extract_option_inner(&field.ty);
937
+
938
+
// Generate checks based on type and constraints
939
+
let field_checks = generate_field_validation(
940
+
field_name,
941
+
&display_name,
942
+
inner_type,
943
+
is_required,
944
+
&lex_attrs,
945
+
)?;
946
+
947
+
checks.extend(field_checks);
948
+
}
949
+
950
+
if checks.is_empty() {
951
+
Ok(quote! { Ok(()) })
952
+
} else {
953
+
Ok(quote! {
954
+
let mut errors = Vec::new();
955
+
956
+
#(#checks)*
957
+
958
+
if errors.is_empty() {
959
+
Ok(())
960
+
} else if errors.len() == 1 {
961
+
Err(errors.into_iter().next().unwrap())
962
+
} else {
963
+
Err(::jacquard_lexicon::schema::ValidationError::Multiple(errors))
964
+
}
965
+
})
966
+
}
967
+
}
968
+
969
+
fn generate_field_validation(
970
+
field_ident: &Ident,
971
+
display_name: &str,
972
+
field_type: &Type,
973
+
is_required: bool,
974
+
constraints: &LexiconFieldAttrs,
975
+
) -> syn::Result<Vec<TokenStream>> {
976
+
let mut checks = Vec::new();
977
+
978
+
// Determine base type
979
+
let lex_type = rust_type_to_lexicon_type(field_type);
980
+
981
+
// Build accessor for the field value
982
+
let (value_binding, value_expr) = if is_required {
983
+
(quote! { let value = &self.#field_ident; }, quote! { value })
984
+
} else {
985
+
(
986
+
quote! {},
987
+
quote! {
988
+
match &self.#field_ident {
989
+
Some(v) => v,
990
+
None => continue,
991
+
}
992
+
},
993
+
)
994
+
};
995
+
996
+
match lex_type {
997
+
Some(LexiconPrimitiveType::String(_)) => {
998
+
// String constraints
999
+
if let Some(max_len) = constraints.max_length {
1000
+
checks.push(quote! {
1001
+
#value_binding
1002
+
if #value_expr.len() > #max_len {
1003
+
errors.push(::jacquard_lexicon::schema::ValidationError::MaxLength {
1004
+
field: #display_name,
1005
+
max: #max_len,
1006
+
actual: #value_expr.len(),
1007
+
});
1008
+
}
1009
+
});
1010
+
}
1011
+
1012
+
if let Some(max_graphemes) = constraints.max_graphemes {
1013
+
checks.push(quote! {
1014
+
#value_binding
1015
+
let count = ::unicode_segmentation::UnicodeSegmentation::graphemes(
1016
+
#value_expr.as_ref(),
1017
+
true
1018
+
).count();
1019
+
if count > #max_graphemes {
1020
+
errors.push(::jacquard_lexicon::schema::ValidationError::MaxGraphemes {
1021
+
field: #display_name,
1022
+
max: #max_graphemes,
1023
+
actual: count,
1024
+
});
1025
+
}
1026
+
});
1027
+
}
1028
+
1029
+
if let Some(min_len) = constraints.min_length {
1030
+
checks.push(quote! {
1031
+
#value_binding
1032
+
if #value_expr.len() < #min_len {
1033
+
errors.push(::jacquard_lexicon::schema::ValidationError::MinLength {
1034
+
field: #display_name,
1035
+
min: #min_len,
1036
+
actual: #value_expr.len(),
1037
+
});
1038
+
}
1039
+
});
1040
+
}
1041
+
1042
+
if let Some(min_graphemes) = constraints.min_graphemes {
1043
+
checks.push(quote! {
1044
+
#value_binding
1045
+
let count = ::unicode_segmentation::UnicodeSegmentation::graphemes(
1046
+
#value_expr.as_ref(),
1047
+
true
1048
+
).count();
1049
+
if count < #min_graphemes {
1050
+
errors.push(::jacquard_lexicon::schema::ValidationError::MinGraphemes {
1051
+
field: #display_name,
1052
+
min: #min_graphemes,
1053
+
actual: count,
1054
+
});
1055
+
}
1056
+
});
1057
+
}
1058
+
}
1059
+
Some(LexiconPrimitiveType::Integer) => {
1060
+
if let Some(maximum) = constraints.maximum {
1061
+
checks.push(quote! {
1062
+
#value_binding
1063
+
if *#value_expr > #maximum {
1064
+
errors.push(::jacquard_lexicon::schema::ValidationError::Maximum {
1065
+
field: #display_name,
1066
+
max: #maximum,
1067
+
actual: *#value_expr,
1068
+
});
1069
+
}
1070
+
});
1071
+
}
1072
+
1073
+
if let Some(minimum) = constraints.minimum {
1074
+
checks.push(quote! {
1075
+
#value_binding
1076
+
if *#value_expr < #minimum {
1077
+
errors.push(::jacquard_lexicon::schema::ValidationError::Minimum {
1078
+
field: #display_name,
1079
+
min: #minimum,
1080
+
actual: *#value_expr,
1081
+
});
1082
+
}
1083
+
});
1084
+
}
1085
+
}
1086
+
Some(LexiconPrimitiveType::Array(_)) => {
1087
+
if let Some(max_len) = constraints.max_length {
1088
+
checks.push(quote! {
1089
+
#value_binding
1090
+
if #value_expr.len() > #max_len {
1091
+
errors.push(::jacquard_lexicon::schema::ValidationError::MaxLength {
1092
+
field: #display_name,
1093
+
max: #max_len,
1094
+
actual: #value_expr.len(),
1095
+
});
1096
+
}
1097
+
});
1098
+
}
1099
+
1100
+
if let Some(min_len) = constraints.min_length {
1101
+
checks.push(quote! {
1102
+
#value_binding
1103
+
if #value_expr.len() < #min_len {
1104
+
errors.push(::jacquard_lexicon::schema::ValidationError::MinLength {
1105
+
field: #display_name,
1106
+
min: #min_len,
1107
+
actual: #value_expr.len(),
1108
+
});
1109
+
}
1110
+
});
1111
+
}
1112
+
}
1113
+
_ => {
1114
+
// No built-in validation for this type
1115
+
}
1116
+
}
1117
+
1118
+
Ok(checks)
1119
+
}
1120
+
1121
+
/// Enum implementation (union support)
1122
+
fn impl_for_enum(
1123
+
input: &DeriveInput,
1124
+
type_attrs: &LexiconTypeAttrs,
1125
+
nsid: &str,
1126
+
data_enum: &syn::DataEnum,
1127
+
) -> syn::Result<TokenStream> {
1128
+
let name = &input.ident;
1129
+
let generics = &input.generics;
1130
+
1131
+
// Detect lifetime
1132
+
let has_lifetime = generics.lifetimes().next().is_some();
1133
+
let lifetime = if has_lifetime {
1134
+
quote! { <'_> }
1135
+
} else {
1136
+
quote! {}
1137
+
};
1138
+
1139
+
// Check if this is an open union (has #[open_union] attribute)
1140
+
let is_open = has_open_union_attr(&input.attrs);
1141
+
1142
+
// Extract variant refs
1143
+
let mut refs = Vec::new();
1144
+
for variant in &data_enum.variants {
1145
+
// Skip Unknown variant (added by #[open_union] macro)
1146
+
if variant.ident == "Unknown" {
1147
+
continue;
1148
+
}
1149
+
1150
+
// Get NSID for this variant
1151
+
let variant_ref = extract_variant_ref(variant, nsid)?;
1152
+
refs.push(variant_ref);
1153
+
}
1154
+
1155
+
// Generate union def
1156
+
// Only set closed: true for explicitly closed unions (no #[open_union])
1157
+
// Open unions omit the field (defaults to open per spec)
1158
+
let closed_field = if !is_open {
1159
+
quote! { Some(true) }
1160
+
} else {
1161
+
quote! { None }
1162
+
};
1163
+
1164
+
let user_type = quote! {
1165
+
::jacquard_lexicon::lexicon::LexUserType::Union(
1166
+
::jacquard_lexicon::lexicon::LexRefUnion {
1167
+
description: None,
1168
+
refs: vec![#(#refs.into()),*],
1169
+
closed: #closed_field,
1170
+
}
1171
+
)
1172
+
};
1173
+
1174
+
Ok(quote! {
1175
+
impl #generics ::jacquard_lexicon::schema::LexiconSchema for #name #lifetime {
1176
+
fn nsid() -> &'static str {
1177
+
#nsid
1178
+
}
1179
+
1180
+
fn schema_id() -> ::jacquard_common::CowStr<'static> {
1181
+
::jacquard_common::CowStr::new_static(#nsid)
1182
+
}
1183
+
1184
+
fn lexicon_doc(
1185
+
_generator: &mut ::jacquard_lexicon::schema::LexiconGenerator
1186
+
) -> ::jacquard_lexicon::lexicon::LexiconDoc<'static> {
1187
+
let mut defs = ::std::collections::BTreeMap::new();
1188
+
defs.insert("main".into(), #user_type);
1189
+
1190
+
::jacquard_lexicon::lexicon::LexiconDoc {
1191
+
lexicon: ::jacquard_lexicon::lexicon::Lexicon::Lexicon1,
1192
+
id: #nsid.into(),
1193
+
revision: None,
1194
+
description: None,
1195
+
defs,
1196
+
}
1197
+
}
1198
+
}
1199
+
1200
+
::inventory::submit! {
1201
+
::jacquard_lexicon::schema::LexiconSchemaRef {
1202
+
nsid: #nsid,
1203
+
provider: || {
1204
+
let mut generator = ::jacquard_lexicon::schema::LexiconGenerator::new(#nsid);
1205
+
#name::lexicon_doc(&mut generator)
1206
+
},
1207
+
}
1208
+
}
1209
+
})
1210
+
}
1211
+
1212
+
/// Check if type has #[open_union] attribute
1213
+
fn has_open_union_attr(attrs: &[Attribute]) -> bool {
1214
+
attrs.iter().any(|attr| attr.path().is_ident("open_union"))
1215
+
}
1216
+
1217
+
/// Extract NSID ref for a variant
1218
+
fn extract_variant_ref(variant: &syn::Variant, base_nsid: &str) -> syn::Result<String> {
1219
+
// Priority 1: Check for #[nsid = "..."] attribute
1220
+
for attr in &variant.attrs {
1221
+
if attr.path().is_ident("nsid") {
1222
+
if let syn::Meta::NameValue(meta) = &attr.meta {
1223
+
if let syn::Expr::Lit(expr_lit) = &meta.value {
1224
+
if let syn::Lit::Str(lit_str) = &expr_lit.lit {
1225
+
return Ok(lit_str.value());
1226
+
}
1227
+
}
1228
+
}
1229
+
}
1230
+
}
1231
+
1232
+
// Priority 2: Check for #[serde(rename = "...")] attribute
1233
+
for attr in &variant.attrs {
1234
+
if !attr.path().is_ident("serde") {
1235
+
continue;
1236
+
}
1237
+
1238
+
let mut rename = None;
1239
+
let _ = attr.parse_nested_meta(|meta| {
1240
+
if meta.path.is_ident("rename") {
1241
+
let value = meta.value()?;
1242
+
let lit: LitStr = value.parse()?;
1243
+
rename = Some(lit.value());
1244
+
}
1245
+
Ok(())
1246
+
});
1247
+
1248
+
if let Some(rename) = rename {
1249
+
return Ok(rename);
1250
+
}
1251
+
}
1252
+
1253
+
// Priority 3: For variants with non-primitive inner types, error
1254
+
// (caller should use #[nsid] or type must impl LexiconSchema)
1255
+
match &variant.fields {
1256
+
Fields::Unit => {
1257
+
// Unit variant - generate fragment ref: baseNsid#variantName
1258
+
let variant_name = variant.ident.to_string().to_lower_camel_case();
1259
+
Ok(format!("{}#{}", base_nsid, variant_name))
1260
+
}
1261
+
Fields::Unnamed(fields) if fields.unnamed.len() == 1 => {
1262
+
let ty = &fields.unnamed.first().unwrap().ty;
1263
+
1264
+
// Check if primitive - if so, error (unions need refs)
1265
+
if let Some(prim) = rust_type_to_lexicon_type(ty) {
1266
+
if is_primitive(&prim) {
1267
+
return Err(syn::Error::new_spanned(
1268
+
variant,
1269
+
"union variants with primitive inner types must use #[nsid] or #[serde(rename)] attribute",
1270
+
));
1271
+
}
1272
+
}
1273
+
1274
+
// Non-primitive - error, must have explicit attribute
1275
+
// (we can't call schema_id() at compile time)
1276
+
Err(syn::Error::new_spanned(
1277
+
variant,
1278
+
"union variants with non-primitive types must use #[nsid] or #[serde(rename)] attribute to specify the ref",
1279
+
))
1280
+
}
1281
+
_ => Err(syn::Error::new_spanned(
1282
+
variant,
1283
+
"union variants must be unit variants or have single unnamed field",
1284
+
)),
1285
+
}
1286
+
}
1287
+
1288
+
/// Check if a lexicon primitive type is actually a primitive (not a ref-able type)
1289
+
fn is_primitive(prim: &LexiconPrimitiveType) -> bool {
1290
+
matches!(
1291
+
prim,
1292
+
LexiconPrimitiveType::Boolean
1293
+
| LexiconPrimitiveType::Integer
1294
+
| LexiconPrimitiveType::String(_)
1295
+
| LexiconPrimitiveType::Bytes
1296
+
| LexiconPrimitiveType::Unknown
1297
+
)
1298
+
}
+2
crates/jacquard-lexicon/src/derive_impl/mod.rs
+2
crates/jacquard-lexicon/src/derive_impl/mod.rs
···
6
6
pub mod helpers;
7
7
pub mod into_static;
8
8
pub mod lexicon_attr;
9
+
pub mod lexicon_schema;
9
10
pub mod open_union_attr;
10
11
pub mod xrpc_request;
11
12
12
13
// Re-export the main entry points
13
14
pub use into_static::impl_derive_into_static;
14
15
pub use lexicon_attr::impl_lexicon;
16
+
pub use lexicon_schema::impl_derive_lexicon_schema;
15
17
pub use open_union_attr::impl_open_union;
16
18
pub use xrpc_request::impl_derive_xrpc_request;
+3
crates/jacquard-lexicon/src/lexicon.rs
+3
crates/jacquard-lexicon/src/lexicon.rs
···
406
406
CidLink(LexCidLink<'s>),
407
407
// lexUnknown
408
408
Unknown(LexUnknown<'s>),
409
+
// lexRefUnion
410
+
Union(LexRefUnion<'s>),
409
411
}
410
412
411
413
// IntoStatic implementations for all lexicon types
···
839
841
Self::Bytes(x) => LexUserType::Bytes(x.into_static()),
840
842
Self::CidLink(x) => LexUserType::CidLink(x.into_static()),
841
843
Self::Unknown(x) => LexUserType::Unknown(x.into_static()),
844
+
Self::Union(x) => LexUserType::Union(x.into_static()),
842
845
}
843
846
}
844
847
}
+15
-2
crates/jacquard-lexicon/src/schema.rs
+15
-2
crates/jacquard-lexicon/src/schema.rs
···
74
74
/// The schema ID for this type
75
75
///
76
76
/// Defaults to NSID. Override for fragments to include `#fragment` suffix.
77
-
fn schema_id() -> Cow<'static, str> {
78
-
Cow::Borrowed(Self::nsid())
77
+
fn schema_id() -> jacquard_common::CowStr<'static> {
78
+
jacquard_common::CowStr::new_static(Self::nsid())
79
79
}
80
80
81
81
/// Whether this type should be inlined vs referenced
···
308
308
#[error("invalid NSID: {nsid}")]
309
309
InvalidNsid { nsid: String },
310
310
}
311
+
312
+
/// Registry entry for schema discovery via inventory
313
+
///
314
+
/// Generated automatically by `#[derive(LexiconSchema)]` to enable runtime schema discovery.
315
+
/// Phase 3 will use this to extract all schemas from a binary.
316
+
pub struct LexiconSchemaRef {
317
+
/// The NSID for this schema
318
+
pub nsid: &'static str,
319
+
/// Function that generates the lexicon document
320
+
pub provider: fn() -> crate::lexicon::LexiconDoc<'static>,
321
+
}
322
+
323
+
inventory::collect!(LexiconSchemaRef);
311
324
312
325
#[cfg(test)]
313
326
mod tests {