jacquard_lexicon/schema.rs
1//! # Lexicon Schema Generation
2//!
3//! This module provides traits and utilities for generating AT Protocol lexicon schemas
4//! from Rust types. This is the reverse direction from the usual lexicon→Rust codegen.
5//!
6//! ## Use Cases
7//!
8//! - **Rapid prototyping**: Define types in Rust, generate schemas automatically
9//! - **Custom lexicons**: Easy creation of third-party lexicons
10//! - **Documentation**: Keep types and schemas in sync
11//! - **Runtime introspection**: Access schema metadata at runtime
12//!
13//! ## Usage
14//!
15//! ### Derive Macro
16//!
17//! Use `#[derive(LexiconSchema)]` to automatically generate schemas:
18//!
19//! ```rust,ignore
20//! use jacquard_lexicon::schema::LexiconSchema;
21//! use jacquard_common::CowStr;
22//!
23//! #[derive(LexiconSchema)]
24//! #[lexicon(nsid = "app.bsky.feed.post", record, key = "tid")]
25//! struct Post<'a> {
26//! #[lexicon(max_graphemes = 300, max_length = 3000)]
27//! text: CowStr<'a>,
28//! created_at: Datetime,
29//! }
30//! ```
31//!
32//! #### Constraint Attributes
33//!
34//! - **Field constraints**: `max_length`, `max_graphemes`, `min_length`, `min_graphemes`
35//! - **Array constraints**: `max_items`, `min_items` (for the array itself)
36//! - **Item constraints**: `item_max_length`, `item_max_graphemes`, etc. (for array items)
37//! - **Integer constraints**: `minimum`, `maximum`
38//! - **Refs**: `ref = "nsid"` to explicitly reference another type
39//! - **Unions**: `union` to mark a field as a union type
40//!
41//! #### Fragments
42//!
43//! Multiple types can share the same NSID using fragments:
44//!
45//! ```rust,ignore
46//! #[derive(LexiconSchema)]
47//! #[lexicon(nsid = "app.bsky.feed.post", fragment = "textSlice")]
48//! struct TextSlice {
49//! start: i64,
50//! end: i64,
51//! }
52//! ```
53//!
54//! ### Runtime Registry
55//!
56//! Access complete schemas (with all fragments merged) via the global registry:
57//!
58//! ```rust,ignore
59//! let registry = jacquard_lexicon::schema::global_registry();
60//! let post_doc = registry.get("app.bsky.feed.post").expect("schema exists");
61//!
62//! // The doc contains all defs: main, textSlice, entity, replyRef, etc.
63//! for (def_name, def) in &post_doc.defs {
64//! println!("Def: {}", def_name);
65//! }
66//! ```
67//!
68//! ## Design Pattern
69//!
70//! - **Trait-based**: Types implement `LexiconSchema` trait
71//! - **Inventory-based discovery**: Runtime schema registry via `inventory` crate
72//! - **Fragment merging**: Multiple types with same NSID have their defs merged
73//! - **Const literals**: Generated code emits schema as const data
74//! - **Validation**: Runtime constraint checking via `validate()` method
75
76pub mod builder;
77#[cfg(feature = "codegen")]
78pub mod from_ast;
79#[cfg(feature = "codegen")]
80pub mod type_mapping;
81
82use crate::lexicon::LexiconDoc;
83
84/// Trait for types that can generate lexicon schemas
85pub trait LexiconSchema {
86 /// The NSID for this type's primary definition
87 ///
88 /// For fragments, this is the base NSID (without `#fragment`).
89 fn nsid() -> &'static str;
90
91 /// The definition name within the lexicon document
92 ///
93 /// Returns "main" for the primary definition, or the fragment name for other defs.
94 /// For example, in a lexicon with multiple defs like `pub.leaflet.poll.definition`,
95 /// the main type returns "main" while the `Option` type returns "option".
96 fn def_name() -> &'static str {
97 "main"
98 }
99
100 /// The schema ID for this type
101 ///
102 /// Defaults to NSID. Override for fragments to include `#fragment` suffix.
103 fn schema_id() -> jacquard_common::CowStr<'static> {
104 jacquard_common::CowStr::new_static(Self::nsid())
105 }
106
107 /// Whether this type should be inlined vs referenced
108 ///
109 /// - `false` (default): Type becomes a def, references use `{"type": "ref", "ref": "nsid"}`
110 /// - `true`: Type's schema is inlined directly into parent
111 ///
112 /// Recursive types MUST return `false` to avoid infinite expansion.
113 fn inline_schema() -> bool {
114 false
115 }
116
117 /// Generate the lexicon document for this type
118 ///
119 /// Returns the complete lexicon schema for this type. Nested refs are resolved
120 /// at runtime via the inventory-based registry.
121 fn lexicon_doc() -> LexiconDoc<'static>;
122
123 /// Validate an instance against lexicon constraints
124 ///
125 /// Checks runtime constraints like `max_length`, `max_graphemes`, `minimum`, etc.
126 /// Returns `Ok(())` if valid, `Err` with details if invalid.
127 fn validate(&self) -> Result<(), crate::validation::ConstraintError> {
128 // Default impl: no constraints to check
129 Ok(())
130 }
131}
132
133/// Registry entry for schema discovery via inventory
134///
135/// Generated automatically by `#[derive(LexiconSchema)]` to enable runtime schema discovery.
136pub struct LexiconSchemaRef {
137 /// The NSID for this schema
138 pub nsid: &'static str,
139 /// The def name within the lexicon (e.g., "main", "textSlice")
140 pub def_name: &'static str,
141 /// Function that generates the lexicon document
142 pub provider: fn() -> crate::lexicon::LexiconDoc<'static>,
143}
144
145inventory::collect!(LexiconSchemaRef);
146
147/// Registry of lexicon schemas
148///
149/// Collects schemas from inventory at construction and supports runtime insertion.
150#[derive(Debug, Clone)]
151pub struct SchemaRegistry {
152 /// Schema documents indexed by NSID (concurrent access safe)
153 schemas: dashmap::DashMap<jacquard_common::smol_str::SmolStr, crate::lexicon::LexiconDoc<'static>>,
154}
155
156impl SchemaRegistry {
157 /// Build registry from inventory-collected schemas
158 pub fn from_inventory() -> Self {
159 use jacquard_common::smol_str::ToSmolStr;
160 let schemas = dashmap::DashMap::new();
161
162 for entry in inventory::iter::<LexiconSchemaRef> {
163 let doc = (entry.provider)();
164
165 // Get existing doc or create new one
166 let mut doc_entry = schemas.entry(entry.nsid.to_smolstr()).or_insert_with(|| {
167 crate::lexicon::LexiconDoc {
168 lexicon: crate::lexicon::Lexicon::Lexicon1,
169 id: jacquard_common::CowStr::new_static(entry.nsid),
170 revision: None,
171 description: None,
172 defs: Default::default(),
173 }
174 });
175
176 // Merge the defs from this schema
177 // Each type's lexicon_doc() now returns a doc with the def under its proper name
178 for (def_name, def) in doc.defs {
179 doc_entry.defs.insert(def_name, def);
180 }
181 }
182
183 Self { schemas }
184 }
185
186 /// Create an empty registry
187 pub fn new() -> Self {
188 Self {
189 schemas: dashmap::DashMap::new(),
190 }
191 }
192
193 /// Get schema by NSID
194 ///
195 /// IMPORTANT: Clone the returned schema immediately to avoid holding DashMap ref
196 pub fn get(&self, nsid: &str) -> Option<crate::lexicon::LexiconDoc<'static>> {
197 self.schemas.get(nsid).map(|doc| doc.clone())
198 }
199
200 /// Insert or update a schema (for runtime schema loading)
201 pub fn insert(&self, nsid: jacquard_common::smol_str::SmolStr, doc: crate::lexicon::LexiconDoc<'static>) {
202 self.schemas.insert(nsid, doc);
203 }
204
205 /// Get specific def from a schema
206 ///
207 /// IMPORTANT: Returns cloned def to avoid holding DashMap ref
208 pub fn get_def(
209 &self,
210 nsid: &str,
211 def_name: &str,
212 ) -> Option<crate::lexicon::LexUserType<'static>> {
213 // Clone immediately to release DashMap ref before returning
214 self.schemas
215 .get(nsid)
216 .and_then(|doc| doc.defs.get(def_name).cloned())
217 }
218}
219
220impl Default for SchemaRegistry {
221 fn default() -> Self {
222 Self::from_inventory()
223 }
224}
225
226/// Global schema registry built from inventory
227pub fn global_registry() -> &'static SchemaRegistry {
228 static REGISTRY: std::sync::LazyLock<SchemaRegistry> = std::sync::LazyLock::new(SchemaRegistry::from_inventory);
229 ®ISTRY
230}
231
232#[cfg(test)]
233mod tests {
234 use crate::validation::{ConstraintError, ValidationPath};
235
236 #[test]
237 fn test_validation_max_length() {
238 let err = ConstraintError::MaxLength {
239 path: ValidationPath::from_field("text"),
240 max: 100,
241 actual: 150,
242 };
243 assert!(err.to_string().contains("exceeds max length"));
244 }
245
246 #[test]
247 fn test_validation_max_graphemes() {
248 let err = ConstraintError::MaxGraphemes {
249 path: ValidationPath::from_field("text"),
250 max: 50,
251 actual: 75,
252 };
253 assert!(err.to_string().contains("exceeds max graphemes"));
254 }
255}