use crate::texparse::{tokenize_with_spans, Token};
use super::{LintFinding, Severity};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TriggerKind {
Package(&'static str),
Command(&'static str),
CommandWithArg(&'static str, &'static str),
}
#[derive(Debug)]
pub struct EngineRule {
pub severity: Severity,
pub triggers: &'static [TriggerKind],
pub message: &'static str,
pub suggestion: &'static str,
}
pub const ENGINE_RULES: &[EngineRule] = &[
EngineRule {
severity: Severity::Warning,
triggers: &[TriggerKind::Package("inputenc")],
message: "\\usepackage{inputenc} is ignored under Tectonic (a XeTeX engine): it always \
reads UTF-8 source, so the package does nothing",
suggestion: "Remove the \\usepackage{inputenc} line",
},
EngineRule {
severity: Severity::Warning,
triggers: &[TriggerKind::Package("epstopdf")],
message: "\\usepackage{epstopdf} will not work under Tectonic (a XeTeX engine): epstopdf \
supports only the pdfTeX and LuaTeX drivers, so included .eps graphics are not \
converted to a renderable format",
suggestion: "Convert the .eps files to PDF or PNG and \\includegraphics those instead",
},
EngineRule {
severity: Severity::Error,
triggers: &[
TriggerKind::Package("microtype"),
TriggerKind::Command("DisableLigatures"),
],
message: "\\DisableLigatures breaks the build under Tectonic (a XeTeX engine): microtype \
cannot disable a font's ligatures there — XeTeX aborts with 'Disabling \
ligatures of a font is only possible'",
suggestion: "Remove \\DisableLigatures from the preamble",
},
EngineRule {
severity: Severity::Error,
triggers: &[
TriggerKind::Package("fontspec"),
TriggerKind::CommandWithArg("setmainfont", "Latin Modern Roman"),
],
message: "\\setmainfont{Latin Modern Roman} breaks the build under Tectonic (a XeTeX \
engine): Tectonic ships no system fonts, so XeTeX aborts with 'The font ... \
cannot be found'",
suggestion: "Remove the \\setmainfont line and use the engine's bundled Latin Modern \
instead",
},
];
pub fn lint_files(files: &[(String, String)]) -> Vec<LintFinding> {
let mut fired: Vec<Vec<Option<(String, usize)>>> = ENGINE_RULES
.iter()
.map(|rule| vec![None; rule.triggers.len()])
.collect();
for (rel, source) in files {
let tokenized = tokenize_with_spans(source);
for spanned in &tokenized.tokens {
match &spanned.token {
Token::BeginDocument => break,
Token::Command { name, args } => {
let line = line_of(source, spanned.start);
for (rule_idx, rule) in ENGINE_RULES.iter().enumerate() {
for (trigger_idx, trigger) in rule.triggers.iter().enumerate() {
if fired[rule_idx][trigger_idx].is_none()
&& trigger_matches(trigger, name, args)
{
fired[rule_idx][trigger_idx] = Some((rel.clone(), line));
}
}
}
}
_ => {}
}
}
}
let mut findings = Vec::new();
for (rule_idx, rule) in ENGINE_RULES.iter().enumerate() {
if fired[rule_idx].iter().all(Option::is_some) {
let (file, line) = fired[rule_idx].last().unwrap().as_ref().unwrap();
findings.push(LintFinding {
file: file.clone(),
line: *line,
severity: rule.severity,
message: rule.message.to_string(),
suggestion: Some(rule.suggestion.to_string()),
});
}
}
findings
}
fn trigger_matches(trigger: &TriggerKind, name: &str, args: &[String]) -> bool {
match trigger {
TriggerKind::Package(pkg) => {
name == "usepackage" && args.last().is_some_and(|a| a.as_str() == *pkg)
}
TriggerKind::Command(cmd) => name == *cmd,
TriggerKind::CommandWithArg(cmd, arg) => {
name == *cmd && args.last().is_some_and(|a| a.as_str() == *arg)
}
}
}
fn line_of(source: &str, offset: usize) -> usize {
let offset = offset.min(source.len());
1 + source[..offset].matches('\n').count()
}
#[cfg(test)]
mod tests {
use super::*;
fn lint(source: &str) -> Vec<LintFinding> {
lint_files(&[("main.tex".to_string(), source.to_string())])
}
fn has_severity_with(findings: &[LintFinding], fragment: &str, sev: Severity) -> bool {
findings
.iter()
.any(|f| f.severity == sev && f.message.contains(fragment))
}
#[test]
fn inputenc_is_a_warning_naming_the_consequence() {
let findings = lint(
r"\documentclass{article}
\usepackage[utf8]{inputenc}
\begin{document}
\end{document}",
);
assert!(has_severity_with(
&findings,
"does nothing",
Severity::Warning
));
assert!(has_severity_with(&findings, "XeTeX", Severity::Warning));
}
#[test]
fn epstopdf_is_a_warning_naming_the_consequence() {
let findings = lint(
r"\documentclass{article}
\usepackage{epstopdf}
\begin{document}
\end{document}",
);
assert!(has_severity_with(&findings, ".eps", Severity::Warning));
assert!(has_severity_with(&findings, "XeTeX", Severity::Warning));
}
#[test]
fn disable_ligatures_with_microtype_is_an_error_naming_the_consequence() {
let findings = lint(
r"\documentclass{article}
\usepackage{microtype}
\DisableLigatures
\begin{document}
\end{document}",
);
assert!(has_severity_with(
&findings,
"breaks the build",
Severity::Error
));
assert!(has_severity_with(&findings, "XeTeX", Severity::Error));
}
#[test]
fn latin_modern_roman_setmainfont_with_fontspec_is_an_error_naming_the_consequence() {
let findings = lint(
r"\documentclass{article}
\usepackage{fontspec}
\setmainfont{Latin Modern Roman}
\begin{document}
\end{document}",
);
assert!(has_severity_with(
&findings,
"breaks the build",
Severity::Error
));
assert!(has_severity_with(
&findings,
"cannot be found",
Severity::Error
));
}
#[test]
fn microtype_without_disable_ligatures_is_clean() {
let findings = lint(
r"\documentclass{article}
\usepackage{microtype}
\begin{document}
\end{document}",
);
assert!(!has_severity_with(&findings, "ligature", Severity::Error));
}
#[test]
fn disable_ligatures_without_microtype_is_clean() {
let findings = lint(
r"\documentclass{article}
\DisableLigatures
\begin{document}
\end{document}",
);
assert!(!has_severity_with(&findings, "ligature", Severity::Error));
}
#[test]
fn setmainfont_of_another_font_is_clean() {
let findings = lint(
r"\documentclass{article}
\usepackage{fontspec}
\setmainfont{TeX Gyre Termes}
\begin{document}
\end{document}",
);
assert!(!has_severity_with(
&findings,
"cannot be found",
Severity::Error
));
}
#[test]
fn setmainfont_without_fontspec_is_clean() {
let findings = lint(
r"\documentclass{article}
\setmainfont{Latin Modern Roman}
\begin{document}
\end{document}",
);
assert!(!has_severity_with(
&findings,
"cannot be found",
Severity::Error
));
}
#[test]
fn a_clean_document_has_no_engine_findings() {
let findings = lint(
r"\documentclass{article}
\begin{document}
Hello
\end{document}",
);
assert!(findings.is_empty());
}
#[test]
fn constructs_after_begin_document_are_not_reported() {
let findings = lint(
r"\documentclass{article}
\begin{document}
\usepackage[utf8]{inputenc}
\setmainfont{Latin Modern Roman}
\end{document}",
);
assert!(findings.is_empty());
}
#[test]
fn constructs_in_comments_are_not_reported() {
let findings = lint(
r"\documentclass{article}
% \usepackage[utf8]{inputenc}
% \usepackage{epstopdf}
\begin{document}
\end{document}",
);
assert!(findings.is_empty());
}
#[test]
fn compound_rule_reports_the_command_location() {
let findings = lint(
"\\documentclass{article}\n\\usepackage{microtype}\n\\DisableLigatures\n\\begin{document}",
);
let finding = findings
.iter()
.find(|f| f.severity == Severity::Error && f.message.contains("DisableLigatures"))
.unwrap();
assert_eq!(finding.line, 3);
assert_eq!(finding.file, "main.tex");
}
#[test]
fn packages_loaded_from_input_files_are_seen() {
let dir = tempfile::TempDir::new().unwrap();
std::fs::write(
dir.path().join("main.tex"),
"\\documentclass{article}\n\\input{preamble}\n\\begin{document}\n\\end{document}",
)
.unwrap();
std::fs::write(
dir.path().join("preamble.tex"),
"\\usepackage[utf8]{inputenc}\n\\usepackage{epstopdf}",
)
.unwrap();
let findings = super::super::lint(dir.path(), "main.tex", None).unwrap();
assert!(has_severity_with(
&findings,
"does nothing",
Severity::Warning
));
assert!(has_severity_with(&findings, ".eps", Severity::Warning));
}
#[test]
fn compound_rule_fires_across_input_files() {
let dir = tempfile::TempDir::new().unwrap();
std::fs::write(
dir.path().join("main.tex"),
"\\documentclass{article}\n\\usepackage{fontspec}\n\\input{preamble}\n\\begin{document}\n\\end{document}",
)
.unwrap();
std::fs::write(
dir.path().join("preamble.tex"),
"\\setmainfont{Latin Modern Roman}",
)
.unwrap();
let findings = super::super::lint(dir.path(), "main.tex", None).unwrap();
assert!(has_severity_with(
&findings,
"cannot be found",
Severity::Error
));
}
}