A better Rust ATProto crate

lexicon schema derive macro

Orual 4c31392d ff8b28d6

Changed files
+1652 -16
crates
jacquard-axum
jacquard-derive
jacquard-identity
jacquard-lexicon
+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
··· 79 79 async fn test_handler(ExtractXrpc(req): ExtractXrpc<TestQueryRequest<'_>>) -> impl IntoResponse { 80 80 Json(TestQueryResponse { 81 81 did: req.did, 82 - extra_data: None, 82 + extra_data: BTreeMap::new(), 83 83 }) 84 84 } 85 85
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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 {