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}: mismatched $schema on line {line_number}: {message}")]
58    #[diagnostic(code(lintel::jsonl::schema_mismatch))]
59    SchemaMismatch {
60        path: String,
61        line_number: usize,
62        message: String,
63    },
64
65    #[error("{path}: {message}")]
66    #[diagnostic(code(lintel::io))]
67    Io { path: String, message: String },
68
69    #[error("{path}: {message}")]
70    #[diagnostic(code(lintel::schema::fetch))]
71    SchemaFetch { path: String, message: String },
72
73    #[error("{path}: {message}")]
74    #[diagnostic(code(lintel::schema::compile))]
75    SchemaCompile { path: String, message: String },
76}
77
78impl From<ParseDiagnostic> for LintError {
79    fn from(d: ParseDiagnostic) -> Self {
80        LintError::Parse {
81            src: d.src,
82            span: d.span,
83            message: d.message,
84        }
85    }
86}
87
88impl LintError {
89    /// File path associated with this error.
90    pub fn path(&self) -> &str {
91        match self {
92            LintError::Parse { src, .. } => src.name(),
93            LintError::Validation { path, .. }
94            | LintError::SchemaMismatch { path, .. }
95            | LintError::Io { path, .. }
96            | LintError::SchemaFetch { path, .. }
97            | LintError::SchemaCompile { path, .. } => path,
98        }
99    }
100
101    /// Human-readable error message.
102    pub fn message(&self) -> &str {
103        match self {
104            LintError::Parse { message, .. }
105            | LintError::Validation { message, .. }
106            | LintError::SchemaMismatch { message, .. }
107            | LintError::Io { message, .. }
108            | LintError::SchemaFetch { message, .. }
109            | LintError::SchemaCompile { message, .. } => message,
110        }
111    }
112
113    /// Byte offset in the source file (for sorting).
114    pub fn offset(&self) -> usize {
115        match self {
116            LintError::Parse { span, .. } | LintError::Validation { span, .. } => span.offset(),
117            LintError::SchemaMismatch { .. }
118            | LintError::Io { .. }
119            | LintError::SchemaFetch { .. }
120            | LintError::SchemaCompile { .. } => 0,
121        }
122    }
123}
124
125/// Convert a byte offset into 1-based (line, column).
126///
127/// Returns `(1, 1)` if the offset is 0 or the content is empty.
128pub fn offset_to_line_col(content: &str, offset: usize) -> (usize, usize) {
129    let offset = offset.min(content.len());
130    let mut line = 1;
131    let mut col = 1;
132    for (i, ch) in content.char_indices() {
133        if i >= offset {
134            break;
135        }
136        if ch == '\n' {
137            line += 1;
138            col = 1;
139        } else {
140            col += 1;
141        }
142    }
143    (line, col)
144}
145
146/// Find the byte offset of the first non-comment, non-blank line in the content.
147///
148/// Skips lines that start with `#` (YAML/TOML comments, modelines) or `//` (JSONC),
149/// as well as blank lines. Returns 0 if all lines are comments or the content is empty.
150fn first_content_offset(content: &str) -> usize {
151    let mut offset = 0;
152    for line in content.lines() {
153        let trimmed = line.trim_start();
154        if !trimmed.is_empty() && !trimmed.starts_with('#') && !trimmed.starts_with("//") {
155            let key_start = line.len() - trimmed.len();
156            return offset + key_start;
157        }
158        offset += line.len() + 1; // +1 for newline
159    }
160    0
161}
162
163/// Find the byte span `(offset, length)` of a JSON pointer path segment in the
164/// source text, suitable for converting directly into a [`SourceSpan`].
165///
166/// For an `instance_path` like `/properties/name`, searches for the last segment
167/// `name` as a JSON key (`"name"`) or YAML key (`name:`), and returns a span
168/// covering the matched token.
169///
170/// For root-level errors (empty or "/" path), skips past leading comment and blank
171/// lines so the error arrow points at actual content rather than modeline comments.
172/// The returned span has zero length in this case since there is no specific token.
173///
174/// Falls back to `(0, 0)` if nothing is found.
175pub fn find_instance_path_span(content: &str, instance_path: &str) -> (usize, usize) {
176    if instance_path.is_empty() || instance_path == "/" {
177        return (first_content_offset(content), 0);
178    }
179
180    // Get the last path segment (e.g., "/foo/bar/baz" -> "baz")
181    let segment = instance_path.rsplit('/').next().unwrap_or("");
182    if segment.is_empty() {
183        return (0, 0);
184    }
185
186    // Try JSON-style key: "segment" — highlight including quotes
187    let json_key = format!("\"{segment}\"");
188    if let Some(pos) = content.find(&json_key) {
189        return (pos, json_key.len());
190    }
191
192    // Try YAML-style key: segment: (at line start or after whitespace)
193    let yaml_key = format!("{segment}:");
194    let quoted_yaml_key = format!("\"{segment}\":");
195    let mut offset = 0;
196    for line in content.lines() {
197        let trimmed = line.trim_start();
198        if trimmed.starts_with(&quoted_yaml_key) {
199            let key_start = line.len() - trimmed.len();
200            // Highlight the quoted key without the trailing colon
201            return (offset + key_start, quoted_yaml_key.len() - 1);
202        }
203        if trimmed.starts_with(&yaml_key) {
204            let key_start = line.len() - trimmed.len();
205            // Highlight just the key without the trailing colon
206            return (offset + key_start, segment.len());
207        }
208        offset += line.len() + 1; // +1 for newline
209    }
210
211    (0, 0)
212}
213
214/// Build a label string combining the instance path and the schema path.
215///
216/// Returns just the `instance_path` when `schema_path` is empty.
217pub fn format_label(instance_path: &str, schema_path: &str) -> String {
218    if schema_path.is_empty() {
219        instance_path.to_string()
220    } else {
221        format!("{instance_path} in {schema_path}")
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn offset_zero_returns_line_one_col_one() {
231        assert_eq!(offset_to_line_col("hello", 0), (1, 1));
232    }
233
234    #[test]
235    fn offset_within_first_line() {
236        assert_eq!(offset_to_line_col("hello world", 5), (1, 6));
237    }
238
239    #[test]
240    fn offset_at_second_line() {
241        assert_eq!(offset_to_line_col("ab\ncd\nef", 3), (2, 1));
242    }
243
244    #[test]
245    fn offset_middle_of_second_line() {
246        assert_eq!(offset_to_line_col("ab\ncd\nef", 4), (2, 2));
247    }
248
249    #[test]
250    fn offset_at_third_line() {
251        assert_eq!(offset_to_line_col("ab\ncd\nef", 6), (3, 1));
252    }
253
254    #[test]
255    fn offset_past_end_clamps() {
256        assert_eq!(offset_to_line_col("ab\ncd", 100), (2, 3));
257    }
258
259    #[test]
260    fn empty_content() {
261        assert_eq!(offset_to_line_col("", 0), (1, 1));
262    }
263
264    #[test]
265    fn root_path_skips_yaml_modeline() {
266        let content = "# yaml-language-server: $schema=https://example.com/s.json\nname: hello\n";
267        let (offset, len) = find_instance_path_span(content, "");
268        assert_eq!(offset, 59); // "name: hello" starts at byte 59
269        assert_eq!(len, 0); // root-level: no specific token
270        assert_eq!(offset_to_line_col(content, offset), (2, 1));
271    }
272
273    #[test]
274    fn root_path_skips_multiple_comments() {
275        let content = "# modeline\n# another comment\n\nname: hello\n";
276        let (offset, _) = find_instance_path_span(content, "");
277        assert_eq!(offset_to_line_col(content, offset), (4, 1));
278    }
279
280    #[test]
281    fn root_path_no_comments_returns_zero() {
282        let content = "{\"name\": \"hello\"}";
283        assert_eq!(find_instance_path_span(content, ""), (0, 0));
284    }
285
286    #[test]
287    fn root_path_skips_toml_modeline() {
288        let content = "# :schema https://example.com/s.json\nname = \"hello\"\n";
289        let (offset, _) = find_instance_path_span(content, "");
290        assert_eq!(offset_to_line_col(content, offset), (2, 1));
291    }
292
293    #[test]
294    fn root_path_slash_skips_comments() {
295        let content = "# yaml-language-server: $schema=url\ndata: value\n";
296        let (offset, _) = find_instance_path_span(content, "/");
297        assert_eq!(offset_to_line_col(content, offset), (2, 1));
298    }
299
300    #[test]
301    fn span_highlights_json_key() {
302        let content = r#"{"name": "hello", "age": 30}"#;
303        assert_eq!(find_instance_path_span(content, "/name"), (1, 6)); // "name"
304        assert_eq!(find_instance_path_span(content, "/age"), (18, 5)); // "age"
305    }
306
307    #[test]
308    fn span_highlights_yaml_key() {
309        let content = "name: hello\nage: 30\n";
310        assert_eq!(find_instance_path_span(content, "/name"), (0, 4)); // name
311        assert_eq!(find_instance_path_span(content, "/age"), (12, 3)); // age
312    }
313
314    #[test]
315    fn span_highlights_quoted_yaml_key() {
316        let content = "\"on\": push\n";
317        assert_eq!(find_instance_path_span(content, "/on"), (0, 4)); // "on"
318    }
319
320    // --- Error code tests ---
321
322    #[test]
323    fn error_codes() {
324        use miette::Diagnostic;
325
326        let cases: Vec<(LintError, &str)> = vec![
327            (
328                LintError::Parse {
329                    src: NamedSource::new("f", String::new()),
330                    span: 0.into(),
331                    message: String::new(),
332                },
333                "lintel::parse",
334            ),
335            (
336                LintError::Validation {
337                    src: NamedSource::new("f", String::new()),
338                    span: 0.into(),
339                    schema_span: 0.into(),
340                    path: String::new(),
341                    instance_path: String::new(),
342                    label: String::new(),
343                    message: String::new(),
344                    schema_url: String::new(),
345                    schema_path: String::new(),
346                },
347                "lintel::validation",
348            ),
349            (
350                LintError::SchemaMismatch {
351                    path: String::new(),
352                    line_number: 0,
353                    message: String::new(),
354                },
355                "lintel::jsonl::schema_mismatch",
356            ),
357            (
358                LintError::Io {
359                    path: String::new(),
360                    message: String::new(),
361                },
362                "lintel::io",
363            ),
364            (
365                LintError::SchemaFetch {
366                    path: String::new(),
367                    message: String::new(),
368                },
369                "lintel::schema::fetch",
370            ),
371            (
372                LintError::SchemaCompile {
373                    path: String::new(),
374                    message: String::new(),
375                },
376                "lintel::schema::compile",
377            ),
378        ];
379
380        for (error, expected_code) in cases {
381            assert_eq!(
382                error.code().expect("missing diagnostic code").to_string(),
383                expected_code,
384                "wrong code for {error:?}"
385            );
386        }
387    }
388
389    // --- format_label tests ---
390
391    #[test]
392    fn format_label_with_schema_path() {
393        assert_eq!(
394            format_label(
395                "/jobs/build",
396                "/properties/jobs/patternProperties/^[_a-zA-Z][a-zA-Z0-9_-]*$/oneOf"
397            ),
398            "/jobs/build in /properties/jobs/patternProperties/^[_a-zA-Z][a-zA-Z0-9_-]*$/oneOf"
399        );
400    }
401
402    #[test]
403    fn format_label_empty_schema_path() {
404        assert_eq!(format_label("/name", ""), "/name");
405    }
406}