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}