use std::path::Path;
use std::sync::OnceLock;
use rowan::{TextRange, TextSize};
use crate::project::{ResolvedCitations, ResolvedLabels};
use crate::semantic::SemanticModel;
use crate::syntax::{SyntaxElement, SyntaxKind, SyntaxNode};
use super::diagnostic::{Diagnostic, Severity};
pub mod abbreviation_spacing;
pub mod dash_length;
pub mod deprecated_command;
pub mod dollar_display_math;
pub mod duplicate_label;
pub mod duplicate_package;
pub mod ellipsis;
pub mod hard_coded_reference;
pub mod makeat_macro;
pub mod math_operator_name;
pub mod mismatched_delimiter;
pub mod missing_nonbreaking_space;
pub mod missing_provides;
pub mod missing_required_argument;
pub mod obsolete_environment;
pub mod primitive_command;
pub mod sectioning_level_jump;
pub mod space_before_command;
pub mod straight_quotes;
pub mod swallowed_space;
pub mod times_variable;
pub mod undefined_citation;
pub mod undefined_ref;
pub mod unreferenced_label;
pub mod verbatim_trailing_text;
pub use abbreviation_spacing::AbbreviationSpacing;
pub use dash_length::DashLength;
pub use deprecated_command::DeprecatedCommand;
pub use dollar_display_math::DollarDisplayMath;
pub use duplicate_label::DuplicateLabel;
pub use duplicate_package::DuplicatePackage;
pub use ellipsis::Ellipsis;
pub use hard_coded_reference::HardCodedReference;
pub use makeat_macro::MakeatMacro;
pub use math_operator_name::MathOperatorName;
pub use mismatched_delimiter::MismatchedDelimiter;
pub use missing_nonbreaking_space::MissingNonbreakingSpace;
pub use missing_provides::MissingProvides;
pub use missing_required_argument::MissingRequiredArgument;
pub use obsolete_environment::ObsoleteEnvironment;
pub use primitive_command::PrimitiveCommand;
pub use sectioning_level_jump::SectioningLevelJump;
pub use space_before_command::SpaceBeforeCommand;
pub use straight_quotes::StraightQuotes;
pub use swallowed_space::SwallowedSpace;
pub use times_variable::TimesVariable;
pub use undefined_citation::UndefinedCitation;
pub use undefined_ref::UndefinedRef;
pub use unreferenced_label::UnreferencedLabel;
pub use verbatim_trailing_text::VerbatimTrailingText;
pub struct RuleContext<'a> {
pub path: &'a Path,
pub root: &'a SyntaxNode,
pub model: &'a SemanticModel,
pub resolution: Option<&'a ResolvedLabels>,
pub citations: Option<&'a ResolvedCitations>,
math_regions: Vec<TextRange>,
}
impl<'a> RuleContext<'a> {
pub fn new(
path: &'a Path,
root: &'a SyntaxNode,
model: &'a SemanticModel,
resolution: Option<&'a ResolvedLabels>,
citations: Option<&'a ResolvedCitations>,
) -> Self {
Self {
path,
root,
model,
resolution,
citations,
math_regions: math_regions(root),
}
}
pub fn in_math(&self, offset: usize) -> bool {
let offset = TextSize::from(offset as u32);
match self
.math_regions
.binary_search_by(|r| r.start().cmp(&offset))
{
Ok(_) => true, Err(0) => false,
Err(i) => self.math_regions[i - 1].contains(offset),
}
}
}
fn math_regions(root: &SyntaxNode) -> Vec<TextRange> {
let mut ranges: Vec<TextRange> = root
.descendants()
.filter(|node| node.kind() == SyntaxKind::MATH)
.map(|node| node.text_range())
.collect();
ranges.sort_by_key(|r| r.start());
let mut merged: Vec<TextRange> = Vec::with_capacity(ranges.len());
for r in ranges {
match merged.last_mut() {
Some(last) if r.start() <= last.end() => {
*last = TextRange::new(last.start(), last.end().max(r.end()));
}
_ => merged.push(r),
}
}
merged
}
pub struct Example {
pub caption: &'static str,
pub source: &'static str,
}
pub trait Rule: Send + Sync {
fn id(&self) -> &'static str;
fn default_severity(&self) -> Severity {
Severity::Warning
}
fn description(&self) -> &'static str {
""
}
fn examples(&self) -> &'static [Example] {
&[]
}
fn example_path(&self) -> &'static str {
"example.tex"
}
fn interests(&self) -> &'static [SyntaxKind] {
&[]
}
fn check(&self, el: &SyntaxElement, ctx: &RuleContext<'_>, sink: &mut Vec<Diagnostic>) {
let _ = (el, ctx, sink);
}
fn check_file(&self, ctx: &RuleContext<'_>, sink: &mut Vec<Diagnostic>) {
let _ = (ctx, sink);
}
fn stream(&self) -> Option<Box<dyn StreamVisitor>> {
None
}
fn emits_fix(&self) -> bool {
false
}
}
pub trait StreamVisitor {
fn visit(&mut self, el: &SyntaxElement, ctx: &RuleContext<'_>, sink: &mut Vec<Diagnostic>);
fn finish(&mut self, ctx: &RuleContext<'_>, sink: &mut Vec<Diagnostic>) {
let _ = (ctx, sink);
}
}
pub fn all_rules() -> Vec<Box<dyn Rule>> {
vec![
Box::new(AbbreviationSpacing),
Box::new(DuplicateLabel),
Box::new(DeprecatedCommand),
Box::new(MissingNonbreakingSpace),
Box::new(ObsoleteEnvironment),
Box::new(PrimitiveCommand),
Box::new(DollarDisplayMath),
Box::new(Ellipsis),
Box::new(HardCodedReference),
Box::new(StraightQuotes),
Box::new(SwallowedSpace),
Box::new(SpaceBeforeCommand),
Box::new(MismatchedDelimiter),
Box::new(DashLength),
Box::new(TimesVariable),
Box::new(MathOperatorName),
Box::new(MakeatMacro),
Box::new(SectioningLevelJump),
Box::new(MissingRequiredArgument),
Box::new(UndefinedRef),
Box::new(UndefinedCitation),
Box::new(UnreferencedLabel),
Box::new(VerbatimTrailingText),
Box::new(DuplicatePackage),
Box::new(MissingProvides),
]
}
pub struct RuleRegistry {
pub rules: Vec<Box<dyn Rule>>,
pub by_kind: Vec<Vec<usize>>,
pub any_node_rules: bool,
}
impl RuleRegistry {
fn build(rules: Vec<Box<dyn Rule>>) -> Self {
let mut by_kind: Vec<Vec<usize>> = vec![Vec::new(); SyntaxKind::COUNT];
let mut any_node_rules = false;
for (i, rule) in rules.iter().enumerate() {
for kind in rule.interests() {
by_kind[*kind as usize].push(i);
any_node_rules = true;
}
}
Self {
rules,
by_kind,
any_node_rules,
}
}
}
pub fn registry() -> &'static RuleRegistry {
static REGISTRY: OnceLock<RuleRegistry> = OnceLock::new();
REGISTRY.get_or_init(|| RuleRegistry::build(all_rules()))
}
pub fn fixable_registry() -> &'static RuleRegistry {
static REGISTRY: OnceLock<RuleRegistry> = OnceLock::new();
REGISTRY.get_or_init(|| {
RuleRegistry::build(all_rules().into_iter().filter(|r| r.emits_fix()).collect())
})
}
pub const ALL_RULE_IDS: &[&str] = &[
"abbreviation-spacing",
"duplicate-label",
"deprecated-command",
"missing-nonbreaking-space",
"obsolete-environment",
"primitive-command",
"dollar-display-math",
"ellipsis",
"hard-coded-reference",
"straight-quotes",
"swallowed-space",
"space-before-command",
"mismatched-delimiter",
"dash-length",
"times-variable",
"math-operator-name",
"makeat-macro",
"sectioning-level-jump",
"missing-required-argument",
"undefined-ref",
"undefined-citation",
"unreferenced-label",
"verbatim-trailing-text",
"duplicate-package",
"missing-provides",
];
fn all_known_rule_ids() -> impl Iterator<Item = &'static str> {
ALL_RULE_IDS
.iter()
.copied()
.chain(crate::bib::linter::ALL_BIB_RULE_IDS.iter().copied())
}
pub const PARSE_RULE_ID: &str = "parse";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuleSelection {
active: Vec<&'static str>,
}
impl RuleSelection {
pub fn resolve(select: Option<&[String]>, ignore: &[String]) -> (Self, Vec<String>) {
let mut unknown = Vec::new();
for id in select.iter().flat_map(|v| v.iter()).chain(ignore.iter()) {
if !all_known_rule_ids().any(|known| known == id) {
unknown.push(id.clone());
}
}
let base: Vec<&'static str> = match select {
Some(picks) => all_known_rule_ids()
.filter(|id| picks.iter().any(|p| p == id))
.collect(),
None => all_known_rule_ids().collect(),
};
let active = base
.into_iter()
.filter(|id| !ignore.iter().any(|i| i == id))
.collect();
(Self { active }, unknown)
}
pub fn all() -> Self {
Self {
active: all_known_rule_ids().collect(),
}
}
pub fn is_active(&self, rule: &str) -> bool {
rule == PARSE_RULE_ID || self.active.contains(&rule)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registry_and_id_list_agree() {
let ids: Vec<&str> = all_rules().iter().map(|r| r.id()).collect();
assert_eq!(ids, ALL_RULE_IDS);
}
#[test]
fn emits_fix_matches_reality() {
for rule in all_rules() {
if rule.emits_fix() {
continue;
}
let path = std::path::Path::new(rule.example_path());
for example in rule.examples() {
let produced_fix = crate::linter::docs::demo_diagnostics_at(path, example.source)
.iter()
.any(|d| d.rule == rule.id() && d.fix.is_some());
assert!(
!produced_fix,
"rule `{}` emits a fix but `emits_fix()` returns false",
rule.id()
);
}
}
}
#[test]
fn all_selection_keeps_every_rule_and_parse() {
let sel = RuleSelection::all();
for id in ALL_RULE_IDS {
assert!(sel.is_active(id), "{id} should be active");
}
assert!(sel.is_active(PARSE_RULE_ID));
}
#[test]
fn select_restricts_to_listed_rules_but_keeps_parse() {
let (sel, unknown) = RuleSelection::resolve(Some(&["duplicate-label".to_string()]), &[]);
assert!(unknown.is_empty());
assert!(sel.is_active("duplicate-label"));
assert!(!sel.is_active("deprecated-command"));
assert!(sel.is_active(PARSE_RULE_ID));
}
#[test]
fn ignore_subtracts_from_default_set() {
let (sel, unknown) = RuleSelection::resolve(None, &["deprecated-command".to_string()]);
assert!(unknown.is_empty());
assert!(!sel.is_active("deprecated-command"));
assert!(sel.is_active("duplicate-label"));
}
#[test]
fn ignore_overrides_select() {
let (sel, _) = RuleSelection::resolve(
Some(&["duplicate-label".to_string(), "undefined-ref".to_string()]),
&["undefined-ref".to_string()],
);
assert!(sel.is_active("duplicate-label"));
assert!(!sel.is_active("undefined-ref"));
}
#[test]
fn bib_rules_are_active_by_default() {
let sel = RuleSelection::all();
for id in crate::bib::linter::ALL_BIB_RULE_IDS {
assert!(sel.is_active(id), "{id} should be active");
}
let (sel, unknown) = RuleSelection::resolve(None, &[]);
assert!(unknown.is_empty());
assert!(sel.is_active("missing-required-field"));
}
#[test]
fn bib_rules_are_selectable_and_ignorable() {
let (sel, unknown) =
RuleSelection::resolve(Some(&["missing-required-field".to_string()]), &[]);
assert!(unknown.is_empty(), "bib id must be recognized, not unknown");
assert!(sel.is_active("missing-required-field"));
assert!(!sel.is_active("duplicate-label"));
let (sel, unknown) = RuleSelection::resolve(None, &["missing-required-field".to_string()]);
assert!(unknown.is_empty());
assert!(!sel.is_active("missing-required-field"));
assert!(sel.is_active("duplicate-label"));
}
#[test]
fn unknown_ids_are_reported() {
let (_, unknown) = RuleSelection::resolve(
Some(&["duplicate-label".to_string(), "no-such-rule".to_string()]),
&["also-bogus".to_string()],
);
assert_eq!(
unknown,
vec!["no-such-rule".to_string(), "also-bogus".to_string()]
);
}
}