Skip to main content

aidoc_core/
error_catalog.rs

1//! Error-catalog extractor: build an LLM-facing catalog of every
2//! diagnostic emitted by the workspace, straight from rustdoc JSON.
3//!
4//! ## Consumer contract (zero coupling)
5//!
6//! Consumer crates need **no dependency on `cargo-aidoc`**. To have a
7//! struct or enum picked up as an error entry, the crate simply:
8//!
9//! 1. Derives `miette::Diagnostic` on the type (already the de facto
10//!    Rust standard for structured diagnostics).
11//! 2. Passes `code(...)` inside `#[diagnostic(...)]`. `help(...)` and
12//!    `url(...)` are optional but recommended — they land verbatim in
13//!    the catalog output.
14//! 3. Optionally embeds fenced code blocks in the type's doc comment
15//!    with a `code=<CODE>` tag in the info-string, e.g.
16//!    ` ```ebp,code=EBP001 ` or ` ```ebp,code=EBP001,fix `. Any fence
17//!    whose info-string contains `code=<this-entry's-code>` is
18//!    collected as a snippet under that entry.
19//!
20//! This mirrors the `docs.rs` bridge model: cargo-aidoc is the reader,
21//! consumers only ever emit information that already lives in their
22//! rustdoc JSON. A future Layer 2 (`aidoc-attrs`) can add opt-in
23//! `#[aidoc::...]` attributes gated behind `#[cfg_attr(aidoc, ...)]`
24//! for cases miette doesn't cover, without changing this contract.
25//!
26//! ## Extraction pipeline
27//!
28//! [`extract`] walks every crate in the workspace, and for each rustdoc
29//! item whose `attrs` contain a `#[diagnostic(...)]` line, produces one
30//! [`ErrorEntry`]. Entries are sorted by their `code` for deterministic
31//! output.
32//!
33//! The attribute parser (`parse_diagnostic_attr`) accepts the exact
34//! literal form rustdoc emits — including the `\n` that rustdoc inserts
35//! after commas inside the attribute — but is intentionally a
36//! hand-rolled string reader, not a proc-macro / syn parser. The
37//! surface area we care about is small (`code(NAME)` / `help("...")` /
38//! `url("...")`), and pulling in `syn` at this layer would drag in a
39//! large dependency tree for very little payoff.
40//!
41//! ## What is covered
42//!
43//! - **Struct-level** `#[diagnostic(code(...), ...)]` — one entry per
44//!   struct.
45//! - **Enum-level** `#[diagnostic(code(...), ...)]` — one entry per
46//!   enum (rare; miette allows at most one code per enum).
47//! - **Per-variant** `#[diagnostic(code(...), ...)]` on enum variants
48//!   — one entry per catalogued variant. This is the dominant
49//!   `thiserror + miette` shape and is what most real crates use;
50//!   see `try_build_entry` and the walk driven by `walk_enum_variants`.
51//!
52//! ## Not covered by MVP
53//!
54//! - Variant-level `source_code` / `label` / `related` metadata from
55//!   miette is intentionally not surfaced yet — those are
56//!   runtime-facing, not spec-facing.
57//! - Non-miette diagnostic frameworks (bevy_error, custom derives)
58//!   would need their own attr shape; the extractor is written so
59//!   another parser can be added alongside `parse_diagnostic_attr`
60//!   without touching the walk.
61
62use rustdoc_types::{Item, ItemEnum, Visibility};
63use serde::Serialize;
64
65use crate::index::{IndexedCrate, IndexedWorkspace};
66
67/// One catalogued diagnostic: everything cargo-aidoc knows about a
68/// single error code, sourced from a single item in the workspace.
69#[derive(Debug, Clone, Serialize)]
70pub struct ErrorEntry {
71    /// The stable error code (e.g. `"EBP001"`), taken verbatim from the
72    /// `code(...)` argument of `#[diagnostic(...)]`.
73    pub code: String,
74    /// The crate the entry was defined in.
75    pub crate_name: String,
76    /// Fully-qualified item path within the crate
77    /// (e.g. `aidoc_spike::MissingInitCtxSeed`).
78    pub item_path: String,
79    /// The `#[error("...")]` message template, if the type also derives
80    /// `thiserror::Error`. Placeholder tokens (`{field}`) are preserved.
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub message_template: Option<String>,
83    /// The `help("...")` string from `#[diagnostic]`, if any.
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub help: Option<String>,
86    /// The `url("...")` string from `#[diagnostic]`, if any.
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub url: Option<String>,
89    /// Full doc body of the item (crate-side `///` / `#[doc = ...]`).
90    /// Preserved verbatim so the catalog reader gets the original prose
91    /// — including any fenced blocks not tagged with this code.
92    pub docs: String,
93    /// Fenced code blocks whose info-string carries `code=<this.code>`.
94    /// Snippets are stored in the order they appear in `docs`.
95    pub snippets: Vec<Snippet>,
96}
97
98/// A fenced code block extracted from an error's doc comment.
99#[derive(Debug, Clone, Serialize)]
100pub struct Snippet {
101    /// The info-string's language token (the first comma-separated
102    /// segment): `ebp`, `rust`, `lua`, etc.
103    pub lang: String,
104    /// Bare (non-`key=value`) tags in the info-string, e.g. `"fix"` in
105    /// ` ```ebp,code=EBP001,fix `. Preserved in source order.
106    pub tags: Vec<String>,
107    /// The fence body, joined with `\n` and without the surrounding
108    /// ` ``` ` lines.
109    pub body: String,
110}
111
112/// Walk the workspace and produce every catalogued diagnostic.
113///
114/// Entries are sorted by [`code`](ErrorEntry::code) for deterministic
115/// `--check` behaviour. Items lacking a parseable `code(...)` are
116/// silently skipped; a future lint stage can surface those as warnings.
117pub fn extract(workspace: &IndexedWorkspace) -> Vec<ErrorEntry> {
118    let mut out = Vec::new();
119    for krate in &workspace.crates {
120        walk_crate(krate, &mut out);
121    }
122    out.sort_by(|a, b| a.code.cmp(&b.code));
123    out
124}
125
126fn walk_crate(krate: &IndexedCrate, out: &mut Vec<ErrorEntry>) {
127    let index = &krate.crate_data.index;
128    let Some(root_item) = index.get(&krate.crate_data.root) else {
129        return;
130    };
131    let ItemEnum::Module(root_module) = &root_item.inner else {
132        return;
133    };
134
135    // Crate-root path segment matches the rustdoc convention
136    // (underscore-normalised crate name), so a downstream link renderer
137    // can turn `aidoc_spike::MissingInitCtxSeed` into a valid rustdoc
138    // URL without further munging.
139    let root_slug = krate.name.replace('-', "_");
140    walk_module(krate, index, root_module, root_slug, out);
141}
142
143fn walk_module(
144    krate: &IndexedCrate,
145    index: &std::collections::HashMap<rustdoc_types::Id, Item>,
146    module: &rustdoc_types::Module,
147    prefix: String,
148    out: &mut Vec<ErrorEntry>,
149) {
150    for child_id in &module.items {
151        let Some(child) = index.get(child_id) else {
152            continue;
153        };
154        if !matches!(child.visibility, Visibility::Public) {
155            continue;
156        }
157        let Some(name) = child.name.as_deref() else {
158            continue;
159        };
160        let path = format!("{prefix}::{name}");
161
162        // Struct-level or enum-level `#[diagnostic(code(...))]`.
163        if let Some(entry) = try_build_entry(krate, child, &path) {
164            out.push(entry);
165        }
166
167        match &child.inner {
168            ItemEnum::Module(child_module) => {
169                walk_module(krate, index, child_module, path, out);
170            }
171            ItemEnum::Enum(child_enum) => {
172                walk_enum_variants(krate, index, child_enum, path, out);
173            }
174            _ => {}
175        }
176    }
177}
178
179/// Walk every variant of an enum and try to catalogue each one whose
180/// `attrs` carry `#[diagnostic(code(...))]`.
181///
182/// This is the dominant `thiserror + miette` shape:
183///
184/// ```rust,ignore
185/// #[derive(Debug, Error, Diagnostic)]
186/// pub enum EvalError {
187///     #[error("...")]
188///     #[diagnostic(code(E001), url("..."))]
189///     PathNotFound { .. },
190///     #[error("...")]
191///     #[diagnostic(code(E002))]
192///     TypeMismatch { .. },
193/// }
194/// ```
195///
196/// Each variant becomes its own [`ErrorEntry`] with `item_path` of the
197/// form `<crate>::<enum_path>::<VariantName>`. Variants without a
198/// `#[diagnostic(code(...))]` are silently skipped.
199fn walk_enum_variants(
200    krate: &IndexedCrate,
201    index: &std::collections::HashMap<rustdoc_types::Id, Item>,
202    enum_: &rustdoc_types::Enum,
203    enum_path: String,
204    out: &mut Vec<ErrorEntry>,
205) {
206    for variant_id in &enum_.variants {
207        let Some(variant) = index.get(variant_id) else {
208            continue;
209        };
210        let Some(name) = variant.name.as_deref() else {
211            continue;
212        };
213        let path = format!("{enum_path}::{name}");
214        if let Some(entry) = try_build_entry(krate, variant, &path) {
215            out.push(entry);
216        }
217    }
218}
219
220fn try_build_entry(krate: &IndexedCrate, item: &Item, item_path: &str) -> Option<ErrorEntry> {
221    let diag_attr = item.attrs.iter().find_map(find_diagnostic_line)?;
222    let parsed = parse_diagnostic_attr(&diag_attr)?;
223    let code = parsed.code?;
224
225    let message_template = item
226        .attrs
227        .iter()
228        .find_map(find_error_line)
229        .and_then(|s| parse_error_attr(&s));
230
231    let docs = item.docs.clone().unwrap_or_default();
232    let snippets = extract_snippets(&docs, &code);
233
234    Some(ErrorEntry {
235        code,
236        crate_name: krate.name.clone(),
237        item_path: item_path.to_owned(),
238        message_template,
239        help: parsed.help,
240        url: parsed.url,
241        docs,
242        snippets,
243    })
244}
245
246/// rustdoc emits `Item::attrs` as a `Vec<AttributeInfo>` in newer
247/// format versions; older versions used `Vec<String>`. In both cases
248/// the payload our extractor cares about is available as a string.
249///
250/// We accept both a raw `String` and a JSON-serialised object shape
251/// (`{"other": "#[diagnostic(...)]"}`) so we don't have to pin to one
252/// specific rustdoc-types major.
253fn find_diagnostic_line(attr: &rustdoc_types::Attribute) -> Option<String> {
254    let raw = attr_as_string(attr)?;
255    let trimmed = raw.trim();
256    if trimmed.starts_with("#[diagnostic(") {
257        Some(trimmed.to_owned())
258    } else {
259        None
260    }
261}
262
263fn find_error_line(attr: &rustdoc_types::Attribute) -> Option<String> {
264    let raw = attr_as_string(attr)?;
265    let trimmed = raw.trim();
266    if trimmed.starts_with("#[error(") {
267        Some(trimmed.to_owned())
268    } else {
269        None
270    }
271}
272
273/// Best-effort stringification of a rustdoc `Attribute`. The type is
274/// non-exhaustive across rustdoc-types versions; we serialise to JSON
275/// and pull the payload out of the shape we know.
276fn attr_as_string(attr: &rustdoc_types::Attribute) -> Option<String> {
277    // Fast path: some rustdoc-types versions expose an `Other(String)`
278    // variant. Debug-format is stable enough for our string starts-with
279    // checks, but we go through JSON to stay version-agnostic.
280    let value = serde_json::to_value(attr).ok()?;
281    if let Some(s) = value.as_str() {
282        return Some(s.to_owned());
283    }
284    if let Some(obj) = value.as_object()
285        && let Some(other) = obj.get("other").and_then(|v| v.as_str())
286    {
287        return Some(other.to_owned());
288    }
289    None
290}
291
292#[derive(Debug, Default)]
293struct ParsedDiagnostic {
294    code: Option<String>,
295    help: Option<String>,
296    url: Option<String>,
297}
298
299/// Parse the interior of `#[diagnostic(...)]`.
300///
301/// Accepts the exact shape rustdoc emits, including the `\n` that
302/// rustdoc inserts after commas between arguments. We recognise the
303/// three arguments cargo-aidoc uses today (`code`, `help`, `url`) and
304/// silently skip any others so the parser stays forward-compatible
305/// with new miette attributes.
306fn parse_diagnostic_attr(raw: &str) -> Option<ParsedDiagnostic> {
307    let inner = raw.strip_prefix("#[diagnostic(")?.strip_suffix(")]")?;
308    let mut out = ParsedDiagnostic::default();
309
310    for arg in split_top_level_args(inner) {
311        let arg = arg.trim();
312        if let Some(code) = arg.strip_prefix("code(").and_then(|s| s.strip_suffix(')')) {
313            out.code = Some(code.trim().to_owned());
314        } else if let Some(help) = arg
315            .strip_prefix("help(")
316            .and_then(|s| s.strip_suffix(')'))
317            .and_then(strip_quotes)
318        {
319            out.help = Some(help);
320        } else if let Some(url) = arg
321            .strip_prefix("url(")
322            .and_then(|s| s.strip_suffix(')'))
323            .and_then(strip_quotes)
324        {
325            out.url = Some(url);
326        }
327    }
328
329    Some(out)
330}
331
332fn parse_error_attr(raw: &str) -> Option<String> {
333    let inner = raw.strip_prefix("#[error(")?.strip_suffix(")]")?;
334    strip_quotes(inner.trim())
335}
336
337/// Strip a surrounding pair of `"..."` and unescape the two escapes
338/// rustdoc's attr serialiser emits (`\\` and `\"`).
339fn strip_quotes(s: &str) -> Option<String> {
340    let inner = s.strip_prefix('"')?.strip_suffix('"')?;
341    let mut out = String::with_capacity(inner.len());
342    let mut chars = inner.chars();
343    while let Some(ch) = chars.next() {
344        if ch == '\\' {
345            match chars.next() {
346                Some('"') => out.push('"'),
347                Some('\\') => out.push('\\'),
348                Some('n') => out.push('\n'),
349                Some(other) => {
350                    out.push('\\');
351                    out.push(other);
352                }
353                None => out.push('\\'),
354            }
355        } else {
356            out.push(ch);
357        }
358    }
359    Some(out)
360}
361
362/// Split the interior of a `foo(a, b(x, y), c)` argument list on
363/// top-level commas, respecting parentheses and `"..."` string literals.
364fn split_top_level_args(s: &str) -> Vec<String> {
365    let mut out = Vec::new();
366    let mut current = String::new();
367    let mut depth: i32 = 0;
368    let mut in_string = false;
369    let mut escape = false;
370
371    for ch in s.chars() {
372        if escape {
373            current.push(ch);
374            escape = false;
375            continue;
376        }
377        if in_string {
378            if ch == '\\' {
379                current.push(ch);
380                escape = true;
381            } else {
382                current.push(ch);
383                if ch == '"' {
384                    in_string = false;
385                }
386            }
387            continue;
388        }
389        match ch {
390            '"' => {
391                in_string = true;
392                current.push(ch);
393            }
394            '(' => {
395                depth += 1;
396                current.push(ch);
397            }
398            ')' => {
399                depth -= 1;
400                current.push(ch);
401            }
402            ',' if depth == 0 => {
403                out.push(std::mem::take(&mut current));
404            }
405            _ => current.push(ch),
406        }
407    }
408    if !current.trim().is_empty() {
409        out.push(current);
410    }
411    out
412}
413
414/// Walk `docs` and collect every fenced code block whose info-string
415/// contains `code=<target_code>` as a comma-separated tag.
416///
417/// Recognised info-string shape:
418///
419/// ```text
420/// <lang>[,tag_or_kv]*
421/// ```
422///
423/// Where each `tag_or_kv` is either a bare identifier (`fix`) or a
424/// `key=value` pair (`code=EBP001`). Only `key=value` pairs go into
425/// `key=value` matching; bare tokens land in [`Snippet::tags`].
426fn extract_snippets(docs: &str, target_code: &str) -> Vec<Snippet> {
427    let mut out = Vec::new();
428    let mut lines = docs.lines().peekable();
429
430    while let Some(line) = lines.next() {
431        let trimmed = line.trim_start();
432        let Some(info) = trimmed.strip_prefix("```") else {
433            continue;
434        };
435        if info.is_empty() {
436            // Bare fence with no info-string — not tagged, skip.
437            skip_to_fence_close(&mut lines);
438            continue;
439        }
440
441        let parts: Vec<&str> = info.split(',').map(str::trim).collect();
442        let lang = parts[0].to_owned();
443
444        let mut tags = Vec::new();
445        let mut has_target_code = false;
446
447        for part in parts.iter().skip(1) {
448            if let Some((k, v)) = part.split_once('=') {
449                if k.trim() == "code" && v.trim() == target_code {
450                    has_target_code = true;
451                }
452                // Other kv pairs currently ignored; a later phase can
453                // surface them as structured tags without breaking the
454                // wire format.
455            } else if !part.is_empty() {
456                tags.push((*part).to_owned());
457            }
458        }
459
460        let body = collect_fence_body(&mut lines);
461
462        if has_target_code {
463            out.push(Snippet { lang, tags, body });
464        }
465    }
466
467    out
468}
469
470fn collect_fence_body<'a>(lines: &mut std::iter::Peekable<std::str::Lines<'a>>) -> String {
471    let mut body = String::new();
472    let mut first = true;
473    for line in lines.by_ref() {
474        if line.trim_start().starts_with("```") {
475            break;
476        }
477        if !first {
478            body.push('\n');
479        }
480        body.push_str(line);
481        first = false;
482    }
483    body
484}
485
486fn skip_to_fence_close<'a>(lines: &mut std::iter::Peekable<std::str::Lines<'a>>) {
487    for line in lines.by_ref() {
488        if line.trim_start().starts_with("```") {
489            return;
490        }
491    }
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497
498    #[test]
499    fn strip_quotes_handles_escaped_quotes() {
500        assert_eq!(
501            strip_quotes(r#""hello \"world\"""#).as_deref(),
502            Some("hello \"world\"")
503        );
504    }
505
506    #[test]
507    fn split_top_level_respects_parens_and_strings() {
508        let got = split_top_level_args(r#"code(EBP001), help("has, comma"), url("x")"#);
509        assert_eq!(
510            got.iter().map(|s| s.trim()).collect::<Vec<_>>(),
511            vec!["code(EBP001)", r#"help("has, comma")"#, r#"url("x")"#]
512        );
513    }
514
515    #[test]
516    fn parse_diagnostic_attr_extracts_all_three_fields() {
517        let raw = "#[diagnostic(code(EBP001),\nhelp(\"seed context.d\"),\nurl(\"mse://guides/errors/EBP001\"))]";
518        let parsed = parse_diagnostic_attr(raw).expect("must parse");
519        assert_eq!(parsed.code.as_deref(), Some("EBP001"));
520        assert_eq!(parsed.help.as_deref(), Some("seed context.d"));
521        assert_eq!(parsed.url.as_deref(), Some("mse://guides/errors/EBP001"));
522    }
523
524    #[test]
525    fn parse_diagnostic_attr_missing_fields_ok() {
526        let raw = "#[diagnostic(code(EBP002))]";
527        let parsed = parse_diagnostic_attr(raw).expect("must parse");
528        assert_eq!(parsed.code.as_deref(), Some("EBP002"));
529        assert!(parsed.help.is_none());
530        assert!(parsed.url.is_none());
531    }
532
533    #[test]
534    fn parse_error_attr_returns_message_template() {
535        let raw = r#"#[error("stage `{stage}` referenced by pipeline has no seed in context.d")]"#;
536        assert_eq!(
537            parse_error_attr(raw).as_deref(),
538            Some("stage `{stage}` referenced by pipeline has no seed in context.d")
539        );
540    }
541
542    #[test]
543    fn extract_snippets_picks_matching_code_tag_and_records_bare_tags() {
544        let docs = concat!(
545            "Some prose.\n",
546            "\n",
547            "```ebp,code=EBP001\n",
548            "bad snippet\n",
549            "line 2\n",
550            "```\n",
551            "\n",
552            "```ebp,code=EBP001,fix\n",
553            "good snippet\n",
554            "```\n",
555            "\n",
556            "```rust,code=EBP002\n",
557            "different code, must be skipped\n",
558            "```\n",
559        );
560        let snippets = extract_snippets(docs, "EBP001");
561        assert_eq!(snippets.len(), 2);
562        assert_eq!(snippets[0].lang, "ebp");
563        assert_eq!(snippets[0].tags, Vec::<String>::new());
564        assert_eq!(snippets[0].body, "bad snippet\nline 2");
565        assert_eq!(snippets[1].lang, "ebp");
566        assert_eq!(snippets[1].tags, vec!["fix".to_owned()]);
567        assert_eq!(snippets[1].body, "good snippet");
568    }
569}