use std::path::{Path, PathBuf};
use badness::linter::{Severity, lint_document};
use badness::parser::{parse, reconstruct};
use badness::project::labels::{document_label_names, is_document_root};
use badness::project::{FileFacts, IncludeGraph, ResolvedLabels, collect_include_edge_keys};
use badness::semantic::SemanticModel;
use badness::syntax::SyntaxNode;
fn lint(src: &str) -> Vec<(&'static str, Severity)> {
let root = SyntaxNode::new_root(parse(src).green);
let model = SemanticModel::build(&root);
lint_document(Path::new("doc.tex"), &root, &model, None, None)
.into_iter()
.map(|d| (d.rule, d.severity))
.collect()
}
fn lint_project(files: &[(&str, &str)]) -> Vec<(String, &'static str, String)> {
let parsed: Vec<(PathBuf, SyntaxNode, SemanticModel)> = files
.iter()
.map(|(path, src)| {
let root = SyntaxNode::new_root(parse(src).green);
let model = SemanticModel::build(&root);
(PathBuf::from(path), root, model)
})
.collect();
let facts: Vec<FileFacts> = parsed
.iter()
.map(|(path, root, _)| FileFacts {
path: path.clone(),
include_edges: collect_include_edge_keys(root, path.parent()),
})
.collect();
let label_inputs: Vec<_> = parsed
.iter()
.map(|(path, root, model)| {
(
path.clone(),
document_label_names(model),
is_document_root(root),
)
})
.collect();
let resolved = ResolvedLabels::build(&label_inputs, &IncludeGraph::build(&facts, None));
let mut out = Vec::new();
for (path, root, model) in &parsed {
for d in lint_document(path, root, model, Some(&resolved), None) {
out.push((path.display().to_string(), d.rule, d.message));
}
}
out
}
fn rules_only(findings: &[(String, &'static str, String)]) -> Vec<&'static str> {
findings.iter().map(|(_, rule, _)| *rule).collect()
}
fn lint_with_bib(tex: &str, bibs: &[(&str, &str)]) -> Vec<&'static str> {
use badness::project::{CiteFileFacts, ResolvedCitations, collect_bib_resource_targets};
use smol_str::SmolStr;
use std::collections::HashMap;
let tex_path = PathBuf::from("doc.tex");
let root = SyntaxNode::new_root(parse(tex).green);
let model = SemanticModel::build(&root);
let bib_keys: HashMap<PathBuf, Vec<SmolStr>> = bibs
.iter()
.map(|(path, src)| {
let bib_model =
badness::bib::semantic::Model::build(&badness::bib::parse(src).syntax());
(
PathBuf::from(path),
bib_model.entries().iter().map(|e| e.key.clone()).collect(),
)
})
.collect();
let facts = vec![FileFacts {
path: tex_path.clone(),
include_edges: collect_include_edge_keys(&root, tex_path.parent()),
}];
let graph = IncludeGraph::build(&facts, None);
let cite_facts = vec![CiteFileFacts {
path: tex_path.clone(),
bib_targets: collect_bib_resource_targets(&root, tex_path.parent()),
nocite_all: model.has_wildcard_nocite(),
is_document_root: is_document_root(&root),
}];
let citations = ResolvedCitations::build(&cite_facts, &graph, &bib_keys);
lint_document(&tex_path, &root, &model, None, Some(&citations))
.into_iter()
.map(|d| d.rule)
.collect()
}
#[test]
fn cross_file_undefined_citation_is_flagged() {
let tex = "\\documentclass{article}\n\\addbibresource{refs.bib}\n\\begin{document}\n\\cite{missing}\n\\end{document}\n";
let bib = "@article{present, title = {T}}\n";
let rules = lint_with_bib(tex, &[("refs.bib", bib)]);
assert!(rules.contains(&"undefined-citation"), "{rules:?}");
}
#[test]
fn cross_file_resolved_citation_is_silent() {
let tex = "\\documentclass{article}\n\\addbibresource{refs.bib}\n\\begin{document}\n\\cite{present}\n\\end{document}\n";
let bib = "@article{present, title = {T}}\n";
let rules = lint_with_bib(tex, &[("refs.bib", bib)]);
assert!(!rules.contains(&"undefined-citation"), "{rules:?}");
}
#[test]
fn citation_gating_holds_for_fragment_and_wildcard() {
let bib = "@article{present, title = {T}}\n";
let fragment = "\\addbibresource{refs.bib}\n\\cite{missing}\n";
assert!(!lint_with_bib(fragment, &[("refs.bib", bib)]).contains(&"undefined-citation"));
let wildcard = "\\documentclass{article}\n\\addbibresource{refs.bib}\n\\nocite{*}\n\\begin{document}\n\\cite{missing}\n\\end{document}\n";
assert!(!lint_with_bib(wildcard, &[("refs.bib", bib)]).contains(&"undefined-citation"));
}
#[test]
fn bibliography_command_resolves_keys() {
let tex = "\\documentclass{article}\n\\begin{document}\n\\cite{present}\n\\bibliography{refs}\n\\end{document}\n";
let bib = "@article{present, title = {T}}\n";
let rules = lint_with_bib(tex, &[("refs.bib", bib)]);
assert!(!rules.contains(&"undefined-citation"), "{rules:?}");
}
#[test]
fn reports_both_rules_in_document_order() {
let src = "\\section{Intro}\n\\label{a}\n{\\bf bold}\n\\label{a}\n";
assert_eq!(
lint(src),
vec![
("deprecated-command", Severity::Warning),
("duplicate-label", Severity::Warning),
]
);
}
#[test]
fn clean_document_has_no_findings() {
let src = "\\section{Intro}\n\\label{a}\\ref{a}\n\\textbf{ok}\n";
assert!(lint(src).is_empty());
}
#[test]
fn node_ignore_suppresses_only_the_next_block() {
let src = "\
% badness-ignore deprecated-command: legacy macro
{\\bf one}
{\\it two}
";
assert_eq!(lint(src), vec![("deprecated-command", Severity::Warning)]);
}
#[test]
fn file_ignore_silences_a_rule_everywhere() {
let src = "\
% badness-ignore-file deprecated-command: legacy file
{\\bf one}
{\\it two}
\\label{a}\\label{a}
";
assert_eq!(lint(src), vec![("duplicate-label", Severity::Warning)]);
}
#[test]
fn file_ignore_all_silences_everything() {
let src = "\
% badness-ignore-file: vendored
{\\bf one}
\\label{a}\\label{a}
";
assert!(lint(src).is_empty());
}
#[test]
fn stylistic_rules_collected_in_document_order() {
let src = "\
\\begin{eqnarray}a&=&b\\end{eqnarray}
$$x = y$$
$\\left) a \\right| $
";
assert_eq!(
lint(src),
vec![
("obsolete-environment", Severity::Warning),
("dollar-display-math", Severity::Warning),
("mismatched-delimiter", Severity::Warning),
]
);
}
#[test]
fn modern_constructs_have_no_findings() {
let src = "\
\\begin{align}a &= b\\end{align}
\\[x = y\\]
$\\left( a \\right] $
";
assert!(lint(src).is_empty(), "got: {:?}", lint(src));
}
#[test]
fn node_ignore_silences_a_stylistic_rule() {
let src = "\
% badness-ignore dollar-display-math: legacy snippet
$$x = y$$
";
assert!(lint(src).is_empty(), "got: {:?}", lint(src));
}
#[test]
fn well_formed_project_has_no_cross_file_findings() {
let findings = lint_project(&[
(
"main.tex",
"\\documentclass{article}\n\\input{chap}\n\\ref{a}\n",
),
("chap.tex", "\\label{a}\n"),
]);
assert!(
findings.is_empty(),
"expected clean project, got: {findings:?}"
);
}
#[test]
fn cross_file_duplicate_label_is_reported_in_both_files() {
let findings = lint_project(&[
(
"main.tex",
"\\documentclass{article}\n\\input{chap}\n\\label{dup}\n",
),
("chap.tex", "\\label{dup}\n"),
]);
assert_eq!(
rules_only(&findings),
vec!["duplicate-label", "duplicate-label"]
);
assert!(
findings
.iter()
.any(|(p, _, m)| p == "main.tex" && m.contains("`chap.tex`"))
);
assert!(
findings
.iter()
.any(|(p, _, m)| p == "chap.tex" && m.contains("`main.tex`"))
);
}
#[test]
fn undefined_ref_fires_in_a_closed_rooted_document() {
let findings = lint_project(&[(
"main.tex",
"\\documentclass{article}\n\\label{a}\\ref{a}\\ref{ghost}\n",
)]);
assert_eq!(rules_only(&findings), vec!["undefined-ref"]);
assert!(findings[0].2.contains("ghost"));
}
#[test]
fn undefined_ref_is_silent_for_a_bare_fragment() {
let findings = lint_project(&[("chap.tex", "\\ref{elsewhere}\n")]);
assert!(findings.is_empty(), "expected silence, got: {findings:?}");
}
#[test]
fn independent_documents_do_not_cross_contaminate() {
let findings = lint_project(&[
(
"one.tex",
"\\documentclass{article}\n\\label{intro}\\ref{intro}\n",
),
(
"two.tex",
"\\documentclass{article}\n\\label{intro}\\ref{intro}\n",
),
]);
assert!(
findings.is_empty(),
"expected no collisions, got: {findings:?}"
);
}
use badness::formatter::{FormatStyle, format_with_style};
use badness::linter::{apply_fixes, check_document};
use badness::parser::LatexFlavor;
fn fix_to_fixpoint(text: &str) -> String {
let path = Path::new("doc.tex");
let mut content = text.to_owned();
for _ in 0..10 {
let fixes: Vec<_> = check_document(path, &content, LatexFlavor::Document)
.into_iter()
.filter_map(|d| d.fix)
.collect();
if fixes.is_empty() {
break;
}
let out = apply_fixes(&content, &fixes, true);
if out.applied == 0 {
break;
}
content = out.output;
}
content
}
fn assert_fix_is_correct(input: &str) {
let style = FormatStyle::default();
let clean = format_with_style(input, style).expect("input should format");
let fixed = fix_to_fixpoint(&clean);
assert!(
parse(&fixed).errors.is_empty(),
"fixed output must parse cleanly:\n{fixed:?}"
);
assert_eq!(
reconstruct(&fixed),
fixed,
"fix broke losslessness (tenet 1).\nfrom:\n{clean}\n--- after fixes ---\n{fixed}"
);
}
#[test]
fn dollar_display_fix_rewrites_to_bracket_form() {
assert_eq!(fix_to_fixpoint("$$x = y$$\n"), "\\[x = y\\]\n");
}
#[test]
fn dollar_display_fix_clears_the_finding() {
let fixed = fix_to_fixpoint("$$a + b$$\n\n$$c$$\n");
assert_eq!(fixed, "\\[a + b\\]\n\n\\[c\\]\n");
let remaining: Vec<_> = check_document(Path::new("doc.tex"), &fixed, LatexFlavor::Document)
.into_iter()
.filter(|d| d.rule == "dollar-display-math")
.collect();
assert!(
remaining.is_empty(),
"expected a clean re-lint, got: {remaining:?}"
);
}
#[test]
fn dollar_display_fix_is_correct() {
for case in ["$$x = y$$\n", "$$\n a + b\n$$\n", "\\[x = y\\]\n", "$x$\n"] {
assert_fix_is_correct(case);
}
}
#[test]
fn missing_nbsp_fix_is_correct() {
for case in ["Figure \\ref{x}\n", "see \\cite{a}\n", "Eq. \\eqref{z}\n"] {
assert_fix_is_correct(case);
}
}
#[test]
fn missing_nbsp_fix_clears_the_finding() {
let fixed = fix_to_fixpoint("Figure \\ref{x}\n");
assert_eq!(fixed, "Figure~\\ref{x}\n");
let remaining: Vec<_> = check_document(Path::new("doc.tex"), &fixed, LatexFlavor::Document)
.into_iter()
.filter(|d| d.rule == "missing-nonbreaking-space")
.collect();
assert!(
remaining.is_empty(),
"expected a clean re-lint, got: {remaining:?}"
);
}
#[test]
fn missing_nbsp_skipped_without_unsafe_opt_in() {
let src = "Figure \\ref{x}\n";
let fixes: Vec<_> = check_document(Path::new("doc.tex"), src, LatexFlavor::Document)
.into_iter()
.filter_map(|d| d.fix)
.collect();
let out = apply_fixes(src, &fixes, false);
assert_eq!(out.output, src, "unsafe tie fix must be skipped");
}