use std::path::PathBuf;
use crate::linter::diagnostic::{Diagnostic, Severity};
use crate::project::{PackageKind, load_option_args, resolve_load_target};
use crate::syntax::{SyntaxElement, SyntaxKind};
use super::{Example, Rule, RuleContext};
const EXAMPLES: &[Example] = &[Example {
caption: "Loading the sibling package with an option it never declares:",
source: "\\usepackage[final]{mypkg}\n",
}];
const EXAMPLE_COMPANION_STY: &str = "\\ProvidesPackage{mypkg}[2026/01/01 v1.0 Demo package]\n\\DeclareOption{draft}{}\n\\ProcessOptions\\relax\n";
pub struct UnknownOption;
impl Rule for UnknownOption {
fn id(&self) -> &'static str {
"unknown-option"
}
fn default_severity(&self) -> Severity {
Severity::Warning
}
fn description(&self) -> &'static str {
"Flag a `\\usepackage`/`\\RequirePackage` option that the loaded package \
never declares with `\\DeclareOption`, which LaTeX reports as an \
\"Unknown option\" error at compile time. Checked only against packages \
that are analyzed project files (a sibling `.sty`) — no option data \
ships for system packages — and only when the package's declared set is \
trustworthy: a `\\DeclareOption*` default handler, a key-value option \
processor (`kvoptions`, `\\ProcessKeyOptions`, …), option forwarding, or \
an `\\input` in the package silences the rule, as does a `key=value` \
option. Class loads (`\\documentclass`) are not checked: an unknown \
class option is not an error, it becomes an unused global option. No \
autofix: dropping or renaming the option is the author's call."
}
fn examples(&self) -> &'static [Example] {
EXAMPLES
}
fn example_companions(&self) -> &'static [(&'static str, &'static str)] {
&[("mypkg.sty", EXAMPLE_COMPANION_STY)]
}
fn interests(&self) -> &'static [SyntaxKind] {
&[SyntaxKind::COMMAND]
}
fn check(&self, el: &SyntaxElement, ctx: &RuleContext<'_>, sink: &mut Vec<Diagnostic>) {
let Some(packages) = ctx.packages else {
return;
};
let Some(node) = el.as_node() else { return };
let kind = match crate::ast::command_name(node).as_deref() {
Some("usepackage") => PackageKind::UsePackage,
Some("RequirePackage") => PackageKind::RequirePackage,
_ => return,
};
let Some(options) = load_option_args(node) else {
return;
};
if options.is_empty() {
return;
}
let Some(names) = crate::ast::nth_group_text(node, 0) else {
return;
};
for name in names.split(',').map(str::trim).filter(|n| !n.is_empty()) {
let target = resolve_load_target(name, kind, ctx.path.parent());
let Some(facts) = packages.get(&target) else {
continue;
};
if facts.handles_unknown {
continue;
}
for opt in &options {
if opt.text.contains('=') || opt.text.contains(char::is_whitespace) {
continue;
}
if facts.declares(&opt.text) {
continue;
}
sink.push(Diagnostic {
rule: self.id(),
severity: self.default_severity(),
path: PathBuf::new(),
start: usize::from(opt.range.start()),
end: usize::from(opt.range.end()),
message: format!("unknown option `{}` for package `{name}`", opt.text),
fix: None,
});
}
}
}
}
#[cfg(test)]
mod tests {
use std::path::Path;
use super::*;
use crate::parser::{parse, parse_with_flavor};
use crate::project::{ResolvedPackageOptions, package_option_facts};
use crate::semantic::SemanticModel;
use crate::syntax::SyntaxNode;
fn findings_with(src: &str, stys: &[(&str, &str)]) -> Vec<Diagnostic> {
let facts = stys.iter().filter_map(|(path, sty_src)| {
let kind = crate::file_discovery::file_kind_or_tex(Path::new(path));
let root = SyntaxNode::new_root(parse_with_flavor(sty_src, kind.lex_config()).green);
let model = SemanticModel::build(&root);
package_option_facts(Path::new(path), &root, &model)
});
let packages = ResolvedPackageOptions::build(facts);
findings_against(src, Some(&packages))
}
fn findings_against(src: &str, packages: Option<&ResolvedPackageOptions>) -> Vec<Diagnostic> {
let root = SyntaxNode::new_root(parse(src).green);
let model = SemanticModel::build(&root);
let ctx = RuleContext::new(
Path::new("/p/main.tex"),
&root,
&model,
None,
None,
packages,
);
let mut out = Vec::new();
for el in root.descendants_with_tokens() {
if UnknownOption.interests().contains(&el.kind()) {
UnknownOption.check(&el, &ctx, &mut out);
}
}
out
}
const DECLARES_DRAFT: &str = "\\DeclareOption{draft}{}\n\\ProcessOptions\\relax\n";
#[test]
fn unknown_option_is_flagged_with_tight_span() {
let src = "\\usepackage[final]{mypkg}\n";
let out = findings_with(src, &[("/p/mypkg.sty", DECLARES_DRAFT)]);
assert_eq!(out.len(), 1);
assert_eq!(out[0].rule, "unknown-option");
assert_eq!(&src[out[0].start..out[0].end], "final");
assert!(
out[0].message.contains("final") && out[0].message.contains("mypkg"),
"got: {}",
out[0].message
);
assert!(out[0].fix.is_none());
}
#[test]
fn declared_option_is_silent() {
let out = findings_with(
"\\usepackage[draft]{mypkg}\n",
&[("/p/mypkg.sty", DECLARES_DRAFT)],
);
assert!(out.is_empty(), "{out:?}");
}
#[test]
fn star_handler_target_is_silent() {
let out = findings_with(
"\\usepackage[anything]{mypkg}\n",
&[("/p/mypkg.sty", "\\DeclareOption*{}\n")],
);
assert!(out.is_empty(), "{out:?}");
}
#[test]
fn non_member_target_is_silent() {
let out = findings_with(
"\\usepackage[fleqn]{amsmath}\n",
&[("/p/mypkg.sty", DECLARES_DRAFT)],
);
assert!(out.is_empty(), "{out:?}");
}
#[test]
fn no_project_view_is_inert() {
let out = findings_against("\\usepackage[final]{mypkg}\n", None);
assert!(out.is_empty(), "{out:?}");
}
#[test]
fn each_unknown_option_is_flagged_separately() {
let src = "\\usepackage[a,b]{mypkg}\n";
let out = findings_with(src, &[("/p/mypkg.sty", DECLARES_DRAFT)]);
assert_eq!(out.len(), 2);
assert_eq!(&src[out[0].start..out[0].end], "a");
assert_eq!(&src[out[1].start..out[1].end], "b");
}
#[test]
fn key_value_option_is_skipped() {
let out = findings_with(
"\\usepackage[width=3cm]{mypkg}\n",
&[("/p/mypkg.sty", DECLARES_DRAFT)],
);
assert!(out.is_empty(), "{out:?}");
}
#[test]
fn dynamic_bracket_is_inert() {
let out = findings_with(
"\\usepackage[\\myopts]{mypkg}\n",
&[("/p/mypkg.sty", DECLARES_DRAFT)],
);
assert!(out.is_empty(), "{out:?}");
}
#[test]
fn comma_list_flags_only_the_member_and_names_it() {
let src = "\\usepackage[x]{amsmath,mypkg}\n";
let out = findings_with(src, &[("/p/mypkg.sty", DECLARES_DRAFT)]);
assert_eq!(out.len(), 1);
assert!(out[0].message.contains("mypkg"), "got: {}", out[0].message);
}
#[test]
fn require_package_fires_like_usepackage() {
let out = findings_with(
"\\RequirePackage[final]{mypkg}\n",
&[("/p/mypkg.sty", DECLARES_DRAFT)],
);
assert_eq!(out.len(), 1);
}
#[test]
fn documentclass_is_never_checked() {
let out = findings_with(
"\\documentclass[weird]{myclass}\n",
&[("/p/myclass.cls", DECLARES_DRAFT)],
);
assert!(out.is_empty(), "{out:?}");
}
#[test]
fn empty_declared_set_without_processors_fires() {
let out = findings_with(
"\\usepackage[anything]{mypkg}\n",
&[("/p/mypkg.sty", "\\ProvidesPackage{mypkg}\n")],
);
assert_eq!(out.len(), 1);
}
}