Skip to main content

lintel_diagnostics/
diagnostics.rs

1use miette::{Diagnostic, LabeledSpan, NamedSource, SourceSpan};
2use thiserror::Error;
3
4/// Default label text used for span annotations when no specific instance path
5/// is available. Checked by reporters to decide whether to show the path suffix.
6pub const DEFAULT_LABEL: &str = "here";
7
8/// A validation diagnostic with a granular error code (e.g. `validation(required)`).
9///
10/// Implements [`Diagnostic`] manually so the code can be computed at runtime
11/// from the [`ValidationErrorKind`](lintel_validation_cache::ValidationErrorKind).
12#[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    /// Schema URI this file was validated against (shown as a clickable link
23    /// in terminals for remote schemas).
24    pub schema_url: String,
25    /// JSON Schema path that triggered the error (e.g. `/properties/jobs/oneOf`).
26    pub schema_path: String,
27    /// Granular diagnostic code (e.g. `validation(required)`).
28    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/// A single diagnostic produced during validation, formatting, or parsing.
71#[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    /// File path associated with this error.
121    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    /// Human-readable error message.
134    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    /// Byte offset in the source file (for sorting).
147    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
160/// Convert a byte offset into 1-based (line, column).
161///
162/// Returns `(1, 1)` if the offset is 0 or the content is empty.
163pub 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
181/// Find the byte offset of the first non-comment, non-blank line in the content.
182///
183/// Skips lines that start with `#` (YAML/TOML comments, modelines) or `//` (JSONC),
184/// as well as blank lines. Returns 0 if all lines are comments or the content is empty.
185fn 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; // +1 for newline
194    }
195    0
196}
197
198/// Find the byte span `(offset, length)` of a JSON pointer path segment in the
199/// source text, suitable for converting directly into a [`SourceSpan`].
200///
201/// For an `instance_path` like `/properties/name`, searches for the last segment
202/// `name` as a JSON key (`"name"`) or YAML key (`name:`), and returns a span
203/// covering the matched token.
204///
205/// For root-level errors (empty or "/" path), skips past leading comment and blank
206/// lines so the error arrow points at actual content rather than modeline comments.
207/// The returned span has zero length in this case since there is no specific token.
208///
209/// Falls back to `(0, 0)` if nothing is found.
210pub 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    // Get the last path segment (e.g., "/foo/bar/baz" -> "baz")
216    let segment = instance_path.rsplit('/').next().unwrap_or("");
217    if segment.is_empty() {
218        return (0, 0);
219    }
220
221    // Try JSON-style key: "segment" — highlight including quotes
222    let json_key = format!("\"{segment}\"");
223    if let Some(pos) = content.find(&json_key) {
224        return (pos, json_key.len());
225    }
226
227    // Try YAML-style key: segment: (at line start or after whitespace)
228    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(&quoted_yaml_key) {
234            let key_start = line.len() - trimmed.len();
235            // Highlight the quoted key without the trailing colon
236            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            // Highlight just the key without the trailing colon
241            return (offset + key_start, segment.len());
242        }
243        offset += line.len() + 1; // +1 for newline
244    }
245
246    (0, 0)
247}
248
249/// Build a label string combining the instance path and the schema path.
250///
251/// Returns just the `instance_path` when `schema_path` is empty.
252pub 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); // "name: hello" starts at byte 59
304        assert_eq!(len, 0); // root-level: no specific token
305        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)); // "name"
339        assert_eq!(find_instance_path_span(content, "/age"), (18, 5)); // "age"
340    }
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)); // name
346        assert_eq!(find_instance_path_span(content, "/age"), (12, 3)); // age
347    }
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)); // "on"
353    }
354
355    // --- Error code tests ---
356
357    #[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    // --- format_label tests ---
434
435    #[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}