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