ferrite_json/
validator.rs

1use miette::{Diagnostic, SourceSpan};
2use serde_json::error::Category;
3use thiserror::Error;
4
5#[derive(Error, Debug, Diagnostic)]
6pub enum JsonError {
7    #[error("trailing comma")]
8    #[diagnostic(code(ferrite::trailing_comma))]
9    TrailingComma(
10        #[source_code] String,
11        #[label("remove this comma")] SourceSpan,
12        #[help] String,
13    ),
14    #[error("expected `,` or `]`")]
15    #[diagnostic(code(ferrite::missing_comma))]
16    MissingComma(
17        #[source_code] String,
18        #[label("add comma here")] SourceSpan,
19        #[help] String,
20    ),
21    #[error("expected `:`")]
22    #[diagnostic(code(ferrite::missing_colon))]
23    MissingColon(
24        #[source_code] String,
25        #[label("add `:` here")] SourceSpan,
26        #[help] String,
27    ),
28    #[error("unexpected end of file")]
29    #[diagnostic(code(ferrite::unexpected_eof))]
30    UnexpectedEof(
31        #[source_code] String,
32        #[label("file ended here")] SourceSpan,
33        #[help] String,
34    ),
35    #[error("expected quoted string as object key")]
36    #[diagnostic(code(ferrite::key_must_be_string))]
37    KeyMustBeString(
38        #[source_code] String,
39        #[label("add quotes around this")] SourceSpan,
40        #[help] String,
41    ),
42    #[error("invalid escape sequence")]
43    #[diagnostic(code(ferrite::invalid_escape))]
44    InvalidEscape(
45        #[source_code] String,
46        #[label("invalid escape")] SourceSpan,
47        #[help] String,
48    ),
49    #[error("invalid number")]
50    #[diagnostic(code(ferrite::invalid_number))]
51    InvalidNumber(
52        #[source_code] String,
53        #[label("malformed number")] SourceSpan,
54        #[help] String,
55    ),
56    #[error("invalid character in string")]
57    #[diagnostic(code(ferrite::control_character))]
58    InvalidControlCharacter(
59        #[source_code] String,
60        #[label("escape this character")] SourceSpan,
61        #[help] String,
62    ),
63    #[error("{0}")]
64    #[diagnostic(code(ferrite::syntax_error))]
65    SyntaxError(
66        String,
67        #[source_code] String,
68        #[label("error here")] SourceSpan,
69    ),
70}
71
72struct ErrorContext<'a> {
73    src: &'a str,
74    line: usize,
75    column: usize,
76    span: SourceSpan,
77    lines: Vec<&'a str>,
78}
79
80impl<'a> ErrorContext<'a> {
81    fn new(src: &'a str, line: usize, column: usize) -> Self {
82        let lines: Vec<&str> = src.lines().collect();
83        let span = SourceSpan::new(Self::offset_at(src, line, column).into(), 1);
84        Self {
85            src,
86            line,
87            column,
88            span,
89            lines,
90        }
91    }
92
93    fn current_line(&self) -> &'a str {
94        self.lines
95            .get(self.line.saturating_sub(1))
96            .copied()
97            .unwrap_or("")
98    }
99
100    fn previous_line(&self) -> Option<&'a str> {
101        self.lines.get(self.line.saturating_sub(2)).copied()
102    }
103
104    fn is_trailing_comma(&self) -> bool {
105        let current = self.current_line();
106        let cut = Self::byte_index(current, self.column.saturating_sub(1));
107        current[..cut.min(current.len())].trim_end().ends_with(',')
108    }
109
110    fn trailing_comma_hint(&self) -> String {
111        let trimmed = self.current_line().trim_end();
112        match trimmed
113            .strip_suffix(",]")
114            .or_else(|| trimmed.strip_suffix(",}"))
115        {
116            Some(prefix) => format!(
117                "change `{}` to `{}{}`",
118                trimmed,
119                prefix.trim_end(),
120                if trimmed.ends_with(",]") { "]" } else { "}" }
121            ),
122            None if trimmed.ends_with(',') => format!(
123                "change `{}` to `{}`",
124                trimmed,
125                trimmed.trim_end_matches(',')
126            ),
127            _ => "remove the trailing comma".to_string(),
128        }
129    }
130
131    fn missing_comma_hint(&self) -> String {
132        if let Some(line) = self.previous_line() {
133            let trimmed = line.trim_end();
134            if !trimmed.ends_with(',') && !trimmed.ends_with('[') && !trimmed.ends_with('{') {
135                return format!(
136                    "add `,` after `{}`",
137                    trimmed.chars().take(32).collect::<String>()
138                );
139            }
140        }
141        "add comma between items".to_string()
142    }
143
144    fn colon_hint(&self) -> String {
145        let line = self.current_line();
146        let end = Self::byte_index(line, self.column.saturating_sub(1)).min(line.len());
147        Self::extract_last_key(&line[..end])
148            .map(|key| format!("change `\"{}\"` to `\"{}\": `", key, key))
149            .unwrap_or_else(|| "add `:` after the key".to_string())
150    }
151
152    fn key_hint(&self) -> String {
153        let line = self.current_line();
154        let token = line[..Self::byte_index(line, self.column).min(line.len())]
155            .split_whitespace()
156            .last()
157            .unwrap_or("key")
158            .trim_end_matches(':');
159        let key = token.trim_matches(|c: char| !c.is_alphanumeric() && c != '_');
160        if key.is_empty() {
161            "wrap the key in quotes".to_string()
162        } else {
163            format!("change `{}` to `\"{}\": `", key, key)
164        }
165    }
166
167    fn eof_hint(&self) -> String {
168        let mut msgs = vec![];
169        let (open_brace, close_brace) = (
170            self.src.chars().filter(|&c| c == '{').count(),
171            self.src.chars().filter(|&c| c == '}').count(),
172        );
173        let (open_arr, close_arr) = (
174            self.src.chars().filter(|&c| c == '[').count(),
175            self.src.chars().filter(|&c| c == ']').count(),
176        );
177        if open_brace > close_brace {
178            msgs.push(format!("{} missing `}}`", open_brace - close_brace));
179        }
180        if open_arr > close_arr {
181            msgs.push(format!("{} missing `]`", open_arr - close_arr));
182        }
183        if msgs.is_empty() {
184            "add missing closing bracket".to_string()
185        } else {
186            msgs.join(", ")
187        }
188    }
189
190    fn escape_hint(&self) -> String {
191        let line = self.current_line();
192        if line.contains(":\\") {
193            if let Some(end) = line.rfind('"') {
194                if let Some(start) = line[..end].rfind('"') {
195                    let value = &line[start + 1..end];
196                    if value.contains(":\\") {
197                        return format!(
198                            "change `\"{}\"` to `\"{}\"`",
199                            value,
200                            value.replace('\\', "\\\\")
201                        );
202                    }
203                }
204            }
205        }
206        let window = self.line_window(5);
207        if window.contains("\\n") || window.contains("\\t") || window.contains("\\r") {
208            "this escape is already correct, check your quotes"
209        } else if window.contains('\\') {
210            "escape the backslash as \\\\"
211        } else {
212            "invalid escape - valid ones: \\\" \\\\ \\/ \\b \\f \\n \\r \\t \\uXXXX"
213        }
214        .to_string()
215    }
216
217    fn number_hint(&self) -> String {
218        let window = self.line_window(8);
219        let token: String = window
220            .chars()
221            .take_while(|ch| ch.is_ascii_digit() || matches!(ch, '-' | '+' | '.'))
222            .collect();
223        if token.is_empty() {
224            return "fix number format".to_string();
225        }
226        if token.starts_with('0')
227            && token.len() > 1
228            && token.chars().nth(1).is_some_and(|c| c.is_ascii_digit())
229        {
230            return format!("change `{}` to `{}`", token, token.trim_start_matches('0'));
231        }
232        if token.ends_with('.') {
233            return format!("change `{}` to `{}0`", token, token);
234        }
235        if let Some(stripped) = token.strip_prefix('+') {
236            return format!("change `{}` to `{}`", token, stripped);
237        }
238        "fix number format".to_string()
239    }
240
241    fn line_window(&self, radius: usize) -> &str {
242        let line = self.current_line();
243        let left_chars = self.column.saturating_sub(radius + 1);
244        let right_chars = self.column.saturating_add(radius);
245        let left = Self::byte_index(line, left_chars).min(line.len());
246        let right = Self::byte_index(line, right_chars).min(line.len());
247        &line[left..right]
248    }
249
250    fn offset_at(content: &str, line: usize, column: usize) -> usize {
251        let mut offset = 0usize;
252        for (idx, current) in content.lines().enumerate() {
253            if idx + 1 == line {
254                offset += Self::byte_index(current, column);
255                break;
256            }
257            offset += current.len() + 1;
258        }
259        offset
260    }
261
262    fn extract_last_key(text: &str) -> Option<String> {
263        let end = text.rfind('"')?;
264        let start = text[..end].rfind('"')?;
265        Some(text[start + 1..end].to_string())
266    }
267
268    fn byte_index(text: &str, column: usize) -> usize {
269        if column == 0 {
270            return 0;
271        }
272        text.char_indices()
273            .map(|(i, _)| i)
274            .nth(column.saturating_sub(1))
275            .unwrap_or_else(|| text.len())
276    }
277}
278
279/// validate json content and return errors with diagnostics
280///
281/// # args
282/// * `content` - the json content to validate
283/// * `_filename` - the filename (for error reporting)
284/// * `_context_lines` - number of context lines to show (reserved for future use)
285///
286/// # returns
287/// * `Ok(())` if the json is valid
288/// * `Err(miette::Report)` with diagnostics if invalid
289pub fn validate_json(
290    content: &str,
291    _filename: String,
292    _context_lines: usize,
293) -> miette::Result<()> {
294    serde_json::from_str::<serde_json::Value>(content)
295        .map(|_| ())
296        .map_err(|err| {
297            let ctx = ErrorContext::new(content, err.line(), err.column());
298            miette::Report::new(map_error(err, &ctx))
299        })
300}
301
302fn map_error(err: serde_json::Error, ctx: &ErrorContext<'_>) -> JsonError {
303    let lower = err.to_string().to_ascii_lowercase();
304    let (src, span) = (ctx.src.to_owned(), ctx.span);
305
306    if lower.contains("trailing comma") || ctx.is_trailing_comma() {
307        JsonError::TrailingComma(src, span, ctx.trailing_comma_hint())
308    } else if lower.contains("expected") && lower.contains("comma") {
309        JsonError::MissingComma(src, span, ctx.missing_comma_hint())
310    } else if lower.contains("key must be a string") {
311        JsonError::KeyMustBeString(src, span, ctx.key_hint())
312    } else if lower.contains("expected") && lower.contains("colon") {
313        JsonError::MissingColon(src, span, ctx.colon_hint())
314    } else if lower.contains("control character") {
315        JsonError::InvalidControlCharacter(
316            src,
317            span,
318            "replace tabs/newlines with \\t or \\n".to_string(),
319        )
320    } else if lower.contains("escape") {
321        JsonError::InvalidEscape(src, span, ctx.escape_hint())
322    } else if lower.contains("number") {
323        JsonError::InvalidNumber(src, span, ctx.number_hint())
324    } else if lower.contains("eof") || err.classify() == Category::Eof {
325        JsonError::UnexpectedEof(src, span, ctx.eof_hint())
326    } else {
327        JsonError::SyntaxError(err.to_string(), src, span)
328    }
329}