Skip to main content

jsslint_core/
engine.rs

1//! Document assembly + rule-running engine — mirrors
2//! `core/engine.py`'s `parse_document`/`load_journal`/`run`.
3//!
4//! `.tex`/`.ltx`/`.bib`/`.rnw`/`.rmd` are all supported. `.rnw` chunk
5//! rewriting lives in `crate::rnw`; `.rmd` tokenizing/fragment
6//! construction lives in `crate::rmd` (see that module's doc comment
7//! for the line-offset scheme). Most rules iterate
8//! `all_tex_like_docs()` (tex_files + every `.Rmd` fragment); a few
9//! (`GLOBAL_TEX_RULES`, below) need every fragment together in one
10//! call instead of one call per fragment — see that constant's doc
11//! comment for which and why.
12//!
13//! No filesystem I/O here — `from_sources` takes already-read
14//! `(path, contents)` pairs so every binding (including WASM, which
15//! has no filesystem) can drive the same engine.
16
17use crate::bib::{self, Library};
18use crate::catalogue;
19use crate::config::{ConfidenceTier, DoiResolver, ToolConfig};
20use crate::report::{CategorySummary, ComplianceReport, SkippedRule, Violation};
21use crate::rules::{
22    abbreviations, capitalization, citations, code_style, code_width, crossrefs, house_style,
23    markup, naming, operators, preamble, references, structure, typography,
24};
25use crate::tex::node::Node as TexNode;
26use crate::tex::position::LineIndex;
27use crate::tex::{self, ParsedTex};
28use std::collections::HashMap;
29
30#[derive(Debug)]
31pub enum EngineError {
32    /// The stored `String` is the complete, ready-to-print message —
33    /// mirrors `api.py::UnsupportedSuffixError`'s
34    /// `f"unsupported file extension: {path.name!r}. Supported: .tex,
35    /// .ltx, .bib, .Rnw, .Rmd (case-insensitive)."` text exactly.
36    /// `jsslint-cli` prints it verbatim (destructures the payload and
37    /// formats it directly rather than going through `Display`).
38    UnsupportedSuffix(String),
39}
40
41impl std::fmt::Display for EngineError {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        match self {
44            EngineError::UnsupportedSuffix(msg) => write!(f, "{msg}"),
45        }
46    }
47}
48
49pub struct ParsedTexFileDoc {
50    pub path: String,
51    pub parsed: ParsedTex,
52    pub line_index: LineIndex,
53    /// `JSS-PARSE-000` findings attached to this file directly (not a
54    /// rule finding) — currently only the CLI's lenient-UTF-8-decode
55    /// warning (`attach_violation`); the tex tokenizer itself never
56    /// raises (see `run`'s doc comment). Empty for `.Rmd` raw-LaTeX
57    /// prose fragments (`ParsedRmdFileDoc` carries its own).
58    pub violations: Vec<Violation>,
59}
60
61pub struct ParsedBibFileDoc {
62    pub path: String,
63    pub source_chars: Vec<char>,
64    pub library: Library,
65    /// See `ParsedTexFileDoc::violations`.
66    pub violations: Vec<Violation>,
67}
68
69/// Mirrors `api.py::ParsedRmdFile`. `latex_fragments` are the raw-LaTeX
70/// islands extracted from prose blocks (spec 005 FR-006) — every rule
71/// that iterates `all_tex_like_docs()` sees these alongside real `.tex`
72/// files. `violations` are `JSS-PARSE-000` findings from the Rmd
73/// tokenizer itself (unterminated frontmatter/fence, malformed YAML),
74/// not rule findings.
75pub struct ParsedRmdFileDoc {
76    pub path: String,
77    pub latex_fragments: Vec<ParsedTexFileDoc>,
78    pub violations: Vec<Violation>,
79}
80
81#[derive(Default)]
82pub struct ParsedDocument {
83    pub tex_files: Vec<ParsedTexFileDoc>,
84    pub bib_files: Vec<ParsedBibFileDoc>,
85    pub rmd_files: Vec<ParsedRmdFileDoc>,
86}
87
88impl ParsedDocument {
89    /// Build a document from `(path, contents)` pairs, dispatching by
90    /// path suffix (case-insensitive) exactly like
91    /// `core/engine.py::parse_document`: `.tex`/`.ltx` -> tex_files,
92    /// `.bib` -> bib_files, `.rnw` -> tex_files (after chunk-rewriting),
93    /// `.rmd` -> rmd_files. Any other suffix is an error.
94    pub fn from_sources(files: &[(String, String)]) -> Result<Self, EngineError> {
95        let mut doc = ParsedDocument::default();
96        for (path, source) in files {
97            // Mirrors `core/parser.py::_read_utf8`: strip exactly one
98            // leading UTF-8 BOM (U+FEFF), if present, before parsing.
99            // Applied here — the single choke point every suffix
100            // branch and every binding (CLI/WASM/PyO3/R) goes through
101            // — rather than separately inside each parser, mirroring
102            // how Python's `_read_utf8` is called uniformly by
103            // `parse_tex_file`/`parse_bib_file`/`parse_rnw_file`/
104            // `parse_rmd_file`. A source with no BOM is unaffected.
105            let source: &str = source.strip_prefix('\u{FEFF}').unwrap_or(source);
106            let lower = path.to_lowercase();
107            if lower.ends_with(".tex") || lower.ends_with(".ltx") {
108                let parsed = tex::parse_tex_source(source);
109                let line_index = LineIndex::with_offset(&parsed.chars, parsed.line_offset);
110                doc.tex_files.push(ParsedTexFileDoc {
111                    path: path.clone(),
112                    parsed,
113                    line_index,
114                    violations: Vec::new(),
115                });
116            } else if lower.ends_with(".bib") {
117                let library = bib::parse(source);
118                doc.bib_files.push(ParsedBibFileDoc {
119                    path: path.clone(),
120                    source_chars: source.chars().collect(),
121                    library,
122                    violations: Vec::new(),
123                });
124            } else if lower.ends_with(".rnw") {
125                let rewritten = crate::rnw::wrap_rnw_chunks_as_sinput(source);
126                let parsed = tex::parse_tex_source(&rewritten);
127                let line_index = LineIndex::with_offset(&parsed.chars, parsed.line_offset);
128                doc.tex_files.push(ParsedTexFileDoc {
129                    path: path.clone(),
130                    parsed,
131                    line_index,
132                    violations: Vec::new(),
133                });
134            } else if lower.ends_with(".rmd") {
135                doc.rmd_files
136                    .push(crate::rmd::parse_rmd_source(path, source));
137            } else {
138                let name = path.rsplit('/').next().unwrap_or(path.as_str());
139                return Err(EngineError::UnsupportedSuffix(format!(
140                    "unsupported file extension: '{name}'. Supported: .tex, .ltx, .bib, .Rnw, .Rmd (case-insensitive)."
141                )));
142            }
143        }
144        Ok(doc)
145    }
146
147    /// Attach a `JSS-PARSE-000` finding directly to the parsed file at
148    /// `path` — used by the CLI's lenient UTF-8 fallback
149    /// (`decode::read_lenient_utf8`) to surface a degraded-parse (or
150    /// unreadable-file) diagnostic after the fact, without needing
151    /// `from_sources` itself to know about filesystem-level concerns
152    /// (mirrors `core/parser.py::parse_tex_file`/`parse_bib_file`/
153    /// `parse_rmd_file` prepending `read_err` to `parsed.violations`).
154    /// A no-op if `path` isn't in this document (shouldn't happen —
155    /// callers pass back exactly the paths they built `sources` from).
156    pub fn attach_violation(&mut self, path: &str, violation: Violation) {
157        for tf in &mut self.tex_files {
158            if tf.path == path {
159                tf.violations.push(violation);
160                return;
161            }
162        }
163        for bf in &mut self.bib_files {
164            if bf.path == path {
165                bf.violations.push(violation);
166                return;
167            }
168        }
169        for rf in &mut self.rmd_files {
170            if rf.path == path {
171                rf.violations.push(violation);
172                return;
173            }
174        }
175    }
176
177    /// Every tex-shaped parsed view: native `.tex`/`.rnw` files plus
178    /// raw-LaTeX islands extracted from `.Rmd` prose blocks. Mirrors
179    /// `api.py::ParsedDocument.all_tex_like`; rules that in Python
180    /// iterate `doc.all_tex_like()` use this instead of `tex_files`.
181    pub(crate) fn all_tex_like_docs(&self) -> impl Iterator<Item = &ParsedTexFileDoc> {
182        self.tex_files
183            .iter()
184            .chain(self.rmd_files.iter().flat_map(|r| r.latex_fragments.iter()))
185    }
186
187    fn tex_like(&self) -> Vec<&[TexNode]> {
188        self.all_tex_like_docs()
189            .map(|t| t.parsed.nodes.as_slice())
190            .collect()
191    }
192
193    fn tex_file_line_indexes(&self) -> Vec<(&str, &LineIndex)> {
194        self.all_tex_like_docs()
195            .map(|t| (t.path.as_str(), &t.line_index))
196            .collect()
197    }
198}
199
200type TexCheckFn = fn(&str, &ParsedTex, u32) -> Vec<Violation>;
201/// The trailing `Option<&DoiResolver>` is `JSS-REFS-003`'s online-mode
202/// hook (`config.doi_resolver.as_deref()`, threaded in from `run()`
203/// below); every other bib rule's closure just ignores it, same as the
204/// existing `_tl`/`_tf`-ignoring closures below.
205type BibCheckFn = fn(
206    &str,
207    &[char],
208    &Library,
209    &[&[TexNode]],
210    &[(&str, &LineIndex)],
211    Option<&DoiResolver>,
212) -> Vec<Violation>;
213/// Called ONCE per document with every tex-like fragment together,
214/// unlike `TexCheckFn` (called once per fragment). Mirrors
215/// `Rule.check(doc, cfg)` receiving the whole `ParsedDocument` in
216/// Python: a handful of rules maintain state that must span every
217/// fragment (a `.Rmd` file's multiple raw-LaTeX prose-block islands),
218/// not reset per fragment — see `GLOBAL_TEX_RULES`'s doc comment.
219type TexGlobalCheckFn = fn(&[(&str, &ParsedTex)]) -> Vec<Violation>;
220
221struct TexRuleEntry {
222    id: &'static str,
223    check: TexCheckFn,
224    /// True for the 9 rules that restrict `Rule.formats` to
225    /// `{"tex","rnw"}` (PRE-001..008, OPER-003) — see
226    /// `rule_should_run`'s doc comment for how this is used. False
227    /// (the default for every other entry below) means `formats is
228    /// None` in Python terms: "all formats", i.e. this rule still
229    /// counts as applied/passed even in a document with zero tex
230    /// files, as long as some other file (a `.bib`) is present.
231    restricted_to_tex: bool,
232    /// True when the Python rule body iterates `doc.all_tex_like()`
233    /// (tex_files + every `.Rmd`'s raw-LaTeX prose fragments) rather
234    /// than `doc.tex_files` alone — true for every entry except
235    /// `WIDTH-001`, `STRUCT-001..006`, `PRE-001..008`, and `OPER-003`
236    /// (confirmed by grepping every rule module in `journals/jss/rules/`
237    /// for `all_tex_like` vs `doc.tex_files`). Independent of
238    /// `restricted_to_tex`: that gate is about whether the rule counts
239    /// as "applied" for a given input-format mix; this one is about
240    /// which files its `check` function actually reads.
241    scans_rmd_prose: bool,
242}
243
244struct BibRuleEntry {
245    id: &'static str,
246    check: BibCheckFn,
247}
248
249struct GlobalTexRuleEntry {
250    id: &'static str,
251    check: TexGlobalCheckFn,
252}
253
254/// Every ported `tex_files`/`raw_source` rule (46 + WIDTH-001), one
255/// adapter closure per rule normalizing its real signature to
256/// `TexCheckFn`.
257mod rules_registry {
258    use super::*;
259
260    pub const TEX_RULES: &[TexRuleEntry] = &[
261        TexRuleEntry {
262            id: "JSS-WIDTH-001",
263            check: |f, p, w| code_width::check_width_001(f, p, w),
264            restricted_to_tex: false,
265            scans_rmd_prose: false,
266        },
267        TexRuleEntry {
268            id: "JSS-CODE-001",
269            check: |f, p, _w| code_style::check_code_001(f, p),
270            restricted_to_tex: false,
271            scans_rmd_prose: true,
272        },
273        TexRuleEntry {
274            id: "JSS-CODE-002",
275            check: |f, p, _w| code_style::check_code_002(f, p),
276            restricted_to_tex: false,
277            scans_rmd_prose: true,
278        },
279        TexRuleEntry {
280            id: "JSS-CODE-003",
281            check: |f, p, _w| code_style::check_code_003(f, p),
282            restricted_to_tex: false,
283            scans_rmd_prose: true,
284        },
285        TexRuleEntry {
286            id: "JSS-ABBR-001",
287            check: |f, p, _w| abbreviations::check_abbr_001(f, p),
288            restricted_to_tex: false,
289            scans_rmd_prose: true,
290        },
291        TexRuleEntry {
292            id: "JSS-TYPO-001",
293            check: |f, p, _w| typography::check_typo_001(f, p),
294            restricted_to_tex: false,
295            scans_rmd_prose: true,
296        },
297        TexRuleEntry {
298            id: "JSS-TYPO-002",
299            check: |f, p, _w| typography::check_typo_002(f, p),
300            restricted_to_tex: false,
301            scans_rmd_prose: true,
302        },
303        TexRuleEntry {
304            id: "JSS-TYPO-003",
305            check: |f, p, _w| typography::check_typo_003(f, p),
306            restricted_to_tex: false,
307            scans_rmd_prose: true,
308        },
309        TexRuleEntry {
310            id: "JSS-TYPO-004",
311            check: |f, p, _w| typography::check_typo_004(f, p),
312            restricted_to_tex: false,
313            scans_rmd_prose: true,
314        },
315        TexRuleEntry {
316            id: "JSS-CAP-002",
317            check: |f, p, _w| capitalization::check_cap_002(f, p),
318            restricted_to_tex: false,
319            scans_rmd_prose: true,
320        },
321        TexRuleEntry {
322            id: "JSS-CAP-004",
323            check: |f, p, _w| capitalization::check_cap_004(f, p),
324            restricted_to_tex: false,
325            scans_rmd_prose: true,
326        },
327        TexRuleEntry {
328            id: "JSS-STRUCT-001",
329            check: |f, p, _w| structure::check_struct_001(f, p),
330            restricted_to_tex: false,
331            scans_rmd_prose: false,
332        },
333        TexRuleEntry {
334            id: "JSS-STRUCT-002",
335            check: |f, p, _w| structure::check_struct_002(f, p),
336            restricted_to_tex: false,
337            scans_rmd_prose: false,
338        },
339        TexRuleEntry {
340            id: "JSS-STRUCT-003",
341            check: |f, p, _w| structure::check_struct_003(f, p),
342            restricted_to_tex: false,
343            scans_rmd_prose: false,
344        },
345        TexRuleEntry {
346            id: "JSS-STRUCT-004",
347            check: |f, p, _w| structure::check_struct_004(f, p),
348            restricted_to_tex: false,
349            scans_rmd_prose: false,
350        },
351        TexRuleEntry {
352            id: "JSS-STRUCT-005",
353            check: |f, p, _w| structure::check_struct_005(f, p),
354            restricted_to_tex: false,
355            scans_rmd_prose: false,
356        },
357        TexRuleEntry {
358            id: "JSS-STRUCT-006",
359            check: |f, p, _w| structure::check_struct_006(f, p),
360            restricted_to_tex: false,
361            scans_rmd_prose: false,
362        },
363        TexRuleEntry {
364            id: "JSS-OPER-001",
365            check: |f, p, _w| operators::check_oper_001(f, p),
366            restricted_to_tex: false,
367            scans_rmd_prose: true,
368        },
369        TexRuleEntry {
370            id: "JSS-OPER-002",
371            check: |f, p, _w| operators::check_oper_002(f, p),
372            restricted_to_tex: false,
373            scans_rmd_prose: true,
374        },
375        TexRuleEntry {
376            id: "JSS-OPER-003",
377            check: |f, p, _w| operators::check_oper_003(f, p),
378            restricted_to_tex: true,
379            scans_rmd_prose: false,
380        },
381        TexRuleEntry {
382            id: "JSS-HOUSE-001",
383            check: |f, p, _w| house_style::check_house_001(f, p),
384            restricted_to_tex: false,
385            scans_rmd_prose: true,
386        },
387        TexRuleEntry {
388            id: "JSS-HOUSE-003",
389            check: |f, p, _w| house_style::check_house_003(f, p),
390            restricted_to_tex: false,
391            scans_rmd_prose: true,
392        },
393        TexRuleEntry {
394            id: "JSS-NAME-001",
395            check: |f, p, _w| naming::check_name_001(f, p),
396            restricted_to_tex: false,
397            scans_rmd_prose: true,
398        },
399        TexRuleEntry {
400            id: "JSS-PRE-001",
401            check: |f, p, _w| preamble::check_pre_001(f, p),
402            restricted_to_tex: true,
403            scans_rmd_prose: false,
404        },
405        TexRuleEntry {
406            id: "JSS-PRE-002",
407            check: |f, p, _w| preamble::check_pre_002(f, p),
408            restricted_to_tex: true,
409            scans_rmd_prose: false,
410        },
411        TexRuleEntry {
412            id: "JSS-PRE-003",
413            check: |f, p, _w| preamble::check_pre_003(f, p),
414            restricted_to_tex: true,
415            scans_rmd_prose: false,
416        },
417        TexRuleEntry {
418            id: "JSS-PRE-004",
419            check: |f, p, _w| preamble::check_pre_004(f, p),
420            restricted_to_tex: true,
421            scans_rmd_prose: false,
422        },
423        TexRuleEntry {
424            id: "JSS-PRE-005",
425            check: |f, p, _w| preamble::check_pre_005(f, p),
426            restricted_to_tex: true,
427            scans_rmd_prose: false,
428        },
429        TexRuleEntry {
430            id: "JSS-PRE-006",
431            check: |f, p, _w| preamble::check_pre_006(f, p),
432            restricted_to_tex: true,
433            scans_rmd_prose: false,
434        },
435        TexRuleEntry {
436            id: "JSS-PRE-007",
437            check: |f, p, _w| preamble::check_pre_007(f, p),
438            restricted_to_tex: true,
439            scans_rmd_prose: false,
440        },
441        TexRuleEntry {
442            id: "JSS-PRE-008",
443            check: |f, p, _w| preamble::check_pre_008(f, p),
444            restricted_to_tex: true,
445            scans_rmd_prose: false,
446        },
447        TexRuleEntry {
448            id: "JSS-CITE-003",
449            check: |f, p, _w| citations::check_cite_003(f, p),
450            restricted_to_tex: false,
451            scans_rmd_prose: true,
452        },
453        TexRuleEntry {
454            id: "JSS-CITE-004",
455            check: |f, p, _w| citations::check_cite_004(f, p),
456            restricted_to_tex: false,
457            scans_rmd_prose: true,
458        },
459        TexRuleEntry {
460            id: "JSS-MARKUP-001",
461            check: |f, p, _w| markup::check_markup_001(f, p),
462            restricted_to_tex: false,
463            scans_rmd_prose: true,
464        },
465        TexRuleEntry {
466            id: "JSS-MARKUP-002",
467            check: |f, p, _w| markup::check_markup_002(f, p),
468            restricted_to_tex: false,
469            scans_rmd_prose: true,
470        },
471        TexRuleEntry {
472            id: "JSS-MARKUP-003",
473            check: |f, p, _w| markup::check_markup_003(f, p),
474            restricted_to_tex: false,
475            scans_rmd_prose: true,
476        },
477        TexRuleEntry {
478            id: "JSS-MARKUP-004",
479            check: |f, p, _w| markup::check_markup_004(f, p),
480            restricted_to_tex: false,
481            scans_rmd_prose: true,
482        },
483        TexRuleEntry {
484            id: "JSS-XREF-001",
485            check: |f, p, _w| crossrefs::check_xref_001(f, p),
486            restricted_to_tex: false,
487            scans_rmd_prose: true,
488        },
489        TexRuleEntry {
490            id: "JSS-XREF-002",
491            check: |f, p, _w| crossrefs::check_xref_002(f, p),
492            restricted_to_tex: false,
493            scans_rmd_prose: true,
494        },
495        TexRuleEntry {
496            id: "JSS-XREF-003",
497            check: |f, p, _w| crossrefs::check_xref_003(f, p),
498            restricted_to_tex: false,
499            scans_rmd_prose: true,
500        },
501        TexRuleEntry {
502            id: "JSS-XREF-006",
503            check: |f, p, _w| crossrefs::check_xref_006(f, p),
504            restricted_to_tex: false,
505            scans_rmd_prose: true,
506        },
507        TexRuleEntry {
508            id: "JSS-XREF-007",
509            check: |f, p, _w| crossrefs::check_xref_007(f, p),
510            restricted_to_tex: false,
511            scans_rmd_prose: true,
512        },
513    ];
514
515    pub const BIB_RULES: &[BibRuleEntry] = &[
516        BibRuleEntry {
517            id: "JSS-BIBTEX-001",
518            check: |f, _c, lib, tl, _tf, _r| crate::rules::bibtex::check_bibtex_001(f, lib, tl),
519        },
520        BibRuleEntry {
521            id: "JSS-BIBTEX-002",
522            check: |f, _c, lib, _tl, _tf, _r| crate::rules::bibtex::check_bibtex_002(f, lib),
523        },
524        BibRuleEntry {
525            id: "JSS-BIBTEX-003",
526            check: |f, _c, lib, tl, _tf, _r| crate::rules::bibtex::check_bibtex_003(f, lib, tl),
527        },
528        BibRuleEntry {
529            id: "JSS-BIBTEX-004",
530            check: |f, _c, lib, tl, tf, _r| crate::rules::bibtex::check_bibtex_004(f, tf, lib, tl),
531        },
532        BibRuleEntry {
533            id: "JSS-BIBTEX-005",
534            check: |f, _c, lib, _tl, _tf, _r| crate::rules::bibtex::check_bibtex_005(f, lib),
535        },
536        BibRuleEntry {
537            id: "JSS-NAME-002",
538            check: |f, c, lib, tl, _tf, _r| naming::check_name_002(f, c, lib, tl),
539        },
540        BibRuleEntry {
541            id: "JSS-HOUSE-002",
542            check: |f, c, lib, tl, _tf, _r| house_style::check_house_002(f, c, lib, tl),
543        },
544        BibRuleEntry {
545            id: "JSS-REFS-001",
546            check: |f, _c, lib, tl, _tf, _r| references::check_refs_001(f, lib, tl),
547        },
548        BibRuleEntry {
549            id: "JSS-REFS-003",
550            check: |f, c, lib, tl, _tf, r| references::check_refs_003(f, c, lib, tl, r),
551        },
552        BibRuleEntry {
553            id: "JSS-REFS-004",
554            check: |f, _c, lib, tl, _tf, _r| references::check_refs_004(f, lib, tl),
555        },
556        BibRuleEntry {
557            id: "JSS-REFS-005",
558            check: |f, _c, lib, tl, _tf, _r| references::check_refs_005(f, lib, tl),
559        },
560        BibRuleEntry {
561            id: "JSS-REFS-006",
562            check: |f, _c, lib, tl, _tf, _r| references::check_refs_006(f, lib, tl),
563        },
564        BibRuleEntry {
565            id: "JSS-REFS-007",
566            check: |f, _c, lib, tl, _tf, _r| references::check_refs_007(f, lib, tl),
567        },
568    ];
569
570    /// The 5 tex-like rules confirmed (by auditing every `check_jss_*`
571    /// in `journals/jss/rules/*.py` for a doc-wide pre-pass before its
572    /// per-fragment walk) to maintain state that must span every
573    /// tex-like fragment, not reset per fragment: CAP-001
574    /// (`_doc_pkg_names_lower`), CITE-002 (`seen` — its own Python
575    /// comment calls out `.Rmd`'s per-prose-block fragmentation by
576    /// name), OPER-004 (`flag_pr`/alias pre-scan), XREF-004/005
577    /// (`_collect_referenced_labels`). Every other tex-like rule reads
578    /// fresh/local state on each fragment, so calling it once per
579    /// fragment (`TEX_RULES`, above) gives an identical result to
580    /// calling it once with all fragments together — these 5 don't.
581    pub const GLOBAL_TEX_RULES: &[GlobalTexRuleEntry] = &[
582        GlobalTexRuleEntry {
583            id: "JSS-CAP-001",
584            check: capitalization::check_cap_001,
585        },
586        GlobalTexRuleEntry {
587            id: "JSS-CITE-002",
588            check: citations::check_cite_002,
589        },
590        GlobalTexRuleEntry {
591            id: "JSS-OPER-004",
592            check: operators::check_oper_004,
593        },
594        GlobalTexRuleEntry {
595            id: "JSS-XREF-004",
596            check: crossrefs::check_xref_004,
597        },
598        GlobalTexRuleEntry {
599            id: "JSS-XREF-005",
600            check: crossrefs::check_xref_005,
601        },
602    ];
603}
604
605/// Mirrors Python `repr(sorted(some_str_set))` for a list of plain
606/// (quote-free) format tags — `str.__repr__` single-quotes. `pub(crate)`
607/// so `diff.rs`'s schema-mismatch messages (also built from
608/// `repr(sorted(some_set))`) can reuse it instead of duplicating.
609pub(crate) fn python_list_repr(items: &[&str]) -> String {
610    let quoted: Vec<String> = items.iter().map(|s| format!("'{s}'")).collect();
611    format!("[{}]", quoted.join(", "))
612}
613
614/// Mirrors Python's `round(x, 1)`: round-half-to-even on the exact
615/// binary value, not `f64::round()`'s round-half-away-from-zero.
616/// `compliance_percentage` is always `100.0 * k / n` for small integer
617/// `k`/`n` (the ratable-category counts), so exact `.x5` ties — where
618/// the two rounding rules disagree — are common (e.g. `13/16 * 100 ==
619/// 81.25` exactly, since both `13` and `16` are exactly representable
620/// in binary). Rust's fixed-precision float formatting already applies
621/// round-half-to-even (IEEE 754 default), so routing through it here
622/// reproduces Python's tie-breaking exactly instead of reimplementing
623/// the rounding algorithm.
624fn python_round1(x: f64) -> f64 {
625    format!("{x:.1}").parse().unwrap_or(x)
626}
627
628/// Mirrors `journals/jss/__init__.py::_TITLE_MAP` — display titles for
629/// `catalogue::categories()`'s ids, hand-maintained on the Python side
630/// too (not generated from catalogue.yaml).
631fn category_title(category_id: &str) -> &'static str {
632    match category_id {
633        "preamble" => "Preamble",
634        "structure" => "Structure",
635        "markup" => "Markup",
636        "citations" => "Citations",
637        "references" => "References",
638        "bibtex" => "BibTeX",
639        "naming" => "Naming",
640        "capitalization" => "Capitalization",
641        "typography" => "Typography",
642        "abbreviations" => "Abbreviations",
643        "code_style" => "Code style",
644        "code_width" => "Code width",
645        "operators" => "Operators",
646        "crossrefs" => "Cross-references",
647        "house_style" => "House style",
648        "project" => "Project",
649        other => {
650            panic!("unknown category id {other:?} (not in journals/jss/__init__.py's _TITLE_MAP)")
651        }
652    }
653}
654
655fn confidence_meets_floor(rule_id: &str, floor: ConfidenceTier) -> (bool, &'static str) {
656    let confidence = catalogue::lookup(rule_id)
657        .map(|m| m.confidence)
658        .unwrap_or("high");
659    let tier = ConfidenceTier::parse(confidence).unwrap_or(ConfidenceTier::High);
660    (tier >= floor, confidence)
661}
662
663/// `catalogue::categories()`'s (ROLLOUT_ORDER) position for a rule's
664/// category, or `usize::MAX` for an unknown category (sorts last).
665fn category_rank(category: &str) -> usize {
666    catalogue::categories()
667        .iter()
668        .position(|&c| c == category)
669        .unwrap_or(usize::MAX)
670}
671
672enum RuleAction<'a> {
673    Tex(&'a TexRuleEntry),
674    Bib(&'a BibRuleEntry),
675    TexGlobal(&'a GlobalTexRuleEntry),
676}
677
678/// Every registered rule, ordered `(category rollout rank, rule id)` —
679/// mirrors `JSSJournal.categories()` iterating `ROLLOUT_ORDER` and
680/// each category module's `rules` tuple in ascending-rule-number
681/// declaration order (which sorting by rule id string reproduces,
682/// since every rule id's numeric suffix is zero-padded to 3 digits).
683/// This order matters beyond cosmetics: `report.skipped_rules` is a
684/// flat list built in this same iteration order.
685fn ordered_rules() -> Vec<(&'static str, RuleAction<'static>)> {
686    let mut all: Vec<(usize, &'static str, RuleAction<'static>)> = Vec::new();
687    for entry in rules_registry::TEX_RULES {
688        let category = catalogue::lookup(entry.id)
689            .map(|m| m.category)
690            .unwrap_or("");
691        all.push((category_rank(category), entry.id, RuleAction::Tex(entry)));
692    }
693    for entry in rules_registry::BIB_RULES {
694        let category = catalogue::lookup(entry.id)
695            .map(|m| m.category)
696            .unwrap_or("");
697        all.push((category_rank(category), entry.id, RuleAction::Bib(entry)));
698    }
699    for entry in rules_registry::GLOBAL_TEX_RULES {
700        let category = catalogue::lookup(entry.id)
701            .map(|m| m.category)
702            .unwrap_or("");
703        all.push((
704            category_rank(category),
705            entry.id,
706            RuleAction::TexGlobal(entry),
707        ));
708    }
709    all.sort_by(|a, b| (a.0, a.1).cmp(&(b.0, b.1)));
710    all.into_iter()
711        .map(|(_, id, action)| (id, action))
712        .collect()
713}
714
715/// Run every registered rule over `document`, applying the
716/// ignore-rules and min-confidence gates, and assemble a
717/// `ComplianceReport`. Mirrors `core/engine.py::run`'s algorithm
718/// (category/rule iteration, `SkippedRule` bookkeeping, format
719/// gating, the synthetic `JSS-PARSE-000` "parse" category,
720/// `compliance_percentage`, sorted violations, severity overrides).
721/// One thing still deferred: inline suppression comments (`%
722/// jss-lint: ignore [RULE-IDS]`) aren't implemented — every rule
723/// finding is reported regardless. `JSS-PARSE-000` findings currently
724/// only ever come from `.Rmd`'s tokenizer (unterminated
725/// frontmatter/fence, malformed YAML); the tex tokenizer itself
726/// (`.tex`/`.ltx`/`.rnw`) never emits one — it only implements
727/// pylatexenc's tolerant-parsing path, which never raises, unlike
728/// Python's strict-then-tolerant-retry `parse_tex_source` (see
729/// `tex::parse_tex_source`'s doc comment).
730pub fn run(config: &ToolConfig, document: &ParsedDocument) -> ComplianceReport {
731    run_impl(config, document, None)
732}
733
734/// Like `run`, but also dispatches `JSS-PROJECT-001` (cycle) /
735/// `JSS-PROJECT-002` (missing reference) — the graph-level violations
736/// a filesystem-bound resolver (`jsslint-cli`'s `resolver` module,
737/// mirroring `core/resolver.py`) already computed by walking
738/// `document`'s `\input`/`\include`/`\subfile`/`\bibliography` graph.
739/// Mirrors `core/engine.py::run` being handed a `ParsedProject` instead
740/// of a plain `ParsedDocument`: these two rules only ever run when a
741/// project was actually resolved (spec 013's auto-resolve path); a
742/// bare multi-file `document` (explicit CLI args, `--no-resolve`, or
743/// any other subcommand) uses plain `run` above and never invokes them
744/// — leaving the "project" category at 0 rules applied (SKIPPED),
745/// same as Python leaves `rule.check_project` uncalled for a bare
746/// `ParsedDocument`.
747pub fn run_with_project(
748    config: &ToolConfig,
749    document: &ParsedDocument,
750    cycles: Vec<Violation>,
751    missing: Vec<Violation>,
752) -> ComplianceReport {
753    run_impl(config, document, Some((cycles, missing)))
754}
755
756fn run_impl(
757    config: &ToolConfig,
758    document: &ParsedDocument,
759    project_extra: Option<(Vec<Violation>, Vec<Violation>)>,
760) -> ComplianceReport {
761    let tex_like = document.tex_like();
762    let tex_file_line_indexes = document.tex_file_line_indexes();
763
764    let mut applied_by_category: HashMap<&'static str, u32> = HashMap::new();
765    let mut passed_by_category: HashMap<&'static str, u32> = HashMap::new();
766    let mut violations_by_category: HashMap<&'static str, Vec<Violation>> = HashMap::new();
767    let mut skipped: Vec<SkippedRule> = Vec::new();
768
769    let mut run_one = |rule_id: &'static str, mut rule_violations: Vec<Violation>| {
770        let Some(meta) = catalogue::lookup(rule_id) else {
771            return;
772        };
773        let category = meta.category;
774
775        if !config.severity_overrides.is_empty() {
776            for v in &mut rule_violations {
777                if let Some(&sev) = config.severity_overrides.get(rule_id) {
778                    v.severity = sev;
779                }
780            }
781        }
782
783        *applied_by_category.entry(category).or_insert(0) += 1;
784        if rule_violations.is_empty() {
785            *passed_by_category.entry(category).or_insert(0) += 1;
786        }
787        violations_by_category
788            .entry(category)
789            .or_default()
790            .extend(rule_violations);
791    };
792
793    for (rule_id, action) in ordered_rules() {
794        if config.ignore_rules.contains(rule_id) {
795            continue;
796        }
797        let (meets_floor, confidence) = confidence_meets_floor(rule_id, config.min_confidence);
798        if !meets_floor {
799            skipped.push(SkippedRule {
800                rule_id: rule_id.to_string(),
801                reason: format!(
802                    "confidence {confidence} below min_confidence={}",
803                    config.min_confidence.as_str()
804                ),
805            });
806            continue;
807        }
808        let has_any_file = !document.tex_files.is_empty()
809            || !document.bib_files.is_empty()
810            || !document.rmd_files.is_empty();
811        match action {
812            RuleAction::Tex(entry) => {
813                // A format-restricted rule (formats={"tex","rnw"}) only
814                // "runs" (counts toward applied/passed) when a tex file
815                // is present. An unrestricted rule (formats=None, the
816                // common case) still runs — with zero tex files to
817                // check, hence zero violations — as long as the
818                // document has ANY file at all (even just a `.bib`),
819                // mirroring `doc.files_for_rule(rule)` yielding
820                // `doc.all_files()` unfiltered. Mirrors
821                // `core/engine.py::run`'s `rule.formats is not None and
822                // not (rule.formats & input_formats)` gate.
823                let should_run = if entry.restricted_to_tex {
824                    !document.tex_files.is_empty()
825                } else {
826                    has_any_file
827                };
828                if !should_run {
829                    // Mirrors `core/engine.py::run`'s `check_skipped_for_format`
830                    // branch: every currently format-restricted rule
831                    // (PRE-001..008, OPER-003) declares
832                    // `formats=frozenset({"tex", "rnw"})`, so the
833                    // "rule formats=" half of the message is a fixed
834                    // literal; `inputs=` is `sorted(_file_format(f) for f
835                    // in doc.all_files())`. `.Rnw` files live in
836                    // `tex_files` alongside real `.tex`/`.ltx`, but
837                    // `_file_format` tags them `"rnw"` by path suffix,
838                    // not `"tex"` — bucket by suffix here too so the
839                    // message stays accurate even though, in practice,
840                    // this branch is unreachable while any `tex_files`
841                    // entry (real `.tex` or `.rnw`) is present (both
842                    // satisfy `restricted_to_tex`'s `!tex_files.is_empty()`
843                    // check above).
844                    let mut input_formats: Vec<&str> = Vec::new();
845                    if document
846                        .tex_files
847                        .iter()
848                        .any(|tf| !tf.path.to_lowercase().ends_with(".rnw"))
849                    {
850                        input_formats.push("tex");
851                    }
852                    if document
853                        .tex_files
854                        .iter()
855                        .any(|tf| tf.path.to_lowercase().ends_with(".rnw"))
856                    {
857                        input_formats.push("rnw");
858                    }
859                    if !document.bib_files.is_empty() {
860                        input_formats.push("bib");
861                    }
862                    if !document.rmd_files.is_empty() {
863                        input_formats.push("rmd");
864                    }
865                    input_formats.sort_unstable();
866                    skipped.push(SkippedRule {
867                        rule_id: rule_id.to_string(),
868                        reason: format!(
869                            "format mismatch (rule formats={}; inputs={})",
870                            python_list_repr(&["rnw", "tex"]),
871                            python_list_repr(&input_formats),
872                        ),
873                    });
874                    continue;
875                }
876                let mut rule_violations = Vec::new();
877                // Mirrors the Python rule module's own choice of
878                // `doc.all_tex_like()` vs `doc.tex_files` — see
879                // `scans_rmd_prose`'s doc comment.
880                if entry.scans_rmd_prose {
881                    for tf in document.all_tex_like_docs() {
882                        rule_violations.extend((entry.check)(
883                            &tf.path,
884                            &tf.parsed,
885                            config.code_width,
886                        ));
887                    }
888                } else {
889                    for tf in &document.tex_files {
890                        rule_violations.extend((entry.check)(
891                            &tf.path,
892                            &tf.parsed,
893                            config.code_width,
894                        ));
895                    }
896                }
897                run_one(rule_id, rule_violations);
898            }
899            RuleAction::Bib(entry) => {
900                if !has_any_file {
901                    continue;
902                }
903                let mut rule_violations = Vec::new();
904                for bf in &document.bib_files {
905                    rule_violations.extend((entry.check)(
906                        &bf.path,
907                        &bf.source_chars,
908                        &bf.library,
909                        &tex_like,
910                        &tex_file_line_indexes,
911                        config.doi_resolver.as_deref(),
912                    ));
913                }
914                run_one(rule_id, rule_violations);
915            }
916            RuleAction::TexGlobal(entry) => {
917                // All 5 `GLOBAL_TEX_RULES` entries are unrestricted-format
918                // rules (formats=None in Python terms), so `should_run`
919                // reduces to `has_any_file` exactly like an unrestricted
920                // `RuleAction::Tex` entry — none of them are among
921                // PRE-001..008/OPER-003's `{"tex","rnw"}` restriction.
922                if !has_any_file {
923                    continue;
924                }
925                let fragments: Vec<(&str, &ParsedTex)> = document
926                    .all_tex_like_docs()
927                    .map(|tf| (tf.path.as_str(), &tf.parsed))
928                    .collect();
929                let rule_violations = (entry.check)(&fragments);
930                run_one(rule_id, rule_violations);
931            }
932        }
933    }
934
935    // Mirrors `core/engine.py::run`'s dispatch for JSS-PROJECT-001/002.
936    // Subtly, these two rules count as "applied" (and, absent findings,
937    // "passed") on EVERY run, project or not: Python's `Rule.check` for
938    // both is a no-op lambda (not `None`), and the engine's `if
939    // rule.check is not None: ... check_ran = True` branch runs it
940    // unconditionally over the given `ParsedDocument` before ever
941    // looking at `rule.check_project` — so a bare multi-file document
942    // (explicit CLI args, `--no-resolve`, or any other subcommand that
943    // never resolves) still shows category "project" as 2 applied / 2
944    // passed / PASS, same as a resolved project with zero cycles/
945    // missing refs. Only actual cycle/missing violations (only
946    // possible via `run_with_project`) can turn it FAIL. `--ignore-rules`
947    // applies (spec 013 data-model.md §13: no sentinel exception); the
948    // confidence-floor gate is skipped since both rules are always
949    // "high" (the catalogue default, no narrowed tier) and every valid
950    // `ConfidenceTier` is `<= High`, so the gate can never actually
951    // filter them — mirrors why Python's identical per-rule gate is a
952    // no-op for these two ids in practice.
953    let has_any_file = !document.tex_files.is_empty()
954        || !document.bib_files.is_empty()
955        || !document.rmd_files.is_empty();
956    if has_any_file {
957        let (cycles, missing) = project_extra.unwrap_or_default();
958        if !config.ignore_rules.contains("JSS-PROJECT-001") {
959            run_one("JSS-PROJECT-001", cycles);
960        }
961        if !config.ignore_rules.contains("JSS-PROJECT-002") {
962            run_one("JSS-PROJECT-002", missing);
963        }
964    }
965
966    let mut summaries: Vec<CategorySummary> = Vec::new();
967    for &category_id in catalogue::categories() {
968        let applied = applied_by_category.get(category_id).copied().unwrap_or(0);
969        let passed = passed_by_category.get(category_id).copied().unwrap_or(0);
970        let violations = violations_by_category
971            .remove(category_id)
972            .unwrap_or_default();
973        summaries.push(CategorySummary::build(
974            category_id.to_string(),
975            category_title(category_id).to_string(),
976            applied,
977            passed,
978            violations,
979        ));
980    }
981
982    // Synthetic "parse" category — mirrors `core/engine.py::run` lines
983    // ~317-334: collects every `JSS-PARSE-000` finding recorded during
984    // parsing — `.Rmd`'s tokenizer (unterminated frontmatter/fence,
985    // malformed YAML frontmatter) plus, on every file type, the CLI's
986    // lenient-UTF-8-decode warning/unreadable-file error attached via
987    // `attach_violation`. Appended last, only when non-empty; excluded
988    // from `compliance_percentage` below exactly like Python's
989    // `s.category_id != _PARSE_CATEGORY_ID` guard.
990    let parse_errors: Vec<Violation> = document
991        .tex_files
992        .iter()
993        .flat_map(|t| t.violations.iter().cloned())
994        .chain(
995            document
996                .bib_files
997                .iter()
998                .flat_map(|b| b.violations.iter().cloned()),
999        )
1000        .chain(
1001            document
1002                .rmd_files
1003                .iter()
1004                .flat_map(|r| r.violations.iter().cloned()),
1005        )
1006        .collect();
1007    if !parse_errors.is_empty() {
1008        summaries.push(CategorySummary {
1009            category_id: "parse".to_string(),
1010            title: "Parse errors".to_string(),
1011            status: crate::report::CategoryStatus::Fail,
1012            rules_applied: 0,
1013            rules_passed: 0,
1014            violations: parse_errors,
1015        });
1016    }
1017
1018    let ratable: Vec<&CategorySummary> = summaries
1019        .iter()
1020        .filter(|s| s.status != crate::report::CategoryStatus::Skipped && s.category_id != "parse")
1021        .collect();
1022    let compliance_percentage = if ratable.is_empty() {
1023        None
1024    } else {
1025        let passed = ratable
1026            .iter()
1027            .filter(|s| s.status == crate::report::CategoryStatus::Pass)
1028            .count();
1029        Some(python_round1(100.0 * passed as f64 / ratable.len() as f64))
1030    };
1031
1032    let mut all_violations: Vec<Violation> = summaries
1033        .iter()
1034        .flat_map(|s| s.violations.iter().cloned())
1035        .collect();
1036    crate::report::sort_violations(&mut all_violations);
1037
1038    ComplianceReport {
1039        tool_version: env!("CARGO_PKG_VERSION").to_string(),
1040        journal_id: config.journal.clone(),
1041        violations: all_violations,
1042        categories: summaries,
1043        compliance_percentage,
1044        skipped_rules: skipped,
1045    }
1046}