Skip to main content

lintel_check/
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    /// Validation error for `lintel.toml` against its built-in schema.
58    ///
59    /// Uses `#[error("{path}: {message}")]` so the path appears even when
60    /// rendered as a plain string (e.g. via `to_string()`). The `path` field
61    /// mirrors `Validation` for consistency; `src` carries the same value via
62    /// `NamedSource` for miette's source-code rendering.
63    #[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    /// File path associated with this error.
100    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    /// Human-readable error message.
112    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    /// Byte offset in the source file (for sorting).
124    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
136/// Convert a byte offset into 1-based (line, column).
137///
138/// Returns `(1, 1)` if the offset is 0 or the content is empty.
139pub 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
157/// Find the byte offset of the first non-comment, non-blank line in the content.
158///
159/// Skips lines that start with `#` (YAML/TOML comments, modelines) or `//` (JSONC),
160/// as well as blank lines. Returns 0 if all lines are comments or the content is empty.
161fn 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; // +1 for newline
170    }
171    0
172}
173
174/// Find the byte span `(offset, length)` of a JSON pointer path segment in the
175/// source text, suitable for converting directly into a [`SourceSpan`].
176///
177/// For an `instance_path` like `/properties/name`, searches for the last segment
178/// `name` as a JSON key (`"name"`) or YAML key (`name:`), and returns a span
179/// covering the matched token.
180///
181/// For root-level errors (empty or "/" path), skips past leading comment and blank
182/// lines so the error arrow points at actual content rather than modeline comments.
183/// The returned span has zero length in this case since there is no specific token.
184///
185/// Falls back to `(0, 0)` if nothing is found.
186pub 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    // Get the last path segment (e.g., "/foo/bar/baz" -> "baz")
192    let segment = instance_path.rsplit('/').next().unwrap_or("");
193    if segment.is_empty() {
194        return (0, 0);
195    }
196
197    // Try JSON-style key: "segment" — highlight including quotes
198    let json_key = format!("\"{segment}\"");
199    if let Some(pos) = content.find(&json_key) {
200        return (pos, json_key.len());
201    }
202
203    // Try YAML-style key: segment: (at line start or after whitespace)
204    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(&quoted_yaml_key) {
210            let key_start = line.len() - trimmed.len();
211            // Highlight the quoted key without the trailing colon
212            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            // Highlight just the key without the trailing colon
217            return (offset + key_start, segment.len());
218        }
219        offset += line.len() + 1; // +1 for newline
220    }
221
222    (0, 0)
223}
224
225/// Build a label string combining the instance path and the schema path.
226///
227/// Returns just the `instance_path` when `schema_path` is empty.
228pub 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); // "name: hello" starts at byte 59
280        assert_eq!(len, 0); // root-level: no specific token
281        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)); // "name"
315        assert_eq!(find_instance_path_span(content, "/age"), (18, 5)); // "age"
316    }
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)); // name
322        assert_eq!(find_instance_path_span(content, "/age"), (12, 3)); // age
323    }
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)); // "on"
329    }
330
331    // --- Error code tests ---
332
333    #[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    // --- format_label tests ---
403
404    #[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}