Skip to main content

lintel_diagnostics/
diagnostics.rs

1use miette::{Diagnostic, 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 single diagnostic produced during validation, formatting, or parsing.
9#[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 URI this file was validated against (shown as a clickable link
39        /// in terminals for remote schemas).
40        schema_url: String,
41        /// JSON Schema path that triggered the error (e.g. `/properties/jobs/oneOf`).
42        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    /// File path associated with this error.
79    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    /// Human-readable error message.
92    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    /// Byte offset in the source file (for sorting).
105    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
119/// Convert a byte offset into 1-based (line, column).
120///
121/// Returns `(1, 1)` if the offset is 0 or the content is empty.
122pub 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
140/// Find the byte offset of the first non-comment, non-blank line in the content.
141///
142/// Skips lines that start with `#` (YAML/TOML comments, modelines) or `//` (JSONC),
143/// as well as blank lines. Returns 0 if all lines are comments or the content is empty.
144fn 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; // +1 for newline
153    }
154    0
155}
156
157/// Find the byte span `(offset, length)` of a JSON pointer path segment in the
158/// source text, suitable for converting directly into a [`SourceSpan`].
159///
160/// For an `instance_path` like `/properties/name`, searches for the last segment
161/// `name` as a JSON key (`"name"`) or YAML key (`name:`), and returns a span
162/// covering the matched token.
163///
164/// For root-level errors (empty or "/" path), skips past leading comment and blank
165/// lines so the error arrow points at actual content rather than modeline comments.
166/// The returned span has zero length in this case since there is no specific token.
167///
168/// Falls back to `(0, 0)` if nothing is found.
169pub 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    // Get the last path segment (e.g., "/foo/bar/baz" -> "baz")
175    let segment = instance_path.rsplit('/').next().unwrap_or("");
176    if segment.is_empty() {
177        return (0, 0);
178    }
179
180    // Try JSON-style key: "segment" — highlight including quotes
181    let json_key = format!("\"{segment}\"");
182    if let Some(pos) = content.find(&json_key) {
183        return (pos, json_key.len());
184    }
185
186    // Try YAML-style key: segment: (at line start or after whitespace)
187    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(&quoted_yaml_key) {
193            let key_start = line.len() - trimmed.len();
194            // Highlight the quoted key without the trailing colon
195            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            // Highlight just the key without the trailing colon
200            return (offset + key_start, segment.len());
201        }
202        offset += line.len() + 1; // +1 for newline
203    }
204
205    (0, 0)
206}
207
208/// Build a label string combining the instance path and the schema path.
209///
210/// Returns just the `instance_path` when `schema_path` is empty.
211pub 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); // "name: hello" starts at byte 59
263        assert_eq!(len, 0); // root-level: no specific token
264        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)); // "name"
298        assert_eq!(find_instance_path_span(content, "/age"), (18, 5)); // "age"
299    }
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)); // name
305        assert_eq!(find_instance_path_span(content, "/age"), (12, 3)); // age
306    }
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)); // "on"
312    }
313
314    // --- Error code tests ---
315
316    #[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    // --- format_label tests ---
392
393    #[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}