Skip to main content

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}