#[derive(Debug, Clone, PartialEq, Eq)]
pub struct XrefFinding {
pub message: String,
pub label: Option<String>,
pub location: Option<String>,
}
fn is_xref_message(message: &str) -> bool {
let m = message.to_lowercase();
m.contains("does not exist")
|| m.contains("unresolved reference")
|| (m.contains("label") && m.contains("unknown"))
}
fn extract_label(message: &str) -> Option<String> {
let start = message.find('<')?;
let end = message[start..].find('>')?;
Some(message[start..=start + end].to_string())
}
fn extract_location(line: &str) -> Option<String> {
let stripped = line.trim().trim_start_matches(|c: char| {
c == '-' || c == '>' || c == '┌' || c == '─' || c == '│' || c == '|' || c == ' '
});
let candidate = stripped.trim();
let parts: Vec<&str> = candidate.rsplitn(3, ':').collect();
if parts.len() == 3
&& !parts[0].is_empty()
&& parts[0].chars().all(|c| c.is_ascii_digit())
&& !parts[1].is_empty()
&& parts[1].chars().all(|c| c.is_ascii_digit())
&& !parts[2].is_empty()
{
Some(candidate.to_string())
} else {
None
}
}
pub fn scan_diagnostics(diagnostics: &str) -> Vec<XrefFinding> {
let lines: Vec<&str> = diagnostics.lines().collect();
let mut out: Vec<XrefFinding> = Vec::new();
for (i, line) in lines.iter().enumerate() {
let Some(rest) = line.trim_start().strip_prefix("error:") else {
continue;
};
let message = rest.trim();
if !is_xref_message(message) {
continue;
}
let mut location = None;
for look in lines.iter().skip(i + 1).take(4) {
if look.trim_start().starts_with("error:")
|| look.trim_start().starts_with("warning:")
{
break;
}
if let Some(loc) = extract_location(look) {
location = Some(loc);
break;
}
}
let finding = XrefFinding {
message: message.to_string(),
label: extract_label(message),
location,
};
if !out.iter().any(|f| f.message == finding.message && f.location == finding.location) {
out.push(finding);
}
}
out
}
pub fn emit_findings(findings: &[XrefFinding]) {
use crate::pane::output::{emit, kinds, Lifetime, Message, Severity};
for f in findings {
let label = f.label.clone().unwrap_or_default();
let location = f.location.clone().unwrap_or_default();
let text = if label.is_empty() {
"unresolved cross-reference".to_string()
} else {
format!("unresolved cross-reference {label}")
};
let msg = Message::new(
kinds::XREF,
Severity::Warning,
Lifetime::UntilActedOn,
serde_json::json!({
"text": text,
"category": "xref",
"label": label,
"location": location,
"detail": f.message,
}),
)
.with_group_key(format!("xref:{label}:{location}"));
emit(&msg);
}
}
pub fn collect_labels(text: &str) -> Vec<String> {
let chars: Vec<char> = text.chars().collect();
let mut out: Vec<String> = Vec::new();
let mut seen = std::collections::HashSet::new();
let mut i = 0;
while i < chars.len() {
if chars[i] != '<' {
i += 1;
continue;
}
let mut valid = chars.get(i + 1).is_some_and(|c| c.is_ascii_alphabetic());
let mut name = String::new();
let mut j = i + 1;
while j < chars.len() && chars[j] != '>' {
let c = chars[j];
if c.is_ascii_alphanumeric() || matches!(c, ':' | '.' | '_' | '-') {
name.push(c);
} else {
valid = false;
}
j += 1;
}
if j < chars.len() && valid && !name.is_empty() {
if seen.insert(name.clone()) {
out.push(name);
}
i = j + 1;
} else {
i += 1;
}
}
out
}
pub fn label_category(label: &str) -> &'static str {
match label.split(':').next().unwrap_or("") {
"fig" => "figure",
"eq" | "eqt" | "eqn" => "equation",
"tbl" | "tab" => "table",
"sec" => "section",
"thm" => "theorem",
"lst" | "lstlisting" => "listing",
"app" => "appendix",
"alg" => "algorithm",
_ => "label",
}
}
pub fn scan_and_emit(diagnostics: &str) -> usize {
let findings = scan_diagnostics(diagnostics);
emit_findings(&findings);
findings.len()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recognises_typst_unresolved_label_inprocess_format() {
let stderr = "\
error: label `<fig:flux>` does not exist in the document
--> /home/u/book/my-book.typ:42:8
hint: check the label spelling
";
let found = scan_diagnostics(stderr);
assert_eq!(found.len(), 1);
assert_eq!(found[0].label.as_deref(), Some("<fig:flux>"));
assert_eq!(
found[0].location.as_deref(),
Some("/home/u/book/my-book.typ:42:8")
);
}
#[test]
fn recognises_external_binary_box_format() {
let stderr = "\
error: label `<eq:energy>` does not exist in the document
┌─ /tmp/paper.typ:7:15
│
7 │ See @eq:energy for details.
";
let found = scan_diagnostics(stderr);
assert_eq!(found.len(), 1);
assert_eq!(found[0].label.as_deref(), Some("<eq:energy>"));
assert_eq!(found[0].location.as_deref(), Some("/tmp/paper.typ:7:15"));
}
#[test]
fn ignores_unrelated_compile_errors() {
let stderr = "\
error: unexpected end of block
--> /tmp/x.typ:3:1
error: unknown variable: foobar
--> /tmp/x.typ:9:2
";
assert!(scan_diagnostics(stderr).is_empty());
}
#[test]
fn dedupes_repeated_identical_findings() {
let stderr = "\
error: label `<fig:a>` does not exist in the document
--> a.typ:1:1
error: label `<fig:a>` does not exist in the document
--> a.typ:1:1
error: label `<fig:b>` does not exist in the document
--> a.typ:2:1
";
let found = scan_diagnostics(stderr);
assert_eq!(found.len(), 2);
}
#[test]
fn collect_labels_extracts_definitions_and_ignores_operators() {
let src = "\
#figure(image(\"f.png\"), caption: [Flux]) <fig:flux>
$ E = m c^2 $ <eq:energy>
See @fig:flux. For a <= b and a -> c and x < y > z we want nothing.
= Intro <sec:intro>
Duplicate <fig:flux> again.
";
let labels = collect_labels(src);
assert_eq!(labels, vec!["fig:flux", "eq:energy", "sec:intro"]);
}
#[test]
fn label_category_maps_common_prefixes() {
assert_eq!(label_category("fig:x"), "figure");
assert_eq!(label_category("eq:y"), "equation");
assert_eq!(label_category("tbl:z"), "table");
assert_eq!(label_category("sec:i"), "section");
assert_eq!(label_category("whatever"), "label");
}
#[test]
fn finding_without_location_still_captured() {
let stderr = "error: label `<sec:intro>` does not exist in the document\n";
let found = scan_diagnostics(stderr);
assert_eq!(found.len(), 1);
assert_eq!(found[0].label.as_deref(), Some("<sec:intro>"));
assert!(found[0].location.is_none());
}
}