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