Skip to main content

jsslint_core/
report.rs

1//! Domain model — mirrors `texlint.api` (see `/workspace/src/texlint/api.py`).
2//!
3//! Deliberate deviation from the Python source: `Violation::file` is a
4//! plain `String` (already POSIX-formatted at construction), not a path
5//! type. The Python dataclass carries a `pathlib.Path` because it also
6//! does filesystem I/O; this domain model doesn't (and one binding
7//! target, WASM-in-browser, has no OS filesystem at all), so treating
8//! the file as an opaque identifier string is the more portable choice.
9//! `output::json` still renders it under the `"file"` key exactly as
10//! `output/json_output.py` does via `Path.as_posix()`.
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13pub enum Severity {
14    Error,
15    Warning,
16    Info,
17}
18
19impl Severity {
20    pub fn as_str(&self) -> &'static str {
21        match self {
22            Severity::Error => "error",
23            Severity::Warning => "warning",
24            Severity::Info => "info",
25        }
26    }
27
28    pub fn parse(s: &str) -> Option<Self> {
29        match s {
30            "error" => Some(Severity::Error),
31            "warning" => Some(Severity::Warning),
32            "info" => Some(Severity::Info),
33            _ => None,
34        }
35    }
36
37    /// Mirrors `cli.py::_SEVERITY_RANK = {"info": 0, "warning": 1,
38    /// "error": 2}` — used for the `--fail-on` exit-code threshold.
39    /// Deliberately not `derive(Ord)`: this enum's declaration order
40    /// (Error, Warning, Info) doesn't match severity rank order, so a
41    /// derived `Ord` would silently invert every threshold comparison.
42    pub fn rank(&self) -> u8 {
43        match self {
44            Severity::Info => 0,
45            Severity::Warning => 1,
46            Severity::Error => 2,
47        }
48    }
49}
50
51/// A single text-edit auto-fix payload (spec 008).
52///
53/// `start`/`end` are 0-based, half-open **Unicode codepoint offsets**,
54/// NOT byte offsets, despite `Fix`'s docstring in api.py claiming
55/// "byte offsets" — verified against actual behavior:
56/// `core/fixer.py` applies a fix via plain Python string slicing
57/// (`new[:fix.start] + fix.replacement + new[fix.end:]`), and Python
58/// `str` slicing is codepoint-indexed. `pylatexenc`'s own `pos`/`len`
59/// (which most `Fix` construction sites derive `start`/`end` from) are
60/// likewise Python-string-index-based, i.e. codepoints. The two are
61/// identical for ASCII-only spans (which is why this went unnoticed:
62/// author names and prose text are the only realistic source of
63/// non-ASCII content, and most auto-fixable rules don't touch them),
64/// but diverge on any span containing non-ASCII text. This crate's
65/// tokenizer positions codepoint-index throughout to match.
66#[derive(Debug, Clone)]
67pub struct Fix {
68    pub start: usize,
69    pub end: usize,
70    pub replacement: String,
71    pub description: String,
72    pub confidence: FixConfidence,
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub enum FixConfidence {
77    Safe,
78    Review,
79}
80
81impl FixConfidence {
82    pub fn as_str(&self) -> &'static str {
83        match self {
84            FixConfidence::Safe => "safe",
85            FixConfidence::Review => "review",
86        }
87    }
88}
89
90#[derive(Debug, Clone)]
91pub struct Violation {
92    pub file: String,
93    /// 1-based.
94    pub line: u32,
95    /// 1-based; `None` when the rule operates at line granularity.
96    pub column: Option<u32>,
97    pub rule_id: String,
98    pub severity: Severity,
99    pub message: String,
100    pub suggestion: Option<String>,
101    pub fix: Option<Fix>,
102}
103
104impl Violation {
105    /// Mirrors `Violation.sort_key` in api.py:
106    /// `(file, line, column-bucket, column-value, rule_id)`, where
107    /// `column = None` sorts before any integer (bucket 0 vs 1).
108    pub fn sort_key(&self) -> (&str, u32, u8, u32, &str) {
109        let (bucket, col) = match self.column {
110            Some(c) => (1u8, c),
111            None => (0u8, 0),
112        };
113        (
114            self.file.as_str(),
115            self.line,
116            bucket,
117            col,
118            self.rule_id.as_str(),
119        )
120    }
121}
122
123/// Sorts violations by `Violation::sort_key`, matching the Python
124/// engine's canonical ordering (spec 001 json-output.md "Determinism").
125pub fn sort_violations(violations: &mut [Violation]) {
126    violations.sort_by(|a, b| a.sort_key().cmp(&b.sort_key()));
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub enum CategoryStatus {
131    Pass,
132    Fail,
133    Skipped,
134}
135
136impl CategoryStatus {
137    pub fn as_str(&self) -> &'static str {
138        match self {
139            CategoryStatus::Pass => "PASS",
140            CategoryStatus::Fail => "FAIL",
141            CategoryStatus::Skipped => "SKIPPED",
142        }
143    }
144}
145
146#[derive(Debug, Clone)]
147pub struct CategorySummary {
148    pub category_id: String,
149    pub title: String,
150    pub status: CategoryStatus,
151    pub rules_applied: u32,
152    pub rules_passed: u32,
153    /// Not serialized to JSON — mirrors api.py: violations live only in
154    /// the top-level `violations` array (json-output.md, "Violations
155    /// are not duplicated inside each category in JSON").
156    pub violations: Vec<Violation>,
157}
158
159impl CategorySummary {
160    /// Mirrors `CategorySummary.build` in api.py.
161    pub fn build(
162        category_id: impl Into<String>,
163        title: impl Into<String>,
164        rules_applied: u32,
165        rules_passed: u32,
166        violations: Vec<Violation>,
167    ) -> Self {
168        let status = if rules_applied == 0 {
169            CategoryStatus::Skipped
170        } else if !violations.is_empty() {
171            CategoryStatus::Fail
172        } else {
173            CategoryStatus::Pass
174        };
175        Self {
176            category_id: category_id.into(),
177            title: title.into(),
178            status,
179            rules_applied,
180            rules_passed,
181            violations,
182        }
183    }
184}
185
186/// A rule that did NOT run because its `formats` filter excluded every
187/// input format present. Mirrors `SkippedRule` in api.py.
188#[derive(Debug, Clone)]
189pub struct SkippedRule {
190    pub rule_id: String,
191    pub reason: String,
192}
193
194#[derive(Debug, Clone)]
195pub struct ComplianceReport {
196    pub tool_version: String,
197    pub journal_id: String,
198    pub violations: Vec<Violation>,
199    pub categories: Vec<CategorySummary>,
200    pub compliance_percentage: Option<f64>,
201    pub skipped_rules: Vec<SkippedRule>,
202}