Skip to main content

soothfast_spec/schema/
types.rs

1//! Rustdoc type nodes → JSON Schema.
2//!
3//! Named local types resolve to `$ref`s into a components map, which both
4//! keeps the emitted spec readable and makes self-referential types
5//! terminate: a type already on the resolution stack yields its `$ref`
6//! immediately and the outer frame fills the definition.
7
8use std::collections::{BTreeMap, BTreeSet};
9
10use serde_json::{Value, json};
11
12use super::docs::{Doc, Docs};
13use super::foreign::{TypeMapping, TypeTable};
14use super::naming;
15use super::wire_names::WireNames;
16use super::{Gap, graphql_attrs, serde_attrs};
17
18/// Generic parameter bindings captured at a concrete use site: parameter name
19/// → (resolved schema, display name used for component naming).
20pub type Subst = BTreeMap<String, (Value, String)>;
21
22/// Walks rustdoc JSON, emitting JSON Schema and collecting [`Gap`]s.
23///
24/// Resolution may span several documents (see [`Docs`]), but `components`,
25/// the component-name registry and the cycle-breaking stack are shared across
26/// all of them: one type gets one component wherever it was defined.
27pub struct Resolver<'a> {
28    /// The documents in consultation order; `docs[0]` is the primary one.
29    docs: Vec<Doc<'a>>,
30    /// Which document the current descent is walking. A type pulled out of an
31    /// auxiliary document names its fields' types in that same document, so
32    /// the cursor moves with the descent and is restored on the way out.
33    cur: usize,
34    table: &'a TypeTable,
35    /// Named schemas, emitted as `components/schemas` in OpenAPI.
36    pub components: BTreeMap<String, Value>,
37    /// Canonical path → component-name stem, decided up front from the
38    /// documents alone (see [`naming`]). Because it does not depend on the
39    /// walk, every resolver over the same documents names a type identically,
40    /// which is what lets a dialect merge many operations' components.
41    stems: BTreeMap<String, String>,
42    /// Component name → the type identity holding it. The stems already keep
43    /// distinct types apart; this catches what they cannot see — a type with
44    /// no canonical path at all, and a monomorphisation suffix that happens
45    /// to spell another type's name.
46    owners: BTreeMap<String, String>,
47    pub gaps: Vec<Gap>,
48    /// Canonical paths walked out of an auxiliary document. Resolved, so
49    /// deliberately *not* gaps — reported only to say where they came from.
50    pub aux_resolved: BTreeSet<String>,
51    /// Component names currently being resolved, to break reference cycles.
52    stack: BTreeSet<String>,
53}
54
55impl<'a> Resolver<'a> {
56    /// Build a resolver over a parsed rustdoc JSON document, or over a
57    /// primary document plus its workspace-local companions ([`Docs`]).
58    pub fn new(docs: impl Into<Docs<'a>>, table: &'a TypeTable) -> Result<Self, String> {
59        let docs = docs.into();
60        let mut open = vec![Doc::open(docs.primary())?];
61        open.extend(docs.aux().iter().filter_map(|d| Doc::open(d).ok()));
62        let walkable: BTreeSet<String> = open
63            .iter()
64            .flat_map(Doc::walkable)
65            .map(String::clone)
66            .collect();
67        Ok(Self {
68            stems: naming::stems(&walkable),
69            docs: open,
70            cur: 0,
71            table,
72            components: BTreeMap::new(),
73            owners: BTreeMap::new(),
74            gaps: Vec::new(),
75            aux_resolved: BTreeSet::new(),
76            stack: BTreeSet::new(),
77        })
78    }
79
80    pub(super) fn item(&self, id: &Value) -> Option<&'a Value> {
81        self.docs[self.cur].item(id)
82    }
83
84    /// Find a struct or enum by its plain name, for attribute overrides that
85    /// name a type rather than pointing at a signature.
86    pub fn find_type_id(&self, name: &str) -> Option<Value> {
87        self.docs[0].scan_type_id(name)
88    }
89
90    /// Find an item by its fully-qualified path, e.g. `app::routes::create`.
91    ///
92    /// `#[route]` records handler ids as `module_path!()::fn_name`, which is
93    /// exactly the path rustdoc reports, so route lookup is a direct match.
94    pub fn find_by_path(&self, canonical: &str) -> Option<&'a Value> {
95        let doc = &self.docs[0];
96        let (id, _) = doc.paths.iter().find(|(_, entry)| {
97            entry["path"]
98                .as_array()
99                .map(|segs| {
100                    let joined: Vec<&str> = segs.iter().filter_map(|s| s.as_str()).collect();
101                    joined.join("::") == canonical
102                })
103                .unwrap_or(false)
104        })?;
105        doc.index.get(id)
106    }
107
108    /// Resolve a struct or enum named in an attribute, searching this crate
109    /// first and then the workspace-local documents.
110    pub fn resolve_by_name(&mut self, name: &str, at: &str) -> Option<Value> {
111        if let Some(id) = self.find_type_id(name) {
112            let node = json!({ "resolved_path": { "path": name, "id": id, "args": null } });
113            return Some(self.resolve(&node, &Subst::new(), at));
114        }
115        let found =
116            (1..self.docs.len()).find_map(|i| self.docs[i].locate_name(name).map(|id| (i, id)));
117        let (home, id) = found?;
118        self.note_aux(home, &id);
119        Some(self.resolve_named(home, &id, &[], &Subst::new(), at))
120    }
121
122    /// Canonical definition path of an id, in the document being walked.
123    pub(super) fn canonical(&self, id: &Value) -> Option<String> {
124        self.docs[self.cur].canonical(id)
125    }
126
127    /// Record that a type came out of an auxiliary crate rather than a gap.
128    fn note_aux(&mut self, home: usize, id: &Value) {
129        if let Some(path) = self.docs[home].canonical(id) {
130            self.aux_resolved.insert(path);
131        }
132    }
133
134    pub(super) fn record(&mut self, gap: Gap) {
135        if !self.gaps.contains(&gap) {
136            self.gaps.push(gap);
137        }
138    }
139
140    /// True when a type is `Option<..>`, which makes a field optional on the
141    /// wire independently of the schema its inner type produces.
142    pub fn is_option(ty: &Value) -> bool {
143        ty["resolved_path"]["path"]
144            .as_str()
145            .is_some_and(|p| p == "Option" || p.ends_with("::Option"))
146    }
147
148    /// Resolve one rustdoc type node into a JSON Schema.
149    ///
150    /// `at` is a human-readable location (`Item.when`) used for gap reports.
151    pub fn resolve(&mut self, ty: &Value, subst: &Subst, at: &str) -> Value {
152        if let Some(p) = ty.get("primitive").and_then(|v| v.as_str()) {
153            return primitive(p);
154        }
155        if let Some(name) = ty.get("generic").and_then(|v| v.as_str()) {
156            // An unbound parameter means the use site was not monomorphic.
157            return match subst.get(name) {
158                Some((schema, _)) => schema.clone(),
159                None => json!({}),
160            };
161        }
162        if let Some(bounds) = ty.get("impl_trait") {
163            return self.erased(bounds, at);
164        }
165        if let Some(dy) = ty.get("dyn_trait") {
166            return self.erased(&dy["traits"], at);
167        }
168        if let Some(inner) = ty.get("borrowed_ref") {
169            return self.resolve(&inner["type"], subst, at);
170        }
171        if let Some(inner) = ty.get("slice") {
172            let items = self.resolve(inner, subst, at);
173            return json!({ "type": "array", "items": items });
174        }
175        if let Some(arr) = ty.get("array") {
176            let items = self.resolve(&arr["type"], subst, at);
177            let mut schema = json!({ "type": "array", "items": items });
178            if let Some(len) = arr["len"].as_str().and_then(|l| l.parse::<u64>().ok()) {
179                schema["minItems"] = json!(len);
180                schema["maxItems"] = json!(len);
181            }
182            return schema;
183        }
184        if let Some(elems) = ty.get("tuple").and_then(|v| v.as_array()) {
185            // serde emits the unit tuple as null and others as a JSON array.
186            if elems.is_empty() {
187                return json!({ "type": "null" });
188            }
189            let items: Vec<Value> = elems.iter().map(|e| self.resolve(e, subst, at)).collect();
190            let n = items.len();
191            return json!({ "type": "array", "prefixItems": items,
192                           "minItems": n, "maxItems": n });
193        }
194        if ty.get("resolved_path").is_some() {
195            return self.resolve_path(&ty["resolved_path"], subst, at);
196        }
197        // Function pointers, qualified paths and inferred types have no wire
198        // meaning worth guessing at.
199        json!({})
200    }
201
202    fn erased(&mut self, bounds: &Value, at: &str) -> Value {
203        let bound = bounds
204            .as_array()
205            .and_then(|b| b.first())
206            .and_then(|b| b["trait_bound"]["trait"]["path"].as_str())
207            .unwrap_or("?")
208            .to_string();
209        self.record(Gap::Erased {
210            at: at.to_string(),
211            bound,
212        });
213        json!({})
214    }
215
216    fn resolve_path(&mut self, rp: &Value, subst: &Subst, at: &str) -> Value {
217        let display = rp["path"].as_str().unwrap_or_default();
218        let args = generic_args(rp);
219        let last = display.rsplit("::").next().unwrap_or(display);
220
221        // Standard containers are structural: they shape the schema rather
222        // than contributing a named component of their own.
223        match last {
224            "Option" => {
225                // Optionality is carried by the field, not the schema; a
226                // nullable inner schema is the honest rendering elsewhere.
227                return match args.first() {
228                    Some(inner) => self.resolve(inner, subst, at),
229                    None => json!({}),
230                };
231            }
232            "Box" | "Arc" | "Rc" | "Cow" | "RefCell" | "Cell" | "Mutex" | "RwLock" => {
233                return match args.first() {
234                    Some(inner) => self.resolve(inner, subst, at),
235                    None => json!({}),
236                };
237            }
238            "Vec" | "VecDeque" | "HashSet" | "BTreeSet" | "BinaryHeap" | "LinkedList" => {
239                let items = match args.first() {
240                    Some(inner) => self.resolve(inner, subst, at),
241                    None => json!({}),
242                };
243                let mut schema = json!({ "type": "array", "items": items });
244                if matches!(last, "HashSet" | "BTreeSet") {
245                    schema["uniqueItems"] = json!(true);
246                }
247                return schema;
248            }
249            "HashMap" | "BTreeMap" => {
250                let values = match args.get(1) {
251                    Some(v) => self.resolve(v, subst, at),
252                    None => json!({}),
253                };
254                return json!({ "type": "object", "additionalProperties": values });
255            }
256            "Result" => {
257                // serde's externally tagged representation of Result.
258                let ok = args
259                    .first()
260                    .map(|t| self.resolve(t, subst, at))
261                    .unwrap_or(json!({}));
262                let err = args
263                    .get(1)
264                    .map(|t| self.resolve(t, subst, at))
265                    .unwrap_or(json!({}));
266                return json!({ "oneOf": [
267                    { "type": "object", "properties": { "Ok": ok },
268                      "required": ["Ok"], "additionalProperties": false },
269                    { "type": "object", "properties": { "Err": err },
270                      "required": ["Err"], "additionalProperties": false },
271                ]});
272            }
273            // async-graphql's cursor-pagination wrapper — hand-written
274            // (`#[Object]` inside async-graphql itself, not derived from its
275            // own struct fields), so it can never be walked out of rustdoc
276            // JSON like a local type; its wire shape is fixed by the crate's
277            // own resolver, not by anything this repo defines. `Cursor` (the
278            // first argument) never reaches the wire itself — only through
279            // the `cursor` field the crate renders as a string — so only the
280            // second argument, the node type, needs resolving.
281            "Connection" => {
282                let node = args
283                    .get(1)
284                    .map(|t| self.resolve(t, subst, at))
285                    .unwrap_or(json!({}));
286                return json!({
287                    "type": "object",
288                    "properties": {
289                        "pageInfo": {
290                            "type": "object",
291                            "properties": {
292                                "hasPreviousPage": { "type": "boolean" },
293                                "hasNextPage": { "type": "boolean" },
294                                "startCursor": { "type": ["string", "null"] },
295                                "endCursor": { "type": ["string", "null"] },
296                            },
297                            "required": ["hasPreviousPage", "hasNextPage"],
298                        },
299                        "edges": {
300                            "type": "array",
301                            "items": {
302                                "type": "object",
303                                "properties": { "cursor": { "type": "string" }, "node": node.clone() },
304                                "required": ["cursor", "node"],
305                            },
306                        },
307                        "nodes": { "type": "array", "items": node },
308                    },
309                    "required": ["pageInfo", "edges", "nodes"],
310                });
311            }
312            _ => {}
313        }
314
315        let id = &rp["id"];
316        // A type in the current document's `index` can be walked; one only in
317        // `paths` is identifiable but opaque *here*.
318        if self.item(id).is_some() {
319            return self.resolve_named(self.cur, id, &args, subst, at);
320        }
321        let canonical = self.canonical(id).unwrap_or_else(|| display.to_string());
322        // The table first: an explicit mapping is the escape hatch for a type
323        // soothfast would derive wrongly, so it outranks anything derived.
324        match self.table.lookup(&canonical).cloned() {
325            Some(TypeMapping::Literal(schema)) => return schema,
326            Some(TypeMapping::Transparent { arg }) => {
327                return self.transparent(&canonical, arg, &args, subst, at);
328            }
329            None => {}
330        }
331        // Then the workspace: a sibling crate's type is as walkable as a local
332        // one once its own document is on hand.
333        if let Some((home, aux_id)) = self.locate_aux(&canonical) {
334            self.aux_resolved.insert(canonical);
335            return self.resolve_named(home, &aux_id, &args, subst, at);
336        }
337        if last == "String" || last == "str" {
338            return json!({ "type": "string" });
339        }
340        self.record(Gap::UnmappedForeign {
341            at: at.to_string(),
342            path: canonical,
343        });
344        json!({})
345    }
346
347    /// Resolve a wrapper mapped `transparent`: its wire shape is exactly its
348    /// type argument's, so it contributes no component and no `$ref` of its
349    /// own — the argument's schema stands in its place.
350    fn transparent(
351        &mut self,
352        path: &str,
353        arg: usize,
354        args: &[Value],
355        subst: &Subst,
356        at: &str,
357    ) -> Value {
358        match args.get(arg) {
359            Some(inner) => self.resolve(inner, subst, at),
360            None => {
361                // Nothing to forward to: a guess here would invent a shape.
362                self.record(Gap::TransparentWithoutArgument {
363                    at: at.to_string(),
364                    path: path.to_string(),
365                    arg,
366                });
367                json!({})
368            }
369        }
370    }
371
372    /// The first auxiliary document defining `canonical`, and its id there.
373    fn locate_aux(&self, canonical: &str) -> Option<(usize, Value)> {
374        (1..self.docs.len()).find_map(|i| self.docs[i].locate(canonical).map(|id| (i, id)))
375    }
376
377    /// Resolve a named type living in `home` to a `$ref`, defining the
378    /// component on first visit.
379    ///
380    /// Generic arguments come from the *use site*, so they are resolved
381    /// before the cursor moves; only the body walk happens in `home`.
382    fn resolve_named(
383        &mut self,
384        home: usize,
385        id: &Value,
386        args: &[Value],
387        subst: &Subst,
388        at: &str,
389    ) -> Value {
390        let Some(item) = self.docs[home].item(id) else {
391            return json!({});
392        };
393        let base = item["name"].as_str().unwrap_or("Anonymous").to_string();
394
395        // Bind the type's own generic parameters to this use site's args.
396        let params = generic_param_names(item);
397        let mut inner_subst = Subst::new();
398        let mut arg_names = Vec::new();
399        for (i, param) in params.iter().enumerate() {
400            let Some(arg) = args.get(i) else { continue };
401            let schema = self.resolve(arg, subst, at);
402            let name = type_display_name(arg, subst);
403            arg_names.push(name.clone());
404            inner_subst.insert(param.clone(), (schema, name));
405        }
406        let suffix = |stem: &str| match arg_names.is_empty() {
407            true => stem.to_string(),
408            false => format!("{stem}_{}", arg_names.join("_")),
409        };
410        // The stem is a property of the type, so every operation in the
411        // document names it the same way and their components can be merged.
412        let canonical = self.docs[home].canonical(id);
413        let stem = canonical
414            .as_ref()
415            .and_then(|c| self.stems.get(c))
416            .unwrap_or(&base);
417        let identity = suffix(canonical.as_ref().unwrap_or(&base));
418        let component = self.claim(&suffix(stem), &identity);
419        let reference = json!({ "$ref": format!("#/components/schemas/{component}") });
420
421        if self.components.contains_key(&component) || self.stack.contains(&component) {
422            return reference;
423        }
424        self.stack.insert(component.clone());
425        // Reserve the name before descending so a cycle finds it on the stack.
426        let outer = std::mem::replace(&mut self.cur, home);
427        let defined = self.define(item, &inner_subst, &component);
428        self.cur = outer;
429        self.stack.remove(&component);
430        self.components.insert(component, defined);
431        reference
432    }
433
434    /// Hold `display` for this type, or number it if another type has it.
435    ///
436    /// [`naming::stems`] has already told apart every type the documents can
437    /// see, so this only fires for the two things it cannot: a type rustdoc
438    /// gives no canonical path (identified by bare name alone, exactly as
439    /// before), and a monomorphisation suffix that happens to spell another
440    /// component's name. A name is never re-pointed at a second type.
441    fn claim(&mut self, display: &str, identity: &str) -> String {
442        let mut candidate = display.to_string();
443        let mut n = 1;
444        loop {
445            match self.owners.get(&candidate) {
446                Some(owner) if owner == identity => return candidate,
447                // `owners` is finite, so a free name is always reached.
448                Some(_) => {
449                    n += 1;
450                    candidate = format!("{display}_{n}");
451                }
452                None => {
453                    self.owners.insert(candidate.clone(), identity.to_string());
454                    return candidate;
455                }
456            }
457        }
458    }
459
460    /// Build the schema body for a local struct or enum.
461    fn define(&mut self, item: &'a Value, subst: &Subst, at: &str) -> Value {
462        let inner = &item["inner"];
463        if let Some(s) = inner.get("struct") {
464            return self.walk_struct(item, s, subst, at);
465        }
466        if let Some(e) = inner.get("enum") {
467            return self.walk_enum(item, e, subst, at);
468        }
469        if let Some(t) = inner.get("type_alias") {
470            return self.resolve(&t["type"], subst, at);
471        }
472        json!({})
473    }
474
475    /// Attach a `description` from the item's own doc comment when present.
476    pub(super) fn describe(item: &Value, schema: &mut Value) {
477        if let Some(doc) = item["docs"].as_str().filter(|d| !d.is_empty())
478            && let Some(obj) = schema.as_object_mut()
479        {
480            let first = doc.split("\n\n").next().unwrap_or(doc);
481            obj.insert("description".into(), json!(first.trim()));
482        }
483    }
484
485    /// Container-level serde attributes, reporting an unusable rename rule.
486    pub(super) fn container_attrs(&mut self, item: &Value, at: &str) -> WireNames {
487        let empty = Vec::new();
488        let attrs = item["attrs"].as_array().unwrap_or(&empty);
489        let serde = serde_attrs::container(attrs);
490
491        // A GraphQL-served type is known two ways: by the container traits its
492        // derive generated, and — for documents whose impls rustdoc did not
493        // record — by carrying a `#[graphql(...)]` attribute at all.
494        let gql = graphql_attrs::container(attrs);
495        let served = gql.present || self.docs[self.cur].serves_graphql(item);
496        let unknown = match served {
497            true => gql.unknown_rename_rule.clone(),
498            false => serde.unknown_rename_all.clone(),
499        };
500        if let Some(rule) = unknown {
501            self.record(Gap::UnknownRenameRule {
502                at: at.to_string(),
503                rule,
504            });
505        }
506        WireNames {
507            serde,
508            graphql: served.then_some(gql),
509        }
510    }
511}
512
513/// The angle-bracketed type arguments of a resolved path, in order.
514pub(super) fn generic_args(rp: &Value) -> Vec<Value> {
515    rp["args"]["angle_bracketed"]["args"]
516        .as_array()
517        .map(|args| args.iter().filter_map(|a| a.get("type").cloned()).collect())
518        .unwrap_or_default()
519}
520
521/// Names of a type's own generic parameters, in declaration order.
522fn generic_param_names(item: &Value) -> Vec<String> {
523    let inner = &item["inner"];
524    let generics = inner
525        .get("struct")
526        .or_else(|| inner.get("enum"))
527        .or_else(|| inner.get("type_alias"))
528        .map(|k| &k["generics"]);
529    generics
530        .and_then(|g| g["params"].as_array())
531        .map(|ps| {
532            ps.iter()
533                .filter(|p| p["kind"].get("type").is_some())
534                .filter_map(|p| p["name"].as_str().map(String::from))
535                .collect()
536        })
537        .unwrap_or_default()
538}
539
540/// A short display name for a type argument, used to name monomorphised
541/// components (`ApiResponse<Item>` → `ApiResponse_Item`).
542fn type_display_name(ty: &Value, subst: &Subst) -> String {
543    if let Some(p) = ty.get("primitive").and_then(|v| v.as_str()) {
544        return p.to_string();
545    }
546    if let Some(g) = ty.get("generic").and_then(|v| v.as_str()) {
547        return subst
548            .get(g)
549            .map(|(_, n)| n.clone())
550            .unwrap_or_else(|| g.to_string());
551    }
552    if let Some(path) = ty["resolved_path"]["path"].as_str() {
553        let base = path.rsplit("::").next().unwrap_or(path).to_string();
554        let args = generic_args(&ty["resolved_path"]);
555        if args.is_empty() {
556            return base;
557        }
558        let inner: Vec<String> = args.iter().map(|a| type_display_name(a, subst)).collect();
559        return format!("{base}_{}", inner.join("_"));
560    }
561    "T".to_string()
562}
563
564/// Rust primitive → JSON Schema, with the integer formats OpenAPI expects.
565fn primitive(p: &str) -> Value {
566    match p {
567        "bool" => json!({ "type": "boolean" }),
568        "char" => json!({ "type": "string", "minLength": 1, "maxLength": 1 }),
569        "str" => json!({ "type": "string" }),
570        "f32" => json!({ "type": "number", "format": "float" }),
571        "f64" => json!({ "type": "number", "format": "double" }),
572        "i8" | "i16" | "i32" => json!({ "type": "integer", "format": "int32" }),
573        "u8" | "u16" | "u32" => json!({ "type": "integer", "format": "int32", "minimum": 0 }),
574        "i64" | "i128" | "isize" => json!({ "type": "integer", "format": "int64" }),
575        "u64" | "u128" | "usize" => {
576            json!({ "type": "integer", "format": "int64", "minimum": 0 })
577        }
578        "()" | "never" => json!({ "type": "null" }),
579        _ => json!({}),
580    }
581}