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