use std::collections::HashMap;
use std::path::PathBuf;
use crate::linter::conditional::{Frame, guaranteed_before};
use crate::linter::diagnostic::{Diagnostic, Severity};
use crate::project::{PackageKind, PackageTarget, collect_package_edges};
use super::{Example, Rule, RuleContext};
const EXAMPLES: &[Example] = &[Example {
caption: "The same package loaded twice:",
source: "\\usepackage{amsmath}\n\\usepackage{amsmath}\n",
}];
pub struct DuplicatePackage;
impl Rule for DuplicatePackage {
fn id(&self) -> &'static str {
"duplicate-package"
}
fn default_severity(&self) -> Severity {
Severity::Warning
}
fn description(&self) -> &'static str {
"Flag a package loaded more than once in the same file with \
`\\usepackage`/`\\RequirePackage` (which share one package namespace). \
LaTeX loads a given package only once; a second load is redundant and, \
when the options disagree, an option-clash error. A warning requires \
a prior load in the same conditional branch or an enclosing context \
(including an unconditional prior). Separate conditional tests are \
treated as uncertain and do not trigger a warning. Recognizes \
`\\if...\\else...\\fi` and common macros with complete braced arguments, \
including `\\ifthenelse`, `\\iftoggle`, and `\\IfFileExists`. Predicates \
are not evaluated, and coverage across branches is not combined. \
No autofix: removing a \
load can drop options the survivor lacks, and which load to keep is the \
author's call. Class loads (`\\documentclass`/`\\LoadClass`) are a \
separate concern and are not flagged."
}
fn examples(&self) -> &'static [Example] {
EXAMPLES
}
fn check_file(&self, ctx: &RuleContext<'_>, sink: &mut Vec<Diagnostic>) {
let mut seen: HashMap<PathBuf, Vec<&[Frame]>> = HashMap::new();
for edge in collect_package_edges(ctx.root, None) {
if !matches!(
edge.kind,
PackageKind::UsePackage | PackageKind::RequirePackage
) {
continue;
}
let PackageTarget::Path(path) = &edge.target else {
continue;
};
let path_here = ctx.conditional_path_at(usize::from(edge.range.start()));
let priors = seen.entry(path.clone()).or_default();
let is_duplicate = priors.iter().any(|p| guaranteed_before(p, path_here));
priors.push(path_here);
if !is_duplicate {
continue;
}
let name = path
.file_stem()
.and_then(|s| s.to_str())
.map(str::to_owned)
.unwrap_or_else(|| path.display().to_string());
sink.push(Diagnostic {
rule: self.id(),
severity: self.default_severity(),
path: PathBuf::new(),
start: usize::from(edge.range.start()),
end: usize::from(edge.range.end()),
message: format!("package `{name}` is loaded more than once"),
fix: None,
related: Vec::new(),
});
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::parse;
use crate::semantic::SemanticModel;
use crate::syntax::SyntaxNode;
fn findings(src: &str) -> Vec<Diagnostic> {
let root = SyntaxNode::new_root(parse(src).green);
let model = SemanticModel::build(&root);
let ctx = RuleContext::new(
std::path::Path::new("x.tex"),
&root,
&model,
None,
None,
None,
);
let mut out = Vec::new();
DuplicatePackage.check_file(&ctx, &mut out);
out
}
#[test]
fn flags_only_the_second_load() {
let out = findings("\\usepackage{amsmath}\n\\usepackage{amsmath}\n");
assert_eq!(out.len(), 1);
assert_eq!(out[0].rule, "duplicate-package");
assert!(
out[0].message.contains("amsmath"),
"got: {}",
out[0].message
);
assert_eq!((out[0].start, out[0].end), (21, 41));
}
#[test]
fn distinct_packages_are_fine() {
assert!(findings("\\usepackage{amsmath}\n\\usepackage{amssymb}\n").is_empty());
}
#[test]
fn comma_list_then_reload_flags_once() {
let out = findings("\\usepackage{amsmath,amssymb}\n\\usepackage{amsmath}\n");
assert_eq!(out.len(), 1);
assert!(
out[0].message.contains("amsmath"),
"got: {}",
out[0].message
);
}
#[test]
fn require_package_and_usepackage_share_namespace() {
let out = findings("\\RequirePackage{tools}\n\\usepackage{tools}\n");
assert_eq!(out.len(), 1);
assert!(out[0].message.contains("tools"), "got: {}", out[0].message);
}
#[test]
fn duplicate_inside_one_list_flags_once() {
let out = findings("\\usepackage{amsmath,amsmath}\n");
assert_eq!(out.len(), 1);
}
#[test]
fn triple_load_flags_twice() {
let out = findings("\\usepackage{a}\n\\usepackage{a}\n\\usepackage{a}\n");
assert_eq!(out.len(), 2);
}
#[test]
fn dynamic_target_is_ignored() {
assert!(findings("\\usepackage{\\pkg}\n\\usepackage{\\pkg}\n").is_empty());
}
#[test]
fn duplicate_documentclass_is_not_this_rules_concern() {
assert!(findings("\\documentclass{article}\n\\documentclass{article}\n").is_empty());
}
#[test]
fn if_else_branches_are_not_flagged() {
assert!(
findings("\\iftrue\\usepackage[opt1]{pkg}\\else\\usepackage[opt2]{pkg}\\fi\n")
.is_empty()
);
}
#[test]
fn ifcase_or_branches_are_not_flagged() {
assert!(
findings(
"\\ifcase 0 \\usepackage{pkg}\\or\\usepackage{pkg}\\or\\usepackage{pkg}\\fi\n"
)
.is_empty()
);
}
#[test]
fn same_branch_is_still_flagged() {
let out = findings("\\iftrue\\usepackage{a}\\usepackage{a}\\else\\usepackage{b}\\fi\n");
assert_eq!(out.len(), 1);
assert!(out[0].message.contains('a'), "got: {}", out[0].message);
}
#[test]
fn conditional_priors_do_not_prove_an_unconditional_duplicate() {
assert!(
findings("\\iftrue\\usepackage{pkg}\\else\\usepackage{pkg}\\fi\n\\usepackage{pkg}\n")
.is_empty()
);
}
#[test]
fn unknown_conditional_branches_are_not_flagged() {
assert!(findings("\\ifmyflag\\usepackage{pkg}\\else\\usepackage{pkg}\\fi\n").is_empty());
}
#[test]
fn unknown_conditionals_else_does_not_shield_a_real_duplicate() {
let out = findings("\\iftrue \\usepackage{p} \\ifmyflag \\else \\usepackage{p} \\fi\\fi\n");
assert_eq!(out.len(), 1);
}
#[test]
fn unknown_conditionals_fi_does_not_break_exclusivity() {
assert!(
findings("\\iftrue \\ifmyflag x\\fi \\usepackage{p} \\else \\usepackage{p} \\fi\n")
.is_empty()
);
}
#[test]
fn conditional_tokens_in_definition_bodies_are_inert() {
let out = findings(
"\\iftrue \\usepackage{p} \\newcommand{\\rest}{\\else} \\usepackage{p} \\fi\n",
);
assert_eq!(out.len(), 1);
}
#[test]
fn if_named_operands_do_not_open_frames() {
assert!(
findings(
"\\iftrue \\usepackage{p} \\ifdefined\\iffalse\\fi \\else \\usepackage{p} \\fi\n"
)
.is_empty()
);
}
#[test]
fn iftex_engine_dispatch_is_not_flagged() {
assert!(
findings("\\ifLuaTeX\\usepackage{fontspec}\\else\\usepackage{fontspec}\\fi\n")
.is_empty()
);
}
#[test]
fn ifthenelse_branches_are_not_flagged() {
assert!(
findings("\\ifthenelse{\\boolean{x}}{\\usepackage{a}}{\\usepackage{a}}\n").is_empty()
);
}
#[test]
fn macro_conditional_operands_do_not_hide_duplicates() {
for src in [
"\\ifdefined\\IfFileExists{}{\\usepackage{a}}{\\usepackage{a}}\\fi",
"\\ifdefined\\ifthenelse{}{\\usepackage{a}}{\\usepackage{a}}\\fi",
"\\let\\saved\\IfFileExists{}{\\usepackage{a}}{\\usepackage{a}}",
"\\ifx\\relax\\IfFileExists{}{\\usepackage{a}}{\\usepackage{a}}\\fi",
] {
let out = findings(src);
assert_eq!(out.len(), 1, "{src}");
assert_eq!(out[0].start, src.rfind("\\usepackage").unwrap(), "{src}");
}
}
#[test]
fn separate_format_choices_are_not_flagged() {
let src = concat!(
"\\ifthenelse{\\equal{\\liturjibicimi}{a4}}{%\n",
" \\addtolength{\\extraMargin}{2em}\n",
" \\usepackage[a4paper,top=4mm+\\extraMargin]{geometry}\n",
"}{}\n",
"\\ifthenelse{\\equal{\\liturjibicimi}{kindle}}{%\n",
" \\usepackage[papersize={90mm,122mm}]{geometry}\n",
"}{}\n",
"\\ifthenelse{\\equal{\\liturjibicimi}{tablet}}{%\n",
" \\usepackage[papersize={30em,485em}]{geometry}\n",
"}{}\n",
);
assert!(findings(src).is_empty());
}
#[test]
fn only_guaranteed_prior_loads_are_flagged() {
for (src, count) in [
("\\iffoo\\usepackage{a}\\fi\\ifbar\\usepackage{a}\\fi", 0),
("\\iffoo\\usepackage{a}\\fi\\usepackage{a}", 0),
("\\usepackage{a}\\iffoo\\usepackage{a}\\fi", 1),
("\\iffoo\\usepackage{a}\\ifbar\\usepackage{a}\\fi\\fi", 1),
("\\iffoo\\ifbar\\usepackage{a}\\fi\\usepackage{a}\\fi", 0),
("\\ifthenelse{x}{\\usepackage{a}\\usepackage{a}}{}", 1),
(
"\\usepackage{a}\\ifthenelse{x}{\\usepackage{a}}{\\usepackage{a}}",
2,
),
(
"\\ifthenelse{x}{\\usepackage{a}}{}\\usepackage{a}\\usepackage{a}",
1,
),
(
"\\ifthenelse{x}{\\usepackage{a}\\iffoo\\usepackage{a}\\fi}{}",
1,
),
(
"\\iffoo\\usepackage{a}\\ifthenelse{x}{\\usepackage{a}}{}\\fi",
1,
),
] {
assert_eq!(findings(src).len(), count, "{src}");
}
}
#[test]
fn math_iff_does_not_disturb_tracking() {
assert!(findings("$a \\iff b$\n\\usepackage{x}\n\\usepackage{y}\n").is_empty());
}
#[test]
fn newif_declaration_does_not_suppress_a_real_duplicate() {
let out = findings("\\newif\\ifmyflag\n\\usepackage{a}\n\\usepackage{a}\n");
assert_eq!(out.len(), 1);
}
}