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
279pub 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}