Skip to main content

jsslint_core/
conformance.rs

1//! One-page conformance report — mirrors `texlint/report.py` (spec
2//! 015). Markdown and HTML are ported here; PDF is CLI-only scope
3//! (see `jsslint-cli`'s `report_pdf` module) since it's a different
4//! document from Python's WeasyPrint-rendered PDF, not something
5//! `jsslint-core`'s WASM/PyO3 consumers need — see `rust/README.md`.
6
7use crate::catalogue;
8use crate::engine::ParsedDocument;
9use crate::html_output::escape_html;
10use crate::report::{ComplianceReport, Severity, Violation};
11use crate::tex::node::{Args, Node};
12use std::collections::{HashMap, HashSet};
13
14const TOOL_SIDE_CATEGORIES: &[&str] = &["parse", "internal"];
15const TITLE_MACROS: &[&str] = &["title", "Plaintitle"];
16const AUTHOR_MACROS: &[&str] = &["Plainauthor", "author"];
17
18#[derive(Debug, Clone)]
19pub struct TopFiveEntry {
20    pub rule_id: String,
21    pub count: usize,
22    pub example_file: String,
23    pub example_line: u32,
24    pub example_excerpt: String,
25}
26
27#[derive(Debug, Clone)]
28pub struct FixMeItem {
29    pub rule_id: String,
30    pub severity: Severity,
31    pub count: usize,
32}
33
34#[derive(Debug, Clone)]
35pub struct ConformanceSummary {
36    pub title: String,
37    pub author: String,
38    pub file_count: usize,
39    pub run_date: String,
40    pub score_percent: Option<i64>,
41    pub rules_passing: usize,
42    pub rules_total_active: usize,
43    pub error_count: usize,
44    pub warning_count: usize,
45    pub info_count: usize,
46    pub top_five: Vec<TopFiveEntry>,
47    pub fix_me_first: Vec<FixMeItem>,
48}
49
50fn active_rule_ids(ignore_rules: &HashSet<String>) -> HashSet<&'static str> {
51    catalogue::all_rules()
52        .iter()
53        .filter(|m| {
54            !ignore_rules.contains(m.rule_id) && !TOOL_SIDE_CATEGORIES.contains(&m.category)
55        })
56        .map(|m| m.rule_id)
57        .collect()
58}
59
60fn severity_rank(s: Severity) -> u8 {
61    match s {
62        Severity::Error => 0,
63        Severity::Warning => 1,
64        Severity::Info => 2,
65    }
66}
67
68/// Mirrors Python's `round()` on a non-negative float: round-half-
69/// to-even, not Rust `f64::round()`'s round-half-away-from-zero.
70/// Matters here: `total_active` is a small denominator (currently
71/// ~50 active rules), so an exact `.5` tie in the integer percentage
72/// is a realistic occurrence, not a hypothetical edge case.
73fn python_round_to_i64(x: f64) -> i64 {
74    let floor = x.floor();
75    let diff = x - floor;
76    let floor_i = floor as i64;
77    if diff < 0.5 {
78        floor_i
79    } else if diff > 0.5 {
80        floor_i + 1
81    } else if floor_i % 2 == 0 {
82        floor_i
83    } else {
84        floor_i + 1
85    }
86}
87
88/// Builds a `ConformanceSummary` from a lint report. Mirrors
89/// `report.py::_compute_summary`. `run_date` is supplied by the
90/// caller (`date.today().isoformat()`-equivalent) rather than computed
91/// here — a wall-clock read belongs at the binding/CLI layer, not in
92/// this crate's otherwise-pure logic.
93pub fn compute_summary(
94    report: &ComplianceReport,
95    title: &str,
96    author: &str,
97    file_count: usize,
98    run_date: &str,
99    ignore_rules: &HashSet<String>,
100) -> ConformanceSummary {
101    let active = active_rule_ids(ignore_rules);
102
103    let mut violating_active: HashSet<&str> = HashSet::new();
104    for v in &report.violations {
105        if active.contains(v.rule_id.as_str()) {
106            violating_active.insert(v.rule_id.as_str());
107        }
108    }
109    let total_active = active.len();
110    let passing = total_active - violating_active.len();
111    let score = if total_active > 0 {
112        Some(python_round_to_i64(
113            100.0 * passing as f64 / total_active as f64,
114        ))
115    } else {
116        None
117    };
118
119    let mut severity_counts: HashMap<Severity, usize> = HashMap::new();
120    for v in &report.violations {
121        *severity_counts.entry(v.severity).or_insert(0) += 1;
122    }
123
124    // Group by rule_id, preserving first-encounter order in
125    // report.violations (already canonically sorted).
126    let mut files: Vec<(&str, Vec<&Violation>)> = Vec::new();
127    let mut index_of: HashMap<&str, usize> = HashMap::new();
128    for v in &report.violations {
129        if let Some(&idx) = index_of.get(v.rule_id.as_str()) {
130            files[idx].1.push(v);
131        } else {
132            index_of.insert(v.rule_id.as_str(), files.len());
133            files.push((v.rule_id.as_str(), vec![v]));
134        }
135    }
136    files.sort_by(|a, b| b.1.len().cmp(&a.1.len()).then_with(|| a.0.cmp(b.0)));
137
138    let mut top_five: Vec<TopFiveEntry> = Vec::new();
139    for (rid, viols) in &files {
140        if !active.contains(rid) {
141            continue;
142        }
143        let first = viols[0];
144        let excerpt: String = first.message.chars().take(80).collect();
145        top_five.push(TopFiveEntry {
146            rule_id: rid.to_string(),
147            count: viols.len(),
148            example_file: first.file.clone(),
149            example_line: first.line,
150            example_excerpt: excerpt,
151        });
152        if top_five.len() == 5 {
153            break;
154        }
155    }
156
157    let mut by_rule_severity: HashMap<&str, Severity> = HashMap::new();
158    let mut by_rule_count: HashMap<&str, usize> = HashMap::new();
159    for v in &report.violations {
160        if !active.contains(v.rule_id.as_str()) {
161            continue;
162        }
163        by_rule_severity.insert(v.rule_id.as_str(), v.severity);
164        *by_rule_count.entry(v.rule_id.as_str()).or_insert(0) += 1;
165    }
166    let mut rule_ids: Vec<&str> = by_rule_count.keys().copied().collect();
167    rule_ids.sort_by(|a, b| {
168        severity_rank(by_rule_severity[a])
169            .cmp(&severity_rank(by_rule_severity[b]))
170            .then_with(|| a.cmp(b))
171    });
172    let fix_me_first: Vec<FixMeItem> = rule_ids
173        .iter()
174        .map(|rid| FixMeItem {
175            rule_id: rid.to_string(),
176            severity: by_rule_severity[rid],
177            count: by_rule_count[rid],
178        })
179        .collect();
180
181    ConformanceSummary {
182        title: title.to_string(),
183        author: author.to_string(),
184        file_count,
185        run_date: run_date.to_string(),
186        score_percent: score,
187        rules_passing: passing,
188        rules_total_active: total_active,
189        error_count: *severity_counts.get(&Severity::Error).unwrap_or(&0),
190        warning_count: *severity_counts.get(&Severity::Warning).unwrap_or(&0),
191        info_count: *severity_counts.get(&Severity::Info).unwrap_or(&0),
192        top_five,
193        fix_me_first,
194    }
195}
196
197/// Mirrors `report.py::_render_md`.
198pub fn render_md(summary: &ConformanceSummary) -> String {
199    let score = match summary.score_percent {
200        Some(p) => format!("{p} %"),
201        None => "n/a".to_string(),
202    };
203    let mut parts = vec![
204        format!("# JSS conformance report — {}", summary.title),
205        String::new(),
206        format!("- **Author:** {}", summary.author),
207        format!("- **Files:** {}", summary.file_count),
208        format!("- **Run date:** {}", summary.run_date),
209        String::new(),
210        format!("## Conformance score: {score}"),
211        format!(
212            "({} of {} rules pass)",
213            summary.rules_passing, summary.rules_total_active
214        ),
215        String::new(),
216        "## Severity counts".to_string(),
217        format!("- Errors: {}", summary.error_count),
218        format!("- Warnings: {}", summary.warning_count),
219        format!("- Info: {}", summary.info_count),
220        String::new(),
221        "## Top 5 most-violated rules".to_string(),
222    ];
223    if summary.top_five.is_empty() {
224        parts.push("- (none)".to_string());
225    } else {
226        for e in &summary.top_five {
227            parts.push(format!(
228                "- `{}` — {} violation(s); {}:{}: {}",
229                e.rule_id, e.count, e.example_file, e.example_line, e.example_excerpt
230            ));
231        }
232    }
233    parts.push(String::new());
234    parts.push("## Fix me first".to_string());
235    if summary.fix_me_first.is_empty() {
236        parts.push("1. (no violations)".to_string());
237    } else {
238        for (i, item) in summary.fix_me_first.iter().enumerate() {
239            parts.push(format!(
240                "{}. `{}` ({}) — {}",
241                i + 1,
242                item.rule_id,
243                item.severity.as_str(),
244                item.count
245            ));
246        }
247    }
248    parts.push(String::new());
249    parts.push("---".to_string());
250    parts.push("Generated by jss-lint.".to_string());
251    parts.join("\n") + "\n"
252}
253
254const CONFORMANCE_STYLE: &str = "  body { font-family: -apple-system, Segoe UI, Roboto, sans-serif; margin: 2rem; color: #222; max-width: 48rem; }\n  h1 { margin-top: 0; }\n  h2 { border-bottom: 1px solid #ccc; padding-bottom: 0.25rem; margin-top: 2rem; }\n  ul.meta { list-style: none; padding-left: 0; }\n  ul.meta li { margin: 0.15rem 0; }\n  .score { font-size: 1.6rem; font-weight: 600; }\n  .score.none { color: #777; font-weight: 400; font-style: italic; }\n  table { border-collapse: collapse; width: 100%; margin-top: 0.5rem; }\n  th, td { border: 1px solid #ddd; padding: 0.4rem 0.6rem; text-align: left; vertical-align: top; font-size: 0.95rem; }\n  th { background: #f5f5f5; }\n  td.num { text-align: right; font-variant-numeric: tabular-nums; }\n  code, .rid { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }\n  .sev-error { color: #b00020; font-weight: 600; }\n  .sev-warning { color: #b8860b; font-weight: 600; }\n  .sev-info { color: #555; font-weight: 600; }\n  .none { color: #888; font-style: italic; }\n  footer { margin-top: 2rem; padding-top: 0.5rem; border-top: 1px solid #ccc; color: #555; font-size: 0.9rem; }\n";
255
256/// Mirrors `report.py::_render_html` / `conformance.html.j2`.
257/// Hand-translated the same way `html_output.rs` translates
258/// `author.html.j2`/`reviewer.html.j2`: the source template has no
259/// `trim_blocks`/`lstrip_blocks`, so the whitespace surrounding every
260/// `{% %}` tag lands in the output verbatim. The exact shapes below
261/// were derived by rendering the real Jinja2 template (both branches
262/// of every `{% if %}`, empty and non-empty loops) and diffing against
263/// this string, not by re-deriving Jinja2's whitespace rules from
264/// scratch — trust the literal layout over intuition when touching it.
265pub fn render_html(summary: &ConformanceSummary) -> String {
266    let title = escape_html(&summary.title);
267    let mut out = String::new();
268    out.push_str(
269        "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>JSS conformance report \u{2014} ",
270    );
271    out.push_str(&title);
272    out.push_str("</title>\n<style>\n");
273    out.push_str(CONFORMANCE_STYLE);
274    out.push_str("</style>\n</head>\n<body>\n<h1>JSS conformance report \u{2014} ");
275    out.push_str(&title);
276    out.push_str("</h1>\n<ul class=\"meta\">\n  <li><strong>Author:</strong> ");
277    out.push_str(&escape_html(&summary.author));
278    out.push_str("</li>\n  <li><strong>Files:</strong> ");
279    out.push_str(&summary.file_count.to_string());
280    out.push_str("</li>\n  <li><strong>Run date:</strong> ");
281    out.push_str(&escape_html(&summary.run_date));
282    out.push_str("</li>\n</ul>\n\n<h2>Conformance score:\n  \n");
283    match summary.score_percent {
284        None => out.push_str("    <span class=\"score none\">n/a</span>\n"),
285        Some(p) => out.push_str(&format!("    <span class=\"score\">{p} %</span>\n")),
286    }
287    out.push_str("  \n</h2>\n<p>(");
288    out.push_str(&summary.rules_passing.to_string());
289    out.push_str(" of ");
290    out.push_str(&summary.rules_total_active.to_string());
291    out.push_str(" rules pass)</p>\n\n<h2>Severity counts</h2>\n<ul>\n  <li>Errors: ");
292    out.push_str(&summary.error_count.to_string());
293    out.push_str("</li>\n  <li>Warnings: ");
294    out.push_str(&summary.warning_count.to_string());
295    out.push_str("</li>\n  <li>Info: ");
296    out.push_str(&summary.info_count.to_string());
297    out.push_str("</li>\n</ul>\n\n<h2>Top 5 most-violated rules</h2>\n\n");
298
299    if summary.top_five.is_empty() {
300        out.push_str("<p class=\"none\">(none)</p>\n");
301    } else {
302        out.push_str("<table>\n  <thead>\n    <tr><th>Rule</th><th class=\"num\">Count</th><th>Example</th></tr>\n  </thead>\n  <tbody>\n  ");
303        for e in &summary.top_five {
304            out.push_str("\n    <tr>\n      <td><code class=\"rid\">");
305            out.push_str(&escape_html(&e.rule_id));
306            out.push_str("</code></td>\n      <td class=\"num\">");
307            out.push_str(&e.count.to_string());
308            out.push_str("</td>\n      <td><code>");
309            out.push_str(&escape_html(&e.example_file));
310            out.push(':');
311            out.push_str(&e.example_line.to_string());
312            out.push_str("</code> \u{2014} ");
313            out.push_str(&escape_html(&e.example_excerpt));
314            out.push_str("</td>\n    </tr>\n  ");
315        }
316        out.push_str("\n  </tbody>\n</table>\n");
317    }
318    out.push_str("\n\n<h2>Fix me first</h2>\n\n");
319
320    if summary.fix_me_first.is_empty() {
321        out.push_str("<ol>\n  <li class=\"none\">(no violations)</li>\n</ol>\n");
322    } else {
323        out.push_str("<ol>\n  ");
324        for item in &summary.fix_me_first {
325            out.push_str("\n    <li><code class=\"rid\">");
326            out.push_str(&escape_html(&item.rule_id));
327            out.push_str("</code>\n      (<span class=\"sev-");
328            out.push_str(item.severity.as_str());
329            out.push_str("\">");
330            out.push_str(item.severity.as_str());
331            out.push_str("</span>)\n      \u{2014} ");
332            out.push_str(&item.count.to_string());
333            out.push_str("</li>\n  ");
334        }
335        out.push_str("\n</ol>\n");
336    }
337    out.push_str("\n\n<footer>Generated by jss-lint.</footer>\n</body>\n</html>\n");
338    out
339}
340
341// ---------------------------------------------------------------------
342// Manuscript metadata extraction (spec 015 follow-up)
343// ---------------------------------------------------------------------
344
345/// Recursively collects literal text from a node tree. Mirrors
346/// `report.py::_node_plain_text`, which walks pylatexenc's generic
347/// `chars`/`nodelist`/`nodeargd` attributes; this port's typed `Node`
348/// enum has the same shape split across variants (`Chars.chars`,
349/// `Group`/`Environment`/`Math.nodelist`, `Macro`/`Environment.args`).
350fn node_plain_text(node: &Node) -> String {
351    match node {
352        Node::Chars(n) => n.chars.clone(),
353        Node::Macro(n) => n.args.iter().flatten().map(node_plain_text).collect(),
354        Node::Group(n) => n.nodelist.iter().map(node_plain_text).collect(),
355        Node::Environment(n) => {
356            let mut s: String = n.nodelist.iter().map(node_plain_text).collect();
357            s.extend(n.args.iter().flatten().map(node_plain_text));
358            s
359        }
360        Node::Math(n) => n.nodelist.iter().map(node_plain_text).collect(),
361        Node::Comment(_) | Node::Specials(_) => String::new(),
362    }
363}
364
365fn args_nodelist(args: &Args) -> impl Iterator<Item = &Node> {
366    args.iter().flatten()
367}
368
369/// Walks `items` depth-first, recording the first non-empty
370/// brace-arg text for each macro name in `macronames` encountered.
371/// Mirrors `report.py::_first_macro_arg_text`'s inner `walk`.
372fn walk_for_macro_text<'a>(
373    items: &'a [Node],
374    macronames: &[&str],
375    found: &mut HashMap<&'a str, String>,
376) {
377    for n in items {
378        if let Node::Macro(m) = n {
379            let name: &'a str = m.macroname.as_str();
380            if macronames.contains(&name) && !found.contains_key(name) {
381                for arg in args_nodelist(&m.args) {
382                    let text = node_plain_text(arg).trim().to_string();
383                    if !text.is_empty() {
384                        found.insert(name, text);
385                        break;
386                    }
387                }
388            }
389        }
390        match n {
391            Node::Group(g) => walk_for_macro_text(&g.nodelist, macronames, found),
392            Node::Environment(e) => walk_for_macro_text(&e.nodelist, macronames, found),
393            Node::Math(m) => walk_for_macro_text(&m.nodelist, macronames, found),
394            _ => {}
395        }
396        let args: Option<&Args> = match n {
397            Node::Macro(m) => Some(&m.args),
398            Node::Environment(e) => Some(&e.args),
399            _ => None,
400        };
401        if let Some(args) = args {
402            for arg in args_nodelist(args) {
403                match arg {
404                    Node::Group(g) => walk_for_macro_text(&g.nodelist, macronames, found),
405                    Node::Environment(e) => walk_for_macro_text(&e.nodelist, macronames, found),
406                    Node::Math(m) => walk_for_macro_text(&m.nodelist, macronames, found),
407                    _ => {}
408                }
409            }
410        }
411    }
412}
413
414fn first_macro_arg_text(nodes: &[Node], macronames: &[&str]) -> Option<String> {
415    let mut found: HashMap<&str, String> = HashMap::new();
416    walk_for_macro_text(nodes, macronames, &mut found);
417    macronames.iter().find_map(|name| found.get(name).cloned())
418}
419
420/// Returns `(title, author)` extracted from the parsed document's
421/// preamble. Mirrors `report.py::extract_metadata`, which iterates
422/// `document.all_tex_like()` — every `.tex`/`.rnw` file plus every
423/// `.Rmd` file's raw-LaTeX prose fragments.
424pub fn extract_metadata(document: &ParsedDocument) -> (Option<String>, Option<String>) {
425    let mut title: Option<String> = None;
426    let mut author: Option<String> = None;
427    for tex_file in document.all_tex_like_docs() {
428        let nodes = &tex_file.parsed.nodes;
429        if title.is_none() {
430            title = first_macro_arg_text(nodes, TITLE_MACROS);
431        }
432        if author.is_none() {
433            author = first_macro_arg_text(nodes, AUTHOR_MACROS);
434        }
435        if title.is_some() && author.is_some() {
436            break;
437        }
438    }
439    (title, author)
440}