1use rustdoc_types::{Item, ItemEnum, Visibility};
63use serde::Serialize;
64
65use crate::index::{IndexedCrate, IndexedWorkspace};
66
67#[derive(Debug, Clone, Serialize)]
70pub struct ErrorEntry {
71 pub code: String,
74 pub crate_name: String,
76 pub item_path: String,
79 #[serde(skip_serializing_if = "Option::is_none")]
82 pub message_template: Option<String>,
83 #[serde(skip_serializing_if = "Option::is_none")]
85 pub help: Option<String>,
86 #[serde(skip_serializing_if = "Option::is_none")]
88 pub url: Option<String>,
89 pub docs: String,
93 pub snippets: Vec<Snippet>,
96}
97
98#[derive(Debug, Clone, Serialize)]
100pub struct Snippet {
101 pub lang: String,
104 pub tags: Vec<String>,
107 pub body: String,
110}
111
112pub 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 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 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
179fn 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
246fn 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
273fn attr_as_string(attr: &rustdoc_types::Attribute) -> Option<String> {
277 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
299fn 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
337fn 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
362fn 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
414fn 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 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 } 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}