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}: {message}")]
64 #[diagnostic(code(lintel::config))]
65 Config {
66 #[source_code]
67 src: NamedSource<String>,
68 #[label("{instance_path}")]
69 span: SourceSpan,
70 path: String,
71 instance_path: String,
72 message: String,
73 },
74
75 #[error("{path}: {message}")]
76 #[diagnostic(code(lintel::io))]
77 Io { path: String, message: String },
78
79 #[error("{path}: {message}")]
80 #[diagnostic(code(lintel::schema::fetch))]
81 SchemaFetch { path: String, message: String },
82
83 #[error("{path}: {message}")]
84 #[diagnostic(code(lintel::schema::compile))]
85 SchemaCompile { path: String, message: String },
86}
87
88impl From<ParseDiagnostic> for LintError {
89 fn from(d: ParseDiagnostic) -> Self {
90 LintError::Parse {
91 src: d.src,
92 span: d.span,
93 message: d.message,
94 }
95 }
96}
97
98impl LintError {
99 pub fn path(&self) -> &str {
101 match self {
102 LintError::Parse { src, .. } => src.name(),
103 LintError::Validation { path, .. }
104 | LintError::Config { path, .. }
105 | LintError::Io { path, .. }
106 | LintError::SchemaFetch { path, .. }
107 | LintError::SchemaCompile { path, .. } => path,
108 }
109 }
110
111 pub fn message(&self) -> &str {
113 match self {
114 LintError::Parse { message, .. }
115 | LintError::Validation { message, .. }
116 | LintError::Config { message, .. }
117 | LintError::Io { message, .. }
118 | LintError::SchemaFetch { message, .. }
119 | LintError::SchemaCompile { message, .. } => message,
120 }
121 }
122
123 pub fn offset(&self) -> usize {
125 match self {
126 LintError::Parse { span, .. }
127 | LintError::Validation { span, .. }
128 | LintError::Config { span, .. } => span.offset(),
129 LintError::Io { .. }
130 | LintError::SchemaFetch { .. }
131 | LintError::SchemaCompile { .. } => 0,
132 }
133 }
134}
135
136pub fn offset_to_line_col(content: &str, offset: usize) -> (usize, usize) {
140 let offset = offset.min(content.len());
141 let mut line = 1;
142 let mut col = 1;
143 for (i, ch) in content.char_indices() {
144 if i >= offset {
145 break;
146 }
147 if ch == '\n' {
148 line += 1;
149 col = 1;
150 } else {
151 col += 1;
152 }
153 }
154 (line, col)
155}
156
157fn first_content_offset(content: &str) -> usize {
162 let mut offset = 0;
163 for line in content.lines() {
164 let trimmed = line.trim_start();
165 if !trimmed.is_empty() && !trimmed.starts_with('#') && !trimmed.starts_with("//") {
166 let key_start = line.len() - trimmed.len();
167 return offset + key_start;
168 }
169 offset += line.len() + 1; }
171 0
172}
173
174pub fn find_instance_path_span(content: &str, instance_path: &str) -> (usize, usize) {
187 if instance_path.is_empty() || instance_path == "/" {
188 return (first_content_offset(content), 0);
189 }
190
191 let segment = instance_path.rsplit('/').next().unwrap_or("");
193 if segment.is_empty() {
194 return (0, 0);
195 }
196
197 let json_key = format!("\"{segment}\"");
199 if let Some(pos) = content.find(&json_key) {
200 return (pos, json_key.len());
201 }
202
203 let yaml_key = format!("{segment}:");
205 let quoted_yaml_key = format!("\"{segment}\":");
206 let mut offset = 0;
207 for line in content.lines() {
208 let trimmed = line.trim_start();
209 if trimmed.starts_with("ed_yaml_key) {
210 let key_start = line.len() - trimmed.len();
211 return (offset + key_start, quoted_yaml_key.len() - 1);
213 }
214 if trimmed.starts_with(&yaml_key) {
215 let key_start = line.len() - trimmed.len();
216 return (offset + key_start, segment.len());
218 }
219 offset += line.len() + 1; }
221
222 (0, 0)
223}
224
225pub fn format_label(instance_path: &str, schema_path: &str) -> String {
229 if schema_path.is_empty() {
230 instance_path.to_string()
231 } else {
232 format!("{instance_path} in {schema_path}")
233 }
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239
240 #[test]
241 fn offset_zero_returns_line_one_col_one() {
242 assert_eq!(offset_to_line_col("hello", 0), (1, 1));
243 }
244
245 #[test]
246 fn offset_within_first_line() {
247 assert_eq!(offset_to_line_col("hello world", 5), (1, 6));
248 }
249
250 #[test]
251 fn offset_at_second_line() {
252 assert_eq!(offset_to_line_col("ab\ncd\nef", 3), (2, 1));
253 }
254
255 #[test]
256 fn offset_middle_of_second_line() {
257 assert_eq!(offset_to_line_col("ab\ncd\nef", 4), (2, 2));
258 }
259
260 #[test]
261 fn offset_at_third_line() {
262 assert_eq!(offset_to_line_col("ab\ncd\nef", 6), (3, 1));
263 }
264
265 #[test]
266 fn offset_past_end_clamps() {
267 assert_eq!(offset_to_line_col("ab\ncd", 100), (2, 3));
268 }
269
270 #[test]
271 fn empty_content() {
272 assert_eq!(offset_to_line_col("", 0), (1, 1));
273 }
274
275 #[test]
276 fn root_path_skips_yaml_modeline() {
277 let content = "# yaml-language-server: $schema=https://example.com/s.json\nname: hello\n";
278 let (offset, len) = find_instance_path_span(content, "");
279 assert_eq!(offset, 59); assert_eq!(len, 0); assert_eq!(offset_to_line_col(content, offset), (2, 1));
282 }
283
284 #[test]
285 fn root_path_skips_multiple_comments() {
286 let content = "# modeline\n# another comment\n\nname: hello\n";
287 let (offset, _) = find_instance_path_span(content, "");
288 assert_eq!(offset_to_line_col(content, offset), (4, 1));
289 }
290
291 #[test]
292 fn root_path_no_comments_returns_zero() {
293 let content = "{\"name\": \"hello\"}";
294 assert_eq!(find_instance_path_span(content, ""), (0, 0));
295 }
296
297 #[test]
298 fn root_path_skips_toml_modeline() {
299 let content = "# :schema https://example.com/s.json\nname = \"hello\"\n";
300 let (offset, _) = find_instance_path_span(content, "");
301 assert_eq!(offset_to_line_col(content, offset), (2, 1));
302 }
303
304 #[test]
305 fn root_path_slash_skips_comments() {
306 let content = "# yaml-language-server: $schema=url\ndata: value\n";
307 let (offset, _) = find_instance_path_span(content, "/");
308 assert_eq!(offset_to_line_col(content, offset), (2, 1));
309 }
310
311 #[test]
312 fn span_highlights_json_key() {
313 let content = r#"{"name": "hello", "age": 30}"#;
314 assert_eq!(find_instance_path_span(content, "/name"), (1, 6)); assert_eq!(find_instance_path_span(content, "/age"), (18, 5)); }
317
318 #[test]
319 fn span_highlights_yaml_key() {
320 let content = "name: hello\nage: 30\n";
321 assert_eq!(find_instance_path_span(content, "/name"), (0, 4)); assert_eq!(find_instance_path_span(content, "/age"), (12, 3)); }
324
325 #[test]
326 fn span_highlights_quoted_yaml_key() {
327 let content = "\"on\": push\n";
328 assert_eq!(find_instance_path_span(content, "/on"), (0, 4)); }
330
331 #[test]
334 fn error_codes() {
335 use miette::Diagnostic;
336
337 let cases: Vec<(LintError, &str)> = vec![
338 (
339 LintError::Parse {
340 src: NamedSource::new("f", String::new()),
341 span: 0.into(),
342 message: String::new(),
343 },
344 "lintel::parse",
345 ),
346 (
347 LintError::Validation {
348 src: NamedSource::new("f", String::new()),
349 span: 0.into(),
350 schema_span: 0.into(),
351 path: String::new(),
352 instance_path: String::new(),
353 label: String::new(),
354 message: String::new(),
355 schema_url: String::new(),
356 schema_path: String::new(),
357 },
358 "lintel::validation",
359 ),
360 (
361 LintError::Config {
362 src: NamedSource::new("f", String::new()),
363 span: 0.into(),
364 path: String::new(),
365 instance_path: String::new(),
366 message: String::new(),
367 },
368 "lintel::config",
369 ),
370 (
371 LintError::Io {
372 path: String::new(),
373 message: String::new(),
374 },
375 "lintel::io",
376 ),
377 (
378 LintError::SchemaFetch {
379 path: String::new(),
380 message: String::new(),
381 },
382 "lintel::schema::fetch",
383 ),
384 (
385 LintError::SchemaCompile {
386 path: String::new(),
387 message: String::new(),
388 },
389 "lintel::schema::compile",
390 ),
391 ];
392
393 for (error, expected_code) in cases {
394 assert_eq!(
395 error.code().expect("missing diagnostic code").to_string(),
396 expected_code,
397 "wrong code for {error:?}"
398 );
399 }
400 }
401
402 #[test]
405 fn format_label_with_schema_path() {
406 assert_eq!(
407 format_label(
408 "/jobs/build",
409 "/properties/jobs/patternProperties/^[_a-zA-Z][a-zA-Z0-9_-]*$/oneOf"
410 ),
411 "/jobs/build in /properties/jobs/patternProperties/^[_a-zA-Z][a-zA-Z0-9_-]*$/oneOf"
412 );
413 }
414
415 #[test]
416 fn format_label_empty_schema_path() {
417 assert_eq!(format_label("/name", ""), "/name");
418 }
419}