Skip to main content

lintel_check/
diagnostics.rs

1use miette::{Diagnostic, LabeledSpan, NamedSource, SourceSpan};
2use thiserror::Error;
3
4/// A parse error with exact source location.
5#[derive(Debug, Error, Diagnostic)]
6#[error("{message}")]
7pub struct ParseDiagnostic {
8    #[source_code]
9    pub src: NamedSource<String>,
10
11    #[label("here")]
12    pub span: SourceSpan,
13
14    pub message: String,
15}
16
17/// A schema validation error for a specific file.
18#[derive(Debug, Error)]
19#[error("{message}")]
20pub struct ValidationDiagnostic {
21    pub src: NamedSource<String>,
22
23    pub span: SourceSpan,
24
25    pub path: String,
26
27    pub instance_path: String,
28
29    pub message: String,
30}
31
32impl Diagnostic for ValidationDiagnostic {
33    fn source_code(&self) -> Option<&dyn miette::SourceCode> {
34        Some(&self.src)
35    }
36
37    fn labels(&self) -> Option<Box<dyn Iterator<Item = LabeledSpan> + '_>> {
38        let label = if self.instance_path.is_empty() {
39            "here".to_string()
40        } else {
41            self.instance_path.clone()
42        };
43        Some(Box::new(std::iter::once(LabeledSpan::new(
44            Some(label),
45            self.span.offset(),
46            self.span.len(),
47        ))))
48    }
49}
50
51/// An I/O or schema-fetch error associated with a file path.
52#[derive(Debug, Error, Diagnostic)]
53#[error("{path}: {message}")]
54pub struct FileDiagnostic {
55    pub path: String,
56    pub message: String,
57}
58
59/// Convert a byte offset into 1-based (line, column).
60///
61/// Returns `(1, 1)` if the offset is 0 or the content is empty.
62pub fn offset_to_line_col(content: &str, offset: usize) -> (usize, usize) {
63    let offset = offset.min(content.len());
64    let mut line = 1;
65    let mut col = 1;
66    for (i, ch) in content.char_indices() {
67        if i >= offset {
68            break;
69        }
70        if ch == '\n' {
71            line += 1;
72            col = 1;
73        } else {
74            col += 1;
75        }
76    }
77    (line, col)
78}
79
80/// Try to find the byte offset of a JSON pointer path segment in the source text.
81///
82/// For an `instance_path` like `/properties/name`, searches for the last segment `name`
83/// as a JSON key (`"name"`) or YAML key (`name:`). Falls back to 0 if not found.
84pub fn find_instance_path_offset(content: &str, instance_path: &str) -> usize {
85    if instance_path.is_empty() || instance_path == "/" {
86        return 0;
87    }
88
89    // Get the last path segment (e.g., "/foo/bar/baz" -> "baz")
90    let segment = instance_path.rsplit('/').next().unwrap_or("");
91    if segment.is_empty() {
92        return 0;
93    }
94
95    // Try JSON-style key: "segment"
96    let json_key = format!("\"{segment}\"");
97    if let Some(pos) = content.find(&json_key) {
98        return pos;
99    }
100
101    // Try YAML-style key: segment: (at line start or after whitespace)
102    let yaml_key = format!("{segment}:");
103    let quoted_yaml_key = format!("\"{segment}\":");
104    let mut offset = 0;
105    for line in content.lines() {
106        let trimmed = line.trim_start();
107        if trimmed.starts_with(&yaml_key) || trimmed.starts_with(&quoted_yaml_key) {
108            let key_start = line.len() - trimmed.len();
109            return offset + key_start;
110        }
111        offset += line.len() + 1; // +1 for newline
112    }
113
114    0
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn offset_zero_returns_line_one_col_one() {
123        assert_eq!(offset_to_line_col("hello", 0), (1, 1));
124    }
125
126    #[test]
127    fn offset_within_first_line() {
128        assert_eq!(offset_to_line_col("hello world", 5), (1, 6));
129    }
130
131    #[test]
132    fn offset_at_second_line() {
133        assert_eq!(offset_to_line_col("ab\ncd\nef", 3), (2, 1));
134    }
135
136    #[test]
137    fn offset_middle_of_second_line() {
138        assert_eq!(offset_to_line_col("ab\ncd\nef", 4), (2, 2));
139    }
140
141    #[test]
142    fn offset_at_third_line() {
143        assert_eq!(offset_to_line_col("ab\ncd\nef", 6), (3, 1));
144    }
145
146    #[test]
147    fn offset_past_end_clamps() {
148        assert_eq!(offset_to_line_col("ab\ncd", 100), (2, 3));
149    }
150
151    #[test]
152    fn empty_content() {
153        assert_eq!(offset_to_line_col("", 0), (1, 1));
154    }
155}