Skip to main content

jsslint_core/
explain.rs

1//! Rule explanation renderer — mirrors `texlint/explain.py` (spec 009).
2//!
3//! `_guide_index.py`'s runtime `guide_section` → `guide_url` backfill
4//! isn't ported: every current `catalogue.yaml` entry that sets
5//! `guide_section` also sets `guide_url` directly (verified: zero
6//! entries need the backfill), and this port's `RuleMeta.guide_url` is
7//! already fully populated from `catalogue.yaml` by `build.rs`.
8
9use crate::catalogue::{self, RuleMeta};
10
11/// Mirrors `explain.py::did_you_mean` (`difflib.get_close_matches`,
12/// `n=5, cutoff=0.6`, candidates = every catalogue rule id).
13pub fn did_you_mean(unknown_id: &str) -> Vec<String> {
14    let candidates: Vec<&str> = catalogue::all_rules().iter().map(|r| r.rule_id).collect();
15    close_matches(unknown_id, &candidates, 5, 0.6)
16}
17
18/// Mirrors `difflib.get_close_matches`: ASCII-safe (rule ids and their
19/// typo'd lookalikes are always ASCII, so byte-sequence `ratio()` is
20/// equivalent to Python's char-sequence `ratio()`). Not reusing the
21/// `difflib` crate's own `get_close_matches` — it sorts ties by ratio
22/// only (stable on iteration order), whereas Python's
23/// `heapq.nlargest(n, result)` on `(ratio, x)` tuples breaks ties by
24/// comparing `x` too (descending), which changes suggestion order
25/// whenever two candidates tie on ratio.
26fn close_matches(word: &str, candidates: &[&str], n: usize, cutoff: f32) -> Vec<String> {
27    let mut scored: Vec<(f32, &str)> = candidates
28        .iter()
29        .filter_map(|&cand| {
30            let mut matcher = difflib::sequencematcher::SequenceMatcher::new(cand, word);
31            let ratio = matcher.ratio();
32            if ratio >= cutoff {
33                Some((ratio, cand))
34            } else {
35                None
36            }
37        })
38        .collect();
39    scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap().then_with(|| b.1.cmp(a.1)));
40    scored.truncate(n);
41    scored.into_iter().map(|(_, s)| s.to_string()).collect()
42}
43
44fn render_one_terminal(rule_id: &str, meta: &RuleMeta) -> String {
45    let mut lines = vec![
46        format!("{rule_id} ({})", meta.severity.as_str().to_uppercase()),
47        format!("  Category: {}", meta.category),
48        format!("  Authority: {} ({})", meta.authority, meta.authority_ref),
49    ];
50    if meta.confidence != "high" {
51        lines.push(format!(
52            "  Confidence: {} (measured corpus precision below the gate; see eval/improvement-log.md)",
53            meta.confidence
54        ));
55    }
56    if !meta.guide_section.is_empty() {
57        lines.push(format!("  JSS guide: {}", meta.guide_section));
58        if let Some(url) = meta.guide_url {
59            lines.push(format!("  See: {url}"));
60        }
61    }
62    lines.push(String::new());
63    let body = if !meta.explanation.is_empty() {
64        meta.explanation
65    } else {
66        meta.message_template
67    };
68    lines.push(body.to_string());
69    lines.join("\n") + "\n"
70}
71
72fn render_one_markdown(rule_id: &str, meta: &RuleMeta) -> String {
73    let mut parts = vec![format!("# {rule_id}"), String::new()];
74    parts.push(format!("- **Severity:** {}", meta.severity.as_str()));
75    parts.push(format!("- **Category:** {}", meta.category));
76    parts.push(format!(
77        "- **Authority:** {} ({})",
78        meta.authority, meta.authority_ref
79    ));
80    if meta.confidence != "high" {
81        parts.push(format!("- **Confidence:** {}", meta.confidence));
82    }
83    if !meta.guide_section.is_empty() {
84        if let Some(url) = meta.guide_url {
85            parts.push(format!("- **JSS guide:** [{}]({url})", meta.guide_section));
86        } else {
87            parts.push(format!("- **JSS guide:** {}", meta.guide_section));
88        }
89    }
90    parts.push(String::new());
91    let body = if !meta.explanation.is_empty() {
92        meta.explanation
93    } else {
94        meta.message_template
95    };
96    parts.push(body.to_string());
97    parts.push(String::new());
98    parts.join("\n")
99}
100
101fn render_listing(fmt: &str) -> String {
102    use std::collections::BTreeMap;
103    let mut by_category: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
104    for meta in catalogue::all_rules() {
105        by_category
106            .entry(meta.category)
107            .or_default()
108            .push(meta.rule_id);
109    }
110    for ids in by_category.values_mut() {
111        ids.sort_unstable();
112    }
113
114    let mut out: Vec<String> = Vec::new();
115    if fmt == "markdown" {
116        out.push("# JSS-lint rule catalogue\n".to_string());
117    } else {
118        out.push("JSS-lint rule catalogue:\n".to_string());
119    }
120
121    for (category, rule_ids) in &by_category {
122        if fmt == "markdown" {
123            out.push(format!("## {category}\n"));
124            for rid in rule_ids {
125                let meta = catalogue::lookup(rid).expect("catalogue rule");
126                out.push(format!("- `{rid}` — {}", meta.message_template));
127            }
128            out.push(String::new());
129        } else {
130            out.push(format!("  {category}:"));
131            for rid in rule_ids {
132                let meta = catalogue::lookup(rid).expect("catalogue rule");
133                out.push(format!("    {rid}  {}", meta.message_template));
134            }
135            out.push(String::new());
136        }
137    }
138
139    let joined = out.join("\n");
140    if fmt == "markdown" {
141        joined + "\n"
142    } else {
143        joined
144    }
145}
146
147/// Renders a rule explanation, or a per-category listing when
148/// `rule_id` is `None`. `Err(normalized_id)` for an unknown rule id —
149/// mirrors `explain.py::render`'s `raise KeyError(rule_id)`, with the
150/// caller (CLI dispatch) producing a "did you mean?" suggestion list.
151pub fn render(rule_id: Option<&str>, fmt: &str) -> Result<String, String> {
152    let Some(raw_id) = rule_id else {
153        return Ok(render_listing(fmt));
154    };
155    let normalized = raw_id.trim().to_uppercase();
156    let Some(meta) = catalogue::lookup(&normalized) else {
157        return Err(normalized);
158    };
159    if fmt == "markdown" {
160        Ok(render_one_markdown(&normalized, meta))
161    } else {
162        Ok(render_one_terminal(&normalized, meta))
163    }
164}