1use miette::{Diagnostic, LabeledSpan, NamedSource, SourceSpan};
2use thiserror::Error;
3
4pub const DEFAULT_LABEL: &str = "here";
7
8#[derive(Debug, Error)]
13#[error("{message}")]
14pub struct ValidationDiagnostic {
15 pub src: NamedSource<String>,
16 pub span: SourceSpan,
17 pub schema_span: SourceSpan,
18 pub path: String,
19 pub instance_path: String,
20 pub label: String,
21 pub message: String,
22 pub schema_url: String,
25 pub schema_path: String,
27 pub validation_code: String,
29}
30
31impl Diagnostic for ValidationDiagnostic {
32 fn code<'a>(&'a self) -> Option<Box<dyn core::fmt::Display + 'a>> {
33 Some(Box::new(&self.validation_code))
34 }
35
36 fn url<'a>(&'a self) -> Option<Box<dyn core::fmt::Display + 'a>> {
37 Some(Box::new(&self.schema_url))
38 }
39
40 fn help<'a>(&'a self) -> Option<Box<dyn core::fmt::Display + 'a>> {
41 Some(Box::new(format!(
42 "run `lintel explain --file {}` to see the full schema definition",
43 self.path
44 )))
45 }
46
47 fn source_code(&self) -> Option<&dyn miette::SourceCode> {
48 Some(&self.src)
49 }
50
51 fn labels(&self) -> Option<Box<dyn Iterator<Item = LabeledSpan> + '_>> {
52 Some(Box::new(
53 [
54 LabeledSpan::new(
55 Some(self.label.clone()),
56 self.span.offset(),
57 self.span.len(),
58 ),
59 LabeledSpan::new(
60 Some(format!("from {}", self.schema_url)),
61 self.schema_span.offset(),
62 self.schema_span.len(),
63 ),
64 ]
65 .into_iter(),
66 ))
67 }
68}
69
70#[derive(Debug, Error, Diagnostic)]
72pub enum LintelDiagnostic {
73 #[error("{message}")]
74 #[diagnostic(code(parse))]
75 Parse {
76 #[source_code]
77 src: NamedSource<String>,
78 #[label("here")]
79 span: SourceSpan,
80 message: String,
81 },
82
83 #[error(transparent)]
84 #[diagnostic(transparent)]
85 Validation(ValidationDiagnostic),
86
87 #[error("{path}: mismatched $schema on line {line_number}: {message}")]
88 #[diagnostic(code(jsonl::schema_mismatch))]
89 SchemaMismatch {
90 path: String,
91 line_number: usize,
92 message: String,
93 },
94
95 #[error("{path}: {message}")]
96 #[diagnostic(code(io))]
97 Io { path: String, message: String },
98
99 #[error("{path}: {message}")]
100 #[diagnostic(code(schema::fetch))]
101 SchemaFetch { path: String, message: String },
102
103 #[error("{path}: {message}")]
104 #[diagnostic(code(schema::compile))]
105 SchemaCompile { path: String, message: String },
106
107 #[error("Formatter would have printed the following content:\n\n{styled_path}\n\n{diff}")]
108 #[diagnostic(
109 code(format),
110 help("run `lintel check --fix` or `lintel format` to fix formatting")
111 )]
112 Format {
113 path: String,
114 styled_path: String,
115 diff: String,
116 },
117}
118
119impl LintelDiagnostic {
120 pub fn path(&self) -> &str {
122 match self {
123 LintelDiagnostic::Parse { src, .. } => src.name(),
124 LintelDiagnostic::Validation(v) => &v.path,
125 LintelDiagnostic::SchemaMismatch { path, .. }
126 | LintelDiagnostic::Io { path, .. }
127 | LintelDiagnostic::SchemaFetch { path, .. }
128 | LintelDiagnostic::SchemaCompile { path, .. }
129 | LintelDiagnostic::Format { path, .. } => path,
130 }
131 }
132
133 pub fn message(&self) -> &str {
135 match self {
136 LintelDiagnostic::Parse { message, .. }
137 | LintelDiagnostic::SchemaMismatch { message, .. }
138 | LintelDiagnostic::Io { message, .. }
139 | LintelDiagnostic::SchemaFetch { message, .. }
140 | LintelDiagnostic::SchemaCompile { message, .. } => message,
141 LintelDiagnostic::Validation(v) => &v.message,
142 LintelDiagnostic::Format { .. } => "file is not properly formatted",
143 }
144 }
145
146 pub fn offset(&self) -> usize {
148 match self {
149 LintelDiagnostic::Parse { span, .. } => span.offset(),
150 LintelDiagnostic::Validation(v) => v.span.offset(),
151 LintelDiagnostic::SchemaMismatch { .. }
152 | LintelDiagnostic::Io { .. }
153 | LintelDiagnostic::SchemaFetch { .. }
154 | LintelDiagnostic::SchemaCompile { .. }
155 | LintelDiagnostic::Format { .. } => 0,
156 }
157 }
158}
159
160pub fn offset_to_line_col(content: &str, offset: usize) -> (usize, usize) {
164 let offset = offset.min(content.len());
165 let mut line = 1;
166 let mut col = 1;
167 for (i, ch) in content.char_indices() {
168 if i >= offset {
169 break;
170 }
171 if ch == '\n' {
172 line += 1;
173 col = 1;
174 } else {
175 col += 1;
176 }
177 }
178 (line, col)
179}
180
181fn first_content_offset(content: &str) -> usize {
186 let mut offset = 0;
187 for line in content.lines() {
188 let trimmed = line.trim_start();
189 if !trimmed.is_empty() && !trimmed.starts_with('#') && !trimmed.starts_with("//") {
190 let key_start = line.len() - trimmed.len();
191 return offset + key_start;
192 }
193 offset += line.len() + 1; }
195 0
196}
197
198pub fn find_instance_path_span(content: &str, instance_path: &str) -> (usize, usize) {
211 if instance_path.is_empty() || instance_path == "/" {
212 return (first_content_offset(content), 0);
213 }
214
215 let segment = instance_path.rsplit('/').next().unwrap_or("");
217 if segment.is_empty() {
218 return (0, 0);
219 }
220
221 let json_key = format!("\"{segment}\"");
223 if let Some(pos) = content.find(&json_key) {
224 return (pos, json_key.len());
225 }
226
227 let yaml_key = format!("{segment}:");
229 let quoted_yaml_key = format!("\"{segment}\":");
230 let mut offset = 0;
231 for line in content.lines() {
232 let trimmed = line.trim_start();
233 if trimmed.starts_with("ed_yaml_key) {
234 let key_start = line.len() - trimmed.len();
235 return (offset + key_start, quoted_yaml_key.len() - 1);
237 }
238 if trimmed.starts_with(&yaml_key) {
239 let key_start = line.len() - trimmed.len();
240 return (offset + key_start, segment.len());
242 }
243 offset += line.len() + 1; }
245
246 (0, 0)
247}
248
249pub fn format_label(instance_path: &str, schema_path: &str) -> String {
253 if schema_path.is_empty() {
254 instance_path.to_string()
255 } else {
256 format!("{instance_path} in {schema_path}")
257 }
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263
264 #[test]
265 fn offset_zero_returns_line_one_col_one() {
266 assert_eq!(offset_to_line_col("hello", 0), (1, 1));
267 }
268
269 #[test]
270 fn offset_within_first_line() {
271 assert_eq!(offset_to_line_col("hello world", 5), (1, 6));
272 }
273
274 #[test]
275 fn offset_at_second_line() {
276 assert_eq!(offset_to_line_col("ab\ncd\nef", 3), (2, 1));
277 }
278
279 #[test]
280 fn offset_middle_of_second_line() {
281 assert_eq!(offset_to_line_col("ab\ncd\nef", 4), (2, 2));
282 }
283
284 #[test]
285 fn offset_at_third_line() {
286 assert_eq!(offset_to_line_col("ab\ncd\nef", 6), (3, 1));
287 }
288
289 #[test]
290 fn offset_past_end_clamps() {
291 assert_eq!(offset_to_line_col("ab\ncd", 100), (2, 3));
292 }
293
294 #[test]
295 fn empty_content() {
296 assert_eq!(offset_to_line_col("", 0), (1, 1));
297 }
298
299 #[test]
300 fn root_path_skips_yaml_modeline() {
301 let content = "# yaml-language-server: $schema=https://example.com/s.json\nname: hello\n";
302 let (offset, len) = find_instance_path_span(content, "");
303 assert_eq!(offset, 59); assert_eq!(len, 0); assert_eq!(offset_to_line_col(content, offset), (2, 1));
306 }
307
308 #[test]
309 fn root_path_skips_multiple_comments() {
310 let content = "# modeline\n# another comment\n\nname: hello\n";
311 let (offset, _) = find_instance_path_span(content, "");
312 assert_eq!(offset_to_line_col(content, offset), (4, 1));
313 }
314
315 #[test]
316 fn root_path_no_comments_returns_zero() {
317 let content = "{\"name\": \"hello\"}";
318 assert_eq!(find_instance_path_span(content, ""), (0, 0));
319 }
320
321 #[test]
322 fn root_path_skips_toml_modeline() {
323 let content = "# :schema https://example.com/s.json\nname = \"hello\"\n";
324 let (offset, _) = find_instance_path_span(content, "");
325 assert_eq!(offset_to_line_col(content, offset), (2, 1));
326 }
327
328 #[test]
329 fn root_path_slash_skips_comments() {
330 let content = "# yaml-language-server: $schema=url\ndata: value\n";
331 let (offset, _) = find_instance_path_span(content, "/");
332 assert_eq!(offset_to_line_col(content, offset), (2, 1));
333 }
334
335 #[test]
336 fn span_highlights_json_key() {
337 let content = r#"{"name": "hello", "age": 30}"#;
338 assert_eq!(find_instance_path_span(content, "/name"), (1, 6)); assert_eq!(find_instance_path_span(content, "/age"), (18, 5)); }
341
342 #[test]
343 fn span_highlights_yaml_key() {
344 let content = "name: hello\nage: 30\n";
345 assert_eq!(find_instance_path_span(content, "/name"), (0, 4)); assert_eq!(find_instance_path_span(content, "/age"), (12, 3)); }
348
349 #[test]
350 fn span_highlights_quoted_yaml_key() {
351 let content = "\"on\": push\n";
352 assert_eq!(find_instance_path_span(content, "/on"), (0, 4)); }
354
355 #[test]
358 fn error_codes() {
359 use miette::Diagnostic;
360
361 let cases: Vec<(LintelDiagnostic, &str)> = vec![
362 (
363 LintelDiagnostic::Parse {
364 src: NamedSource::new("f", String::new()),
365 span: 0.into(),
366 message: String::new(),
367 },
368 "parse",
369 ),
370 (
371 LintelDiagnostic::Validation(ValidationDiagnostic {
372 src: NamedSource::new("f", String::new()),
373 span: 0.into(),
374 schema_span: 0.into(),
375 path: String::new(),
376 instance_path: String::new(),
377 label: String::new(),
378 message: String::new(),
379 schema_url: String::new(),
380 schema_path: String::new(),
381 validation_code: "validation(required)".to_string(),
382 }),
383 "validation(required)",
384 ),
385 (
386 LintelDiagnostic::SchemaMismatch {
387 path: String::new(),
388 line_number: 0,
389 message: String::new(),
390 },
391 "jsonl::schema_mismatch",
392 ),
393 (
394 LintelDiagnostic::Io {
395 path: String::new(),
396 message: String::new(),
397 },
398 "io",
399 ),
400 (
401 LintelDiagnostic::SchemaFetch {
402 path: String::new(),
403 message: String::new(),
404 },
405 "schema::fetch",
406 ),
407 (
408 LintelDiagnostic::SchemaCompile {
409 path: String::new(),
410 message: String::new(),
411 },
412 "schema::compile",
413 ),
414 (
415 LintelDiagnostic::Format {
416 path: String::new(),
417 styled_path: String::new(),
418 diff: String::new(),
419 },
420 "format",
421 ),
422 ];
423
424 for (error, expected_code) in cases {
425 assert_eq!(
426 error.code().expect("missing diagnostic code").to_string(),
427 expected_code,
428 "wrong code for {error:?}"
429 );
430 }
431 }
432
433 #[test]
436 fn format_label_with_schema_path() {
437 assert_eq!(
438 format_label(
439 "/jobs/build",
440 "/properties/jobs/patternProperties/^[_a-zA-Z][a-zA-Z0-9_-]*$/oneOf"
441 ),
442 "/jobs/build in /properties/jobs/patternProperties/^[_a-zA-Z][a-zA-Z0-9_-]*$/oneOf"
443 );
444 }
445
446 #[test]
447 fn format_label_empty_schema_path() {
448 assert_eq!(format_label("/name", ""), "/name");
449 }
450}