Skip to main content

jsslint_core/
diff.rs

1//! `jss-lint diff` engine + renderers โ€” mirrors `texlint/diff.py`
2//! (spec 016). Pure comparison over the spec-001 `--output json` shape;
3//! violations are kept as generic `serde_json::Value` objects (not
4//! this crate's typed `Violation`) since a diff input can be any
5//! spec-001-shaped JSON file, including ones this binary didn't
6//! produce, and every original field must round-trip into
7//! `render_json`'s output untouched.
8
9use crate::engine::python_list_repr;
10use serde_json::{Map, Value};
11use std::collections::{HashMap, HashSet};
12
13const SPEC_001_REQUIRED_KEYS: &[&str] = &["tool_version", "journal_id", "violations"];
14const PER_VIOLATION_REQUIRED: &[&str] = &["rule_id", "file", "line", "message"];
15
16/// Mirrors `diff.py::SchemaMismatch`.
17#[derive(Debug, Clone)]
18pub struct SchemaMismatch(pub String);
19
20impl std::fmt::Display for SchemaMismatch {
21    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22        write!(f, "{}", self.0)
23    }
24}
25
26fn json_type_name(v: &Value) -> &'static str {
27    match v {
28        Value::Null => "NoneType",
29        Value::Bool(_) => "bool",
30        Value::Number(n) => {
31            if n.is_f64() {
32                "float"
33            } else {
34                "int"
35            }
36        }
37        Value::String(_) => "str",
38        Value::Array(_) => "list",
39        Value::Object(_) => "dict",
40    }
41}
42
43/// Validates a parsed JSON payload against the spec-001 shape and
44/// returns its `violations` array. `source` is a short human-readable
45/// identifier (typically a file path) included in the error message.
46/// Mirrors `diff.py::validate_payload`.
47pub fn validate_payload(payload: &Value, source: &str) -> Result<Vec<Value>, SchemaMismatch> {
48    let Value::Object(obj) = payload else {
49        return Err(SchemaMismatch(format!(
50            "{source}: top-level value is not a JSON object"
51        )));
52    };
53
54    let mut missing: Vec<&str> = SPEC_001_REQUIRED_KEYS
55        .iter()
56        .filter(|k| !obj.contains_key(**k))
57        .copied()
58        .collect();
59    if !missing.is_empty() {
60        missing.sort_unstable();
61        return Err(SchemaMismatch(format!(
62            "{source}: missing required key(s) {} โ€” not a spec-001 jss-lint JSON report",
63            python_list_repr(&missing)
64        )));
65    }
66
67    let violations_value = obj.get("violations").expect("checked above");
68    let Value::Array(items) = violations_value else {
69        return Err(SchemaMismatch(format!(
70            "{source}: 'violations' must be a list (got {})",
71            json_type_name(violations_value)
72        )));
73    };
74
75    for (i, v) in items.iter().enumerate() {
76        let Value::Object(vobj) = v else {
77            return Err(SchemaMismatch(format!(
78                "{source}: violations[{i}] is not an object"
79            )));
80        };
81        let mut v_missing: Vec<&str> = PER_VIOLATION_REQUIRED
82            .iter()
83            .filter(|k| !vobj.contains_key(**k))
84            .copied()
85            .collect();
86        if !v_missing.is_empty() {
87            v_missing.sort_unstable();
88            return Err(SchemaMismatch(format!(
89                "{source}: violations[{i}] missing key(s) {}",
90                python_list_repr(&v_missing)
91            )));
92        }
93    }
94
95    Ok(items.clone())
96}
97
98/// Mirrors `diff.py::DiffReport`.
99#[derive(Debug, Clone)]
100pub struct DiffReport {
101    pub fixed: Vec<Value>,
102    pub introduced: Vec<Value>,
103    pub unchanged: Vec<Value>,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, Hash)]
107struct IdentityKey {
108    rule_id: String,
109    file: String,
110    line: Option<i64>,
111    message: String,
112}
113
114fn str_field(obj: &Map<String, Value>, key: &str) -> String {
115    obj.get(key)
116        .and_then(Value::as_str)
117        .unwrap_or_default()
118        .to_string()
119}
120
121fn identity(obj: &Map<String, Value>, drop_line: bool) -> IdentityKey {
122    IdentityKey {
123        rule_id: str_field(obj, "rule_id"),
124        file: str_field(obj, "file"),
125        line: if drop_line {
126            None
127        } else {
128            obj.get("line").and_then(Value::as_i64)
129        },
130        message: str_field(obj, "message"),
131    }
132}
133
134fn apply_renames(violations: &[Value], renames: &HashMap<String, String>) -> Vec<Value> {
135    violations
136        .iter()
137        .map(|v| {
138            let Some(obj) = v.as_object() else {
139                return v.clone();
140            };
141            let rule_id = obj.get("rule_id").and_then(Value::as_str).unwrap_or("");
142            match renames.get(rule_id) {
143                Some(new_id) if new_id != rule_id => {
144                    let mut new_obj = obj.clone();
145                    new_obj.insert("rule_id".to_string(), Value::String(new_id.clone()));
146                    Value::Object(new_obj)
147                }
148                _ => v.clone(),
149            }
150        })
151        .collect()
152}
153
154fn sort_key(v: &Value) -> (String, i64, String) {
155    let Some(obj) = v.as_object() else {
156        return (String::new(), 0, String::new());
157    };
158    (
159        str_field(obj, "file"),
160        obj.get("line").and_then(Value::as_i64).unwrap_or(0),
161        str_field(obj, "rule_id"),
162    )
163}
164
165/// Compares two spec-001 violation lists. Mirrors `diff.py::compare`
166/// (spec 016 contract C-2). `rule_renames` isn't exposed by the
167/// `jss-lint diff` CLI surface today (Python's `diff_cmd` doesn't wire
168/// a `--rule-renames` flag either) โ€” kept as a parameter for API
169/// parity with the Python function.
170pub fn compare(
171    old: &[Value],
172    new: &[Value],
173    ignore_line_drift: bool,
174    rule_renames: Option<&HashMap<String, String>>,
175) -> DiffReport {
176    let empty = HashMap::new();
177    let renames = rule_renames.unwrap_or(&empty);
178    let old_norm = apply_renames(old, renames);
179
180    let mut old_index: HashMap<IdentityKey, Value> = HashMap::new();
181    for v in &old_norm {
182        if let Some(obj) = v.as_object() {
183            old_index.insert(identity(obj, ignore_line_drift), v.clone());
184        }
185    }
186    let mut new_index: HashMap<IdentityKey, Value> = HashMap::new();
187    for v in new {
188        if let Some(obj) = v.as_object() {
189            new_index.insert(identity(obj, ignore_line_drift), v.clone());
190        }
191    }
192
193    let old_keys: HashSet<&IdentityKey> = old_index.keys().collect();
194    let new_keys: HashSet<&IdentityKey> = new_index.keys().collect();
195
196    let mut fixed: Vec<Value> = old_keys
197        .difference(&new_keys)
198        .map(|k| old_index[*k].clone())
199        .collect();
200    fixed.sort_by_key(sort_key);
201
202    let mut introduced: Vec<Value> = new_keys
203        .difference(&old_keys)
204        .map(|k| new_index[*k].clone())
205        .collect();
206    introduced.sort_by_key(sort_key);
207
208    // `unchanged` uses NEW's values (diff.py's `research ยง7` note).
209    let mut unchanged: Vec<Value> = old_keys
210        .intersection(&new_keys)
211        .map(|k| new_index[*k].clone())
212        .collect();
213    unchanged.sort_by_key(sort_key);
214
215    DiffReport {
216        fixed,
217        introduced,
218        unchanged,
219    }
220}
221
222fn v_summary(v: &Value) -> String {
223    let Some(obj) = v.as_object() else {
224        return String::new();
225    };
226    let rule_id = str_field(obj, "rule_id");
227    let file = str_field(obj, "file");
228    let line = obj.get("line").and_then(Value::as_i64).unwrap_or(0);
229    let message = str_field(obj, "message");
230    format!("{rule_id} {file}:{line}: {message}")
231}
232
233/// ANSI-free plain-text rendering. Mirrors `diff.py::render_terminal`.
234pub fn render_terminal(diff: &DiffReport) -> String {
235    let mut lines = vec![format!(
236        "fixed: {} introduced: {} unchanged: {}",
237        diff.fixed.len(),
238        diff.introduced.len(),
239        diff.unchanged.len()
240    )];
241    for (label, group) in [
242        ("Fixed", &diff.fixed),
243        ("Introduced", &diff.introduced),
244        ("Unchanged", &diff.unchanged),
245    ] {
246        if group.is_empty() {
247            continue;
248        }
249        lines.push(String::new());
250        lines.push(format!("== {label} =="));
251        for v in group {
252            lines.push(format!("  {}", v_summary(v)));
253        }
254    }
255    lines.join("\n") + "\n"
256}
257
258/// GitHub-flavoured CommonMark. Mirrors `diff.py::render_markdown`.
259pub fn render_markdown(diff: &DiffReport) -> String {
260    let mut parts = vec![
261        format!(
262            "**fixed:** {} | **introduced:** {} | **unchanged:** {}",
263            diff.fixed.len(),
264            diff.introduced.len(),
265            diff.unchanged.len()
266        ),
267        String::new(),
268    ];
269    for (label, group) in [
270        ("Fixed", &diff.fixed),
271        ("Introduced", &diff.introduced),
272        ("Unchanged", &diff.unchanged),
273    ] {
274        parts.push(format!("## {label}"));
275        if group.is_empty() {
276            parts.push(String::new());
277            parts.push("(none)".to_string());
278            parts.push(String::new());
279            continue;
280        }
281        parts.push(String::new());
282        for v in group {
283            let Some(obj) = v.as_object() else { continue };
284            let rule_id = str_field(obj, "rule_id");
285            let file = str_field(obj, "file");
286            let line = obj.get("line").and_then(Value::as_i64).unwrap_or(0);
287            let message = str_field(obj, "message");
288            parts.push(format!("- `{rule_id}` {file}:{line}: {message}"));
289        }
290        parts.push(String::new());
291    }
292    parts.join("\n").trim_end().to_string() + "\n"
293}
294
295/// Deterministic JSON output. Mirrors `diff.py::render_json`, reusing
296/// `json_output::write_value` for the same `json.dumps`-compatible
297/// serialization.
298pub fn render_json(diff: &DiffReport) -> String {
299    let payload = serde_json::json!({
300        "summary": {
301            "fixed": diff.fixed.len(),
302            "introduced": diff.introduced.len(),
303            "unchanged": diff.unchanged.len(),
304        },
305        "fixed": diff.fixed,
306        "introduced": diff.introduced,
307        "unchanged": diff.unchanged,
308    });
309    let mut out = String::new();
310    crate::json_output::write_value(&payload, 0, &mut out);
311    out.push('\n');
312    out
313}