soothfast_spec/schema/docs.rs
1//! The rustdoc documents one resolution runs against.
2//!
3//! A crate's wire types routinely live in *other* crates of the same cargo
4//! workspace. Those are absent from its own rustdoc `index` — identifiable
5//! through `paths` but not walkable — which used to make every one of them a
6//! reported gap and an open schema. Carrying the sibling crates' documents
7//! alongside the package's own lets a type be walked out of whichever
8//! document defines it, so a workspace-local type is as fully derived as a
9//! local one.
10
11use std::collections::BTreeMap;
12
13use serde_json::{Map, Value};
14
15/// The rustdoc documents a resolution may walk: the package's own, plus zero
16/// or more workspace-local crates its types can be defined in.
17///
18/// Auxiliary documents are consulted in the order given, and only after the
19/// [`TypeTable`](super::TypeTable) — an explicit mapping is the documented
20/// escape hatch for a type soothfast would otherwise get wrong, so it has to
21/// keep winning over anything derived.
22#[derive(Clone, Copy)]
23pub struct Docs<'a> {
24 primary: &'a Value,
25 aux: &'a [Value],
26}
27
28impl<'a> Docs<'a> {
29 /// One document, the way single-crate resolution has always worked.
30 pub fn single(primary: &'a Value) -> Self {
31 Self { primary, aux: &[] }
32 }
33
34 /// A package's document plus the workspace-local crates it reaches into.
35 pub fn new(primary: &'a Value, aux: &'a [Value]) -> Self {
36 Self { primary, aux }
37 }
38
39 /// The package's own rustdoc document.
40 pub fn primary(self) -> &'a Value {
41 self.primary
42 }
43
44 /// The auxiliary documents, in consultation order.
45 pub fn aux(self) -> &'a [Value] {
46 self.aux
47 }
48}
49
50impl<'a> From<&'a Value> for Docs<'a> {
51 fn from(primary: &'a Value) -> Self {
52 Self::single(primary)
53 }
54}
55
56/// One rustdoc JSON document, with the lookup tables cross-document
57/// resolution needs.
58pub(super) struct Doc<'a> {
59 pub(super) index: &'a Map<String, Value>,
60 pub(super) paths: &'a Map<String, Value>,
61 /// This document's own crate, spelled the way rustdoc spells it
62 /// (underscored). Empty when the document does not say.
63 pub(super) krate: String,
64 /// Canonical path → id, restricted to walkable struct/enum/alias items.
65 by_path: BTreeMap<String, String>,
66 /// Bare type name → id, `None` where the name is ambiguous in this crate.
67 by_name: BTreeMap<String, Option<String>>,
68}
69
70impl<'a> Doc<'a> {
71 /// Open a document, indexed by the canonical paths it can walk.
72 ///
73 /// Every document is indexed, not just the auxiliary ones: the primary
74 /// document's own types take part in naming, and two same-named types in
75 /// one crate have to be told apart exactly as two crates' are.
76 pub(super) fn open(doc: &'a Value) -> Result<Self, String> {
77 let index = doc["index"]
78 .as_object()
79 .ok_or("rustdoc JSON has no `index` object")?;
80 let paths = doc["paths"]
81 .as_object()
82 .ok_or("rustdoc JSON has no `paths` object")?;
83 let mut d = Self {
84 index,
85 paths,
86 krate: crate_name(doc, paths),
87 by_path: BTreeMap::new(),
88 by_name: BTreeMap::new(),
89 };
90 for (id, entry) in paths {
91 if !matches!(
92 entry["kind"].as_str(),
93 Some("struct" | "enum" | "type_alias" | "typedef")
94 ) || !d.index.contains_key(id)
95 {
96 continue;
97 }
98 let Some(path) = joined_path(entry) else {
99 continue;
100 };
101 let name = path.rsplit("::").next().unwrap_or(&path).to_string();
102 d.by_path.entry(path).or_insert_with(|| id.clone());
103 // A name claimed twice is ambiguous, and stays ambiguous.
104 d.by_name
105 .entry(name)
106 .and_modify(|slot| *slot = None)
107 .or_insert_with(|| Some(id.clone()));
108 }
109 Ok(d)
110 }
111
112 /// Every canonical path this document can actually walk, for naming.
113 pub(super) fn walkable(&self) -> impl Iterator<Item = &String> {
114 self.by_path.keys()
115 }
116
117 /// The item an id names in this document.
118 pub(super) fn item(&self, id: &Value) -> Option<&'a Value> {
119 self.index.get(&id_key(id)?)
120 }
121
122 /// Canonical definition path of an id, e.g. `core::time::Duration`.
123 pub(super) fn canonical(&self, id: &Value) -> Option<String> {
124 joined_path(self.paths.get(&id_key(id)?)?)
125 }
126
127 /// True when async-graphql, not serde, produces this type's JSON.
128 ///
129 /// Rustdoc drops the derive itself, so detection goes through the impls
130 /// the derive generates: only a type async-graphql serves implements one
131 /// of its container traits. `OutputType`/`InputType` are deliberately not
132 /// in the set — a custom `#[Scalar]` implements those too, and its wire
133 /// form is code, not fields.
134 pub(super) fn serves_graphql(&self, item: &Value) -> bool {
135 let inner = &item["inner"];
136 let Some(impls) = inner
137 .get("struct")
138 .or_else(|| inner.get("enum"))
139 .and_then(|k| k["impls"].as_array())
140 else {
141 return false;
142 };
143 impls
144 .iter()
145 .filter_map(|id| self.item(id))
146 .filter_map(|im| self.canonical(&im["inner"]["impl"]["trait"]["id"]))
147 .any(|path| is_graphql_container_trait(&path))
148 }
149
150 /// Locate a type this document defines, given the canonical path another
151 /// document reported for it.
152 ///
153 /// Exact path match first. Failing that, a bare-name match — but only
154 /// when the path names *this* crate and the name is unambiguous here, so
155 /// a re-export whose two documents disagree on the module path resolves
156 /// while two unrelated same-named types stay unresolved.
157 pub(super) fn locate(&self, canonical: &str) -> Option<Value> {
158 if let Some(id) = self.by_path.get(canonical) {
159 return Some(Value::String(id.clone()));
160 }
161 let first = canonical.split("::").next()?;
162 if self.krate.is_empty() || first != self.krate {
163 return None;
164 }
165 self.locate_name(canonical.rsplit("::").next()?)
166 }
167
168 /// Locate a type by its plain name, when it is unambiguous here.
169 pub(super) fn locate_name(&self, name: &str) -> Option<Value> {
170 self.by_name
171 .get(name)?
172 .as_ref()
173 .map(|id| Value::String(id.clone()))
174 }
175
176 /// Find a struct or enum by its plain name, for attribute overrides that
177 /// name a type rather than pointing at a signature.
178 pub(super) fn scan_type_id(&self, name: &str) -> Option<Value> {
179 self.index
180 .iter()
181 .find(|(_, item)| {
182 item["name"].as_str() == Some(name)
183 && (item["inner"].get("struct").is_some()
184 || item["inner"].get("enum").is_some())
185 })
186 .and_then(|(k, _)| {
187 k.parse::<u64>()
188 .ok()
189 .map(Value::from)
190 .or_else(|| Some(Value::String(k.clone())))
191 })
192 }
193}
194
195/// Rustdoc ids appear as integers or strings depending on version.
196fn id_key(id: &Value) -> Option<String> {
197 match id {
198 Value::Number(n) => Some(n.to_string()),
199 Value::String(s) => Some(s.clone()),
200 _ => None,
201 }
202}
203
204/// Traits only a type async-graphql *serves* implements. Deliberately not
205/// `OutputType`/`InputType`, which a custom `#[Scalar]` implements too.
206fn is_graphql_container_trait(path: &str) -> bool {
207 path.split("::").next() == Some("async_graphql")
208 && matches!(
209 path.rsplit("::").next(),
210 Some(
211 "ContainerType"
212 | "ObjectType"
213 | "InterfaceType"
214 | "UnionType"
215 | "InputObjectType"
216 | "OneofObjectType"
217 | "EnumType"
218 )
219 )
220}
221
222/// The `::`-joined path of a `paths` entry.
223fn joined_path(entry: &Value) -> Option<String> {
224 let segs: Vec<&str> = entry["path"]
225 .as_array()?
226 .iter()
227 .filter_map(|s| s.as_str())
228 .collect();
229 Some(segs.join("::"))
230}
231
232/// The crate a document documents: the first segment of its root module's
233/// path, falling back to any item the document owns.
234fn crate_name(doc: &Value, paths: &Map<String, Value>) -> String {
235 let root = doc
236 .get("root")
237 .and_then(id_key)
238 .and_then(|r| paths.get(&r))
239 .and_then(joined_path);
240 if let Some(name) = root.as_deref().and_then(|p| p.split("::").next())
241 && !name.is_empty()
242 {
243 return name.to_string();
244 }
245 paths
246 .values()
247 .find(|e| e["crate_id"].as_u64() == Some(0))
248 .and_then(joined_path)
249 .and_then(|p| p.split("::").next().map(String::from))
250 .unwrap_or_default()
251}