1use 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 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 pub violations: Vec<Violation>,
59}
60
61pub struct ParsedBibFileDoc {
62 pub path: String,
63 pub source_chars: Vec<char>,
64 pub library: Library,
65 pub violations: Vec<Violation>,
67}
68
69pub 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 pub fn from_sources(files: &[(String, String)]) -> Result<Self, EngineError> {
95 let mut doc = ParsedDocument::default();
96 for (path, source) in files {
97 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 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 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>;
201type BibCheckFn = fn(
206 &str,
207 &[char],
208 &Library,
209 &[&[TexNode]],
210 &[(&str, &LineIndex)],
211 Option<&DoiResolver>,
212) -> Vec<Violation>;
213type TexGlobalCheckFn = fn(&[(&str, &ParsedTex)]) -> Vec<Violation>;
220
221struct TexRuleEntry {
222 id: &'static str,
223 check: TexCheckFn,
224 restricted_to_tex: bool,
232 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
254mod 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 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
605pub(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
614fn python_round1(x: f64) -> f64 {
625 format!("{x:.1}").parse().unwrap_or(x)
626}
627
628fn 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
663fn 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
678fn 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
715pub fn run(config: &ToolConfig, document: &ParsedDocument) -> ComplianceReport {
731 run_impl(config, document, None)
732}
733
734pub 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 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 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 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 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 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 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}