use rustdoc_types::{Item, ItemEnum, Visibility};
use serde::Serialize;
use crate::index::{IndexedCrate, IndexedWorkspace};
#[derive(Debug, Clone, Serialize)]
pub struct ErrorEntry {
pub code: String,
pub crate_name: String,
pub item_path: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub message_template: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub help: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
pub docs: String,
pub snippets: Vec<Snippet>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Snippet {
pub lang: String,
pub tags: Vec<String>,
pub body: String,
}
pub fn extract(workspace: &IndexedWorkspace) -> Vec<ErrorEntry> {
let mut out = Vec::new();
for krate in &workspace.crates {
walk_crate(krate, &mut out);
}
out.sort_by(|a, b| a.code.cmp(&b.code));
out
}
fn walk_crate(krate: &IndexedCrate, out: &mut Vec<ErrorEntry>) {
let index = &krate.crate_data.index;
let Some(root_item) = index.get(&krate.crate_data.root) else {
return;
};
let ItemEnum::Module(root_module) = &root_item.inner else {
return;
};
let root_slug = krate.name.replace('-', "_");
walk_module(krate, index, root_module, root_slug, out);
}
fn walk_module(
krate: &IndexedCrate,
index: &std::collections::HashMap<rustdoc_types::Id, Item>,
module: &rustdoc_types::Module,
prefix: String,
out: &mut Vec<ErrorEntry>,
) {
for child_id in &module.items {
let Some(child) = index.get(child_id) else {
continue;
};
if !matches!(child.visibility, Visibility::Public) {
continue;
}
let Some(name) = child.name.as_deref() else {
continue;
};
let path = format!("{prefix}::{name}");
if let Some(entry) = try_build_entry(krate, child, &path) {
out.push(entry);
}
match &child.inner {
ItemEnum::Module(child_module) => {
walk_module(krate, index, child_module, path, out);
}
ItemEnum::Enum(child_enum) => {
walk_enum_variants(krate, index, child_enum, path, out);
}
_ => {}
}
}
}
fn walk_enum_variants(
krate: &IndexedCrate,
index: &std::collections::HashMap<rustdoc_types::Id, Item>,
enum_: &rustdoc_types::Enum,
enum_path: String,
out: &mut Vec<ErrorEntry>,
) {
for variant_id in &enum_.variants {
let Some(variant) = index.get(variant_id) else {
continue;
};
let Some(name) = variant.name.as_deref() else {
continue;
};
let path = format!("{enum_path}::{name}");
if let Some(entry) = try_build_entry(krate, variant, &path) {
out.push(entry);
}
}
}
fn try_build_entry(krate: &IndexedCrate, item: &Item, item_path: &str) -> Option<ErrorEntry> {
let diag_attr = item.attrs.iter().find_map(find_diagnostic_line)?;
let parsed = parse_diagnostic_attr(&diag_attr)?;
let code = parsed.code?;
let message_template = item
.attrs
.iter()
.find_map(find_error_line)
.and_then(|s| parse_error_attr(&s));
let docs = item.docs.clone().unwrap_or_default();
let snippets = extract_snippets(&docs, &code);
Some(ErrorEntry {
code,
crate_name: krate.name.clone(),
item_path: item_path.to_owned(),
message_template,
help: parsed.help,
url: parsed.url,
docs,
snippets,
})
}
fn find_diagnostic_line(attr: &rustdoc_types::Attribute) -> Option<String> {
let raw = attr_as_string(attr)?;
let trimmed = raw.trim();
if trimmed.starts_with("#[diagnostic(") {
Some(trimmed.to_owned())
} else {
None
}
}
fn find_error_line(attr: &rustdoc_types::Attribute) -> Option<String> {
let raw = attr_as_string(attr)?;
let trimmed = raw.trim();
if trimmed.starts_with("#[error(") {
Some(trimmed.to_owned())
} else {
None
}
}
fn attr_as_string(attr: &rustdoc_types::Attribute) -> Option<String> {
let value = serde_json::to_value(attr).ok()?;
if let Some(s) = value.as_str() {
return Some(s.to_owned());
}
if let Some(obj) = value.as_object()
&& let Some(other) = obj.get("other").and_then(|v| v.as_str())
{
return Some(other.to_owned());
}
None
}
#[derive(Debug, Default)]
struct ParsedDiagnostic {
code: Option<String>,
help: Option<String>,
url: Option<String>,
}
fn parse_diagnostic_attr(raw: &str) -> Option<ParsedDiagnostic> {
let inner = raw.strip_prefix("#[diagnostic(")?.strip_suffix(")]")?;
let mut out = ParsedDiagnostic::default();
for arg in split_top_level_args(inner) {
let arg = arg.trim();
if let Some(code) = arg.strip_prefix("code(").and_then(|s| s.strip_suffix(')')) {
out.code = Some(code.trim().to_owned());
} else if let Some(help) = arg
.strip_prefix("help(")
.and_then(|s| s.strip_suffix(')'))
.and_then(strip_quotes)
{
out.help = Some(help);
} else if let Some(url) = arg
.strip_prefix("url(")
.and_then(|s| s.strip_suffix(')'))
.and_then(strip_quotes)
{
out.url = Some(url);
}
}
Some(out)
}
fn parse_error_attr(raw: &str) -> Option<String> {
let inner = raw.strip_prefix("#[error(")?.strip_suffix(")]")?;
strip_quotes(inner.trim())
}
fn strip_quotes(s: &str) -> Option<String> {
let inner = s.strip_prefix('"')?.strip_suffix('"')?;
let mut out = String::with_capacity(inner.len());
let mut chars = inner.chars();
while let Some(ch) = chars.next() {
if ch == '\\' {
match chars.next() {
Some('"') => out.push('"'),
Some('\\') => out.push('\\'),
Some('n') => out.push('\n'),
Some(other) => {
out.push('\\');
out.push(other);
}
None => out.push('\\'),
}
} else {
out.push(ch);
}
}
Some(out)
}
fn split_top_level_args(s: &str) -> Vec<String> {
let mut out = Vec::new();
let mut current = String::new();
let mut depth: i32 = 0;
let mut in_string = false;
let mut escape = false;
for ch in s.chars() {
if escape {
current.push(ch);
escape = false;
continue;
}
if in_string {
if ch == '\\' {
current.push(ch);
escape = true;
} else {
current.push(ch);
if ch == '"' {
in_string = false;
}
}
continue;
}
match ch {
'"' => {
in_string = true;
current.push(ch);
}
'(' => {
depth += 1;
current.push(ch);
}
')' => {
depth -= 1;
current.push(ch);
}
',' if depth == 0 => {
out.push(std::mem::take(&mut current));
}
_ => current.push(ch),
}
}
if !current.trim().is_empty() {
out.push(current);
}
out
}
fn extract_snippets(docs: &str, target_code: &str) -> Vec<Snippet> {
let mut out = Vec::new();
let mut lines = docs.lines().peekable();
while let Some(line) = lines.next() {
let trimmed = line.trim_start();
let Some(info) = trimmed.strip_prefix("```") else {
continue;
};
if info.is_empty() {
skip_to_fence_close(&mut lines);
continue;
}
let parts: Vec<&str> = info.split(',').map(str::trim).collect();
let lang = parts[0].to_owned();
let mut tags = Vec::new();
let mut has_target_code = false;
for part in parts.iter().skip(1) {
if let Some((k, v)) = part.split_once('=') {
if k.trim() == "code" && v.trim() == target_code {
has_target_code = true;
}
} else if !part.is_empty() {
tags.push((*part).to_owned());
}
}
let body = collect_fence_body(&mut lines);
if has_target_code {
out.push(Snippet { lang, tags, body });
}
}
out
}
fn collect_fence_body<'a>(lines: &mut std::iter::Peekable<std::str::Lines<'a>>) -> String {
let mut body = String::new();
let mut first = true;
for line in lines.by_ref() {
if line.trim_start().starts_with("```") {
break;
}
if !first {
body.push('\n');
}
body.push_str(line);
first = false;
}
body
}
fn skip_to_fence_close<'a>(lines: &mut std::iter::Peekable<std::str::Lines<'a>>) {
for line in lines.by_ref() {
if line.trim_start().starts_with("```") {
return;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strip_quotes_handles_escaped_quotes() {
assert_eq!(
strip_quotes(r#""hello \"world\"""#).as_deref(),
Some("hello \"world\"")
);
}
#[test]
fn split_top_level_respects_parens_and_strings() {
let got = split_top_level_args(r#"code(EBP001), help("has, comma"), url("x")"#);
assert_eq!(
got.iter().map(|s| s.trim()).collect::<Vec<_>>(),
vec!["code(EBP001)", r#"help("has, comma")"#, r#"url("x")"#]
);
}
#[test]
fn parse_diagnostic_attr_extracts_all_three_fields() {
let raw = "#[diagnostic(code(EBP001),\nhelp(\"seed context.d\"),\nurl(\"mse://guides/errors/EBP001\"))]";
let parsed = parse_diagnostic_attr(raw).expect("must parse");
assert_eq!(parsed.code.as_deref(), Some("EBP001"));
assert_eq!(parsed.help.as_deref(), Some("seed context.d"));
assert_eq!(parsed.url.as_deref(), Some("mse://guides/errors/EBP001"));
}
#[test]
fn parse_diagnostic_attr_missing_fields_ok() {
let raw = "#[diagnostic(code(EBP002))]";
let parsed = parse_diagnostic_attr(raw).expect("must parse");
assert_eq!(parsed.code.as_deref(), Some("EBP002"));
assert!(parsed.help.is_none());
assert!(parsed.url.is_none());
}
#[test]
fn parse_error_attr_returns_message_template() {
let raw = r#"#[error("stage `{stage}` referenced by pipeline has no seed in context.d")]"#;
assert_eq!(
parse_error_attr(raw).as_deref(),
Some("stage `{stage}` referenced by pipeline has no seed in context.d")
);
}
#[test]
fn extract_snippets_picks_matching_code_tag_and_records_bare_tags() {
let docs = concat!(
"Some prose.\n",
"\n",
"```ebp,code=EBP001\n",
"bad snippet\n",
"line 2\n",
"```\n",
"\n",
"```ebp,code=EBP001,fix\n",
"good snippet\n",
"```\n",
"\n",
"```rust,code=EBP002\n",
"different code, must be skipped\n",
"```\n",
);
let snippets = extract_snippets(docs, "EBP001");
assert_eq!(snippets.len(), 2);
assert_eq!(snippets[0].lang, "ebp");
assert_eq!(snippets[0].tags, Vec::<String>::new());
assert_eq!(snippets[0].body, "bad snippet\nline 2");
assert_eq!(snippets[1].lang, "ebp");
assert_eq!(snippets[1].tags, vec!["fix".to_owned()]);
assert_eq!(snippets[1].body, "good snippet");
}
}