Skip to main content

omnigraph_compiler/
error.rs

1use thiserror::Error;
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4pub struct SourceSpan {
5    pub start: usize,
6    pub end: usize,
7}
8
9impl SourceSpan {
10    pub fn new(start: usize, end: usize) -> Self {
11        Self { start, end }
12    }
13}
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct ParseDiagnostic {
17    pub message: String,
18    pub span: Option<SourceSpan>,
19}
20
21impl ParseDiagnostic {
22    pub fn new(message: String, span: Option<SourceSpan>) -> Self {
23        Self { message, span }
24    }
25}
26
27impl std::fmt::Display for ParseDiagnostic {
28    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29        write!(f, "{}", self.message)
30    }
31}
32
33impl std::error::Error for ParseDiagnostic {}
34
35pub fn render_span(span: SourceSpan) -> SourceSpan {
36    SourceSpan {
37        start: span.start,
38        end: span.end.max(span.start.saturating_add(1)),
39    }
40}
41
42pub fn decode_string_literal(raw: &str) -> Result<String> {
43    let inner = raw
44        .strip_prefix('"')
45        .and_then(|inner| inner.strip_suffix('"'))
46        .unwrap_or(raw);
47
48    let mut decoded = String::with_capacity(inner.len());
49    let mut chars = inner.chars();
50    while let Some(ch) = chars.next() {
51        if ch != '\\' {
52            decoded.push(ch);
53            continue;
54        }
55
56        let escaped = chars
57            .next()
58            .ok_or_else(|| CompilerError::Parse("unterminated escape sequence".to_string()))?;
59        match escaped {
60            '"' => decoded.push('"'),
61            '\\' => decoded.push('\\'),
62            'n' => decoded.push('\n'),
63            'r' => decoded.push('\r'),
64            't' => decoded.push('\t'),
65            other => {
66                return Err(CompilerError::Parse(format!(
67                    "unsupported escape sequence: \\{}",
68                    other
69                )));
70            }
71        }
72    }
73
74    Ok(decoded)
75}
76
77#[derive(Debug, Error)]
78pub enum CompilerError {
79    #[error("parse error: {0}")]
80    Parse(String),
81
82    #[error("catalog error: {0}")]
83    Catalog(String),
84
85    #[error("type error: {0}")]
86    Type(String),
87
88    #[error("storage error: {0}")]
89    Storage(String),
90
91    #[error(
92        "@unique constraint violation on {type_name}.{property}: duplicate value '{value}' at rows {first_row} and {second_row}"
93    )]
94    UniqueConstraint {
95        type_name: String,
96        property: String,
97        value: String,
98        first_row: usize,
99        second_row: usize,
100    },
101
102    #[error("plan error: {0}")]
103    Plan(String),
104
105    #[error("execution error: {0}")]
106    Execution(String),
107
108    #[error(transparent)]
109    Arrow(#[from] arrow_schema::ArrowError),
110
111    #[error("io error: {0}")]
112    Io(#[from] std::io::Error),
113
114    #[error("lance error: {0}")]
115    Lance(String),
116
117    #[error("manifest error: {0}")]
118    Manifest(String),
119}
120
121#[deprecated(note = "use CompilerError")]
122pub type NanoError = CompilerError;
123
124pub type Result<T> = std::result::Result<T, CompilerError>;
125
126#[cfg(test)]
127mod tests {
128    use std::path::Path;
129
130    use super::{CompilerError, SourceSpan, decode_string_literal, render_span};
131
132    #[test]
133    fn source_span_preserves_zero_width() {
134        let span = SourceSpan::new(7, 7);
135        assert_eq!(span.start, 7);
136        assert_eq!(span.end, 7);
137    }
138
139    #[test]
140    fn render_span_widens_zero_width_for_diagnostics() {
141        let rendered = render_span(SourceSpan::new(7, 7));
142        assert_eq!(rendered.start, 7);
143        assert_eq!(rendered.end, 8);
144    }
145
146    #[test]
147    fn decode_string_literal_supports_common_escapes() {
148        let decoded = decode_string_literal("\"a\\n\\r\\t\\\\\\\"b\"").unwrap();
149        assert_eq!(decoded, "a\n\r\t\\\"b");
150    }
151
152    #[test]
153    fn compiler_error_parse_display_is_stable() {
154        let err = CompilerError::Parse("bad token".to_string());
155        assert_eq!(err.to_string(), "parse error: bad token");
156    }
157
158    #[allow(deprecated)]
159    #[test]
160    fn legacy_nano_error_alias_constructs_variants() {
161        let err = super::NanoError::Parse("bad token".to_string());
162        assert_eq!(err.to_string(), "parse error: bad token");
163    }
164
165    #[test]
166    fn legacy_name_is_confined_to_alias_and_compatibility_test() {
167        let legacy_name = ["Nano", "Error"].concat();
168        let workspace_root = Path::new(env!("CARGO_MANIFEST_DIR"))
169            .parent()
170            .and_then(Path::parent)
171            .expect("compiler crate should live under crates/");
172        let allowed_file = workspace_root.join("crates/omnigraph-compiler/src/error.rs");
173        let mut offenders = Vec::new();
174
175        visit_rs_files(workspace_root, &mut |path| {
176            let text = std::fs::read_to_string(path).expect("source file should be readable");
177            let count = text.matches(&legacy_name).count();
178            if path == allowed_file {
179                if count != 2 {
180                    offenders.push(format!(
181                        "{} contains {count} legacy-name occurrences; expected exactly 2",
182                        display_path(workspace_root, path)
183                    ));
184                }
185            } else if count > 0 {
186                offenders.push(format!(
187                    "{} contains {count} legacy-name occurrence(s)",
188                    display_path(workspace_root, path)
189                ));
190            }
191        });
192
193        assert!(
194            offenders.is_empty(),
195            "legacy compiler error name should stay compatibility-only:\n{}",
196            offenders.join("\n")
197        );
198    }
199
200    fn visit_rs_files(dir: &Path, visit: &mut impl FnMut(&Path)) {
201        for entry in std::fs::read_dir(dir).expect("source directory should be readable") {
202            let entry = entry.expect("source entry should be readable");
203            let path = entry.path();
204            if path.is_dir() {
205                if matches!(
206                    path.file_name().and_then(|name| name.to_str()),
207                    Some(".git" | "target")
208                ) {
209                    continue;
210                }
211                visit_rs_files(&path, visit);
212            } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
213                visit(&path);
214            }
215        }
216    }
217
218    fn display_path(root: &Path, path: &Path) -> String {
219        path.strip_prefix(root)
220            .unwrap_or(path)
221            .to_string_lossy()
222            .into_owned()
223    }
224}