1use 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
68fn 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
88pub 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 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
197pub 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
256pub 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
341fn 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
369fn 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
420pub 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}