Skip to main content

jsslint_core/
json_output.rs

1//! Deterministic JSON renderer — mirrors
2//! `/workspace/src/texlint/output/json_output.py` byte-for-byte,
3//! including two of its quirks (both intentional to preserve parity,
4//! not oversights):
5//!
6//! 1. `fix` is hardcoded to `null`. The Python renderer's docstring
7//!    still says "reserved for Step 4" even though spec 008 (auto-fix)
8//!    shipped — the JSON contract was never updated to emit real `Fix`
9//!    payloads. If that Python gap is ever closed, this must change in
10//!    lockstep (the parity harness in `tests/parity.rs` will catch a
11//!    drift either way).
12//! 2. Python's `json.dumps(..., indent=2, sort_keys=True)` uses the
13//!    default `ensure_ascii=True`, which escapes every non-ASCII
14//!    codepoint to `\uXXXX` — e.g. every `guide_section` value contains
15//!    "§", so this fires on essentially every real violation, not just
16//!    edge-case input. `serde_json`'s built-in pretty-printer does NOT
17//!    do this, so we can't use it directly; `write_value` below is a
18//!    small hand-rolled formatter that replicates CPython's C-accelerated
19//!    JSON encoder output exactly.
20
21use crate::catalogue;
22use crate::report::{CategorySummary, ComplianceReport, Violation};
23use serde_json::{json, Map, Value};
24
25/// Builds the same payload shape as `json_output.py::to_payload`.
26pub fn to_payload(report: &ComplianceReport) -> Value {
27    let mut sorted = report.violations.clone();
28    crate::report::sort_violations(&mut sorted);
29
30    let violations: Vec<Value> = sorted.iter().map(violation_value).collect();
31    let categories: Vec<Value> = report.categories.iter().map(category_value).collect();
32    let skipped_rules: Vec<Value> = report
33        .skipped_rules
34        .iter()
35        .map(|s| json!({"rule_id": s.rule_id, "reason": s.reason}))
36        .collect();
37
38    json!({
39        "tool_version": report.tool_version,
40        "journal_id": report.journal_id,
41        "compliance_percentage": report.compliance_percentage,
42        "categories": categories,
43        "violations": violations,
44        "skipped_rules": skipped_rules,
45    })
46}
47
48fn violation_value(v: &Violation) -> Value {
49    let meta = catalogue::lookup(&v.rule_id);
50    let guide_section = meta.map(|m| m.guide_section).unwrap_or("");
51    let guide_url = meta.and_then(|m| m.guide_url);
52    let confidence = meta.map(|m| m.confidence).unwrap_or("high");
53
54    json!({
55        "file": v.file,
56        "line": v.line,
57        "column": v.column,
58        "rule_id": v.rule_id,
59        "severity": v.severity.as_str(),
60        "message": v.message,
61        "suggestion": v.suggestion,
62        "fix": Value::Null,
63        "guide_section": guide_section,
64        "guide_url": guide_url,
65        "confidence": confidence,
66    })
67}
68
69fn category_value(c: &CategorySummary) -> Value {
70    json!({
71        "category_id": c.category_id,
72        "title": c.title,
73        "status": c.status.as_str(),
74        "rules_applied": c.rules_applied,
75        "rules_passed": c.rules_passed,
76    })
77}
78
79/// Render a `ComplianceReport` exactly as `jss-lint --output json` does.
80pub fn render(report: &ComplianceReport) -> String {
81    let payload = to_payload(report);
82    let mut out = String::new();
83    write_value(&payload, 0, &mut out);
84    out.push('\n');
85    out
86}
87
88/// Exposed crate-wide so `sarif.rs` can reuse the same
89/// Python-`json.dumps(indent=2, sort_keys=True)`-compatible writer
90/// (including its `ensure_ascii=True` string escaping) rather than
91/// duplicating it.
92pub(crate) fn write_value(value: &Value, indent: usize, out: &mut String) {
93    match value {
94        Value::Null => out.push_str("null"),
95        Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
96        Value::Number(n) => out.push_str(&n.to_string()),
97        Value::String(s) => write_py_string(s, out),
98        Value::Array(items) => write_array(items, indent, out),
99        Value::Object(map) => write_object(map, indent, out),
100    }
101}
102
103fn write_array(items: &[Value], indent: usize, out: &mut String) {
104    if items.is_empty() {
105        out.push_str("[]");
106        return;
107    }
108    out.push('[');
109    let inner_indent = indent + 2;
110    for (i, item) in items.iter().enumerate() {
111        out.push('\n');
112        out.push_str(&" ".repeat(inner_indent));
113        write_value(item, inner_indent, out);
114        if i + 1 < items.len() {
115            out.push(',');
116        }
117    }
118    out.push('\n');
119    out.push_str(&" ".repeat(indent));
120    out.push(']');
121}
122
123fn write_object(map: &Map<String, Value>, indent: usize, out: &mut String) {
124    if map.is_empty() {
125        out.push_str("{}");
126        return;
127    }
128    // Unicode-codepoint order (Python's `sort_keys=True` default) equals
129    // UTF-8 byte order equals Rust's `str`/`String` `Ord` — plain `.sort()`
130    // on the keys is sufficient, no custom collation needed.
131    let mut keys: Vec<&String> = map.keys().collect();
132    keys.sort();
133
134    out.push('{');
135    let inner_indent = indent + 2;
136    for (i, key) in keys.iter().enumerate() {
137        out.push('\n');
138        out.push_str(&" ".repeat(inner_indent));
139        write_py_string(key, out);
140        out.push_str(": ");
141        write_value(&map[*key], inner_indent, out);
142        if i + 1 < keys.len() {
143            out.push(',');
144        }
145    }
146    out.push('\n');
147    out.push_str(&" ".repeat(indent));
148    out.push('}');
149}
150
151/// Matches CPython's JSON string encoding with `ensure_ascii=True` (the
152/// default `json.dumps` uses, and what `json_output.py` relies on
153/// without overriding): standard shorthand escapes for `"`, `\`, and
154/// `\n\r\t\b\f`; printable ASCII (0x20-0x7E) passed through as-is;
155/// every other codepoint — including other C0 controls, DEL (0x7F, NOT
156/// ASCII-printable despite being in the ASCII range), and all non-ASCII
157/// — emitted as `\uXXXX`, with UTF-16 surrogate pairs above U+FFFF.
158fn write_py_string(s: &str, out: &mut String) {
159    out.push('"');
160    for c in s.chars() {
161        match c {
162            '"' => out.push_str("\\\""),
163            '\\' => out.push_str("\\\\"),
164            '\n' => out.push_str("\\n"),
165            '\r' => out.push_str("\\r"),
166            '\t' => out.push_str("\\t"),
167            '\x08' => out.push_str("\\b"),
168            '\x0c' => out.push_str("\\f"),
169            c if (' '..='~').contains(&c) => out.push(c),
170            c => {
171                let cp = c as u32;
172                if cp <= 0xFFFF {
173                    out.push_str(&format!("\\u{cp:04x}"));
174                } else {
175                    let cp = cp - 0x10000;
176                    let high = 0xD800 + (cp >> 10);
177                    let low = 0xDC00 + (cp & 0x3FF);
178                    out.push_str(&format!("\\u{high:04x}\\u{low:04x}"));
179                }
180            }
181        }
182    }
183    out.push('"');
184}