Skip to main content

lintel_validate/
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 parse error with exact source location.
9///
10/// Used as the error type for the [`Parser`](crate::parsers::Parser) trait.
11/// Converted into [`LintError::Parse`] via the `From` impl.
12#[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/// A single lint error produced during validation.
21#[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 URI this file was validated against (shown as a clickable link
51        /// in terminals for remote schemas).
52        schema_url: String,
53        /// JSON Schema path that triggered the error (e.g. `/properties/jobs/oneOf`).
54        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    /// File path associated with this error.
82    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    /// Human-readable error message.
93    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    /// Byte offset in the source file (for sorting).
104    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
114/// Convert a byte offset into 1-based (line, column).
115///
116/// Returns `(1, 1)` if the offset is 0 or the content is empty.
117pub 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
135/// Find the byte offset of the first non-comment, non-blank line in the content.
136///
137/// Skips lines that start with `#` (YAML/TOML comments, modelines) or `//` (JSONC),
138/// as well as blank lines. Returns 0 if all lines are comments or the content is empty.
139fn 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; // +1 for newline
148    }
149    0
150}
151
152/// Find the byte span `(offset, length)` of a JSON pointer path segment in the
153/// source text, suitable for converting directly into a [`SourceSpan`].
154///
155/// For an `instance_path` like `/properties/name`, searches for the last segment
156/// `name` as a JSON key (`"name"`) or YAML key (`name:`), and returns a span
157/// covering the matched token.
158///
159/// For root-level errors (empty or "/" path), skips past leading comment and blank
160/// lines so the error arrow points at actual content rather than modeline comments.
161/// The returned span has zero length in this case since there is no specific token.
162///
163/// Falls back to `(0, 0)` if nothing is found.
164pub 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    // Get the last path segment (e.g., "/foo/bar/baz" -> "baz")
170    let segment = instance_path.rsplit('/').next().unwrap_or("");
171    if segment.is_empty() {
172        return (0, 0);
173    }
174
175    // Try JSON-style key: "segment" — highlight including quotes
176    let json_key = format!("\"{segment}\"");
177    if let Some(pos) = content.find(&json_key) {
178        return (pos, json_key.len());
179    }
180
181    // Try YAML-style key: segment: (at line start or after whitespace)
182    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(&quoted_yaml_key) {
188            let key_start = line.len() - trimmed.len();
189            // Highlight the quoted key without the trailing colon
190            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            // Highlight just the key without the trailing colon
195            return (offset + key_start, segment.len());
196        }
197        offset += line.len() + 1; // +1 for newline
198    }
199
200    (0, 0)
201}
202
203/// Build a label string combining the instance path and the schema path.
204///
205/// Returns just the `instance_path` when `schema_path` is empty.
206pub 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); // "name: hello" starts at byte 59
258        assert_eq!(len, 0); // root-level: no specific token
259        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)); // "name"
293        assert_eq!(find_instance_path_span(content, "/age"), (18, 5)); // "age"
294    }
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)); // name
300        assert_eq!(find_instance_path_span(content, "/age"), (12, 3)); // age
301    }
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)); // "on"
307    }
308
309    // --- Error code tests ---
310
311    #[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    // --- format_label tests ---
371
372    #[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}