1use crate::cli::CliError;
23use crate::diagnostics::{PositionalDiagnostic, Severity};
24use crate::log::color_enabled;
25use crate::project::compiler::typecheck::{ObjectTypeCheckError, TypeCheckError};
26use crate::project::error::{ParseError, ProjectError, ValidationError, ValidationErrors};
27use annotate_snippets::{AnnotationKind, Group, Level, Patch, Renderer, Snippet, Title};
28
29pub(crate) fn render(pd: &PositionalDiagnostic) -> String {
34 let level = match pd.severity {
35 Severity::Error => Level::ERROR,
36 Severity::Warning => Level::WARNING,
37 };
38 let origin = origin_string(&pd.file);
39
40 let mut groups: Vec<Group<'_>> = Vec::new();
41 let primary_title: Title<'_> = level.primary_title(pd.message.as_str());
42 let primary_group = if pd.source.is_empty() {
43 Group::with_title(primary_title)
44 } else {
45 primary_title.element(
46 Snippet::source(&pd.source)
47 .path(origin.as_str())
48 .annotation(AnnotationKind::Primary.span(clamped_range(pd))),
49 )
50 };
51 groups.push(primary_group);
52
53 for footer in &pd.footers {
54 groups.push(Group::with_title(
55 Level::HELP.secondary_title(footer.as_str()),
56 ));
57 }
58
59 for s in &pd.suggestions {
60 if s.alternatives.is_empty() {
61 continue;
62 }
63 let mut group = Group::with_title(Level::HELP.secondary_title(s.label.as_str()));
64 for alt in &s.alternatives {
65 group = group.element(Snippet::source(&pd.source).path(origin.as_str()).patch(
66 Patch::new(clamp(&pd.source, &alt.byte_range), alt.replacement.as_str()),
67 ));
68 }
69 groups.push(group);
70 }
71
72 let renderer = if color_enabled() {
73 Renderer::styled()
74 } else {
75 Renderer::plain()
76 };
77 renderer.render(&groups[..]).to_string()
78}
79
80fn origin_string(path: &std::path::Path) -> String {
83 let trimmed: std::path::PathBuf = path
84 .components()
85 .filter(|c| !matches!(c, std::path::Component::CurDir))
86 .collect();
87 if trimmed.as_os_str().is_empty() {
88 path.display().to_string()
89 } else {
90 trimmed.display().to_string()
91 }
92}
93
94fn clamped_range(pd: &PositionalDiagnostic) -> std::ops::Range<usize> {
97 clamp(&pd.source, &pd.byte_range)
98}
99
100fn clamp(source: &str, range: &std::ops::Range<usize>) -> std::ops::Range<usize> {
106 let len = source.len();
107 let mut start = range.start.min(len);
108 let mut end = range.end.min(len).max(start);
109 while start > 0 && !source.is_char_boundary(start) {
110 start -= 1;
111 }
112 while end < len && !source.is_char_boundary(end) {
113 end += 1;
114 }
115 start..end
116}
117
118pub(crate) fn to_positional(error: &CliError) -> Vec<PositionalDiagnostic> {
123 match error {
124 CliError::Project(ProjectError::Parse(pe)) => parse_to_positional(pe),
125 CliError::Project(ProjectError::Validation(ves)) => validation_to_positional(ves),
126 CliError::TypeCheckFailed(tce) => typecheck_to_positional(tce),
127 _ => Vec::new(),
128 }
129}
130
131fn parse_to_positional(error: &ParseError) -> Vec<PositionalDiagnostic> {
132 match error {
133 ParseError::SqlParseFailed { path, sql, source } => vec![PositionalDiagnostic {
134 severity: Severity::Error,
135 file: path.clone(),
136 source: sql.clone(),
137 byte_range: source.error.pos..source.error.pos,
138 message: source.error.message.clone(),
139 footers: Vec::new(),
140 suggestions: Vec::new(),
141 }],
142 ParseError::UnresolvedVariables(ve) => unresolved_variables_to_positional(ve),
143 ParseError::StatementsParseFailed { .. } => Vec::new(),
144 }
145}
146
147fn unresolved_variables_to_positional(
152 error: &crate::project::syntax::variables::VariableError,
153) -> Vec<PositionalDiagnostic> {
154 let source = std::fs::read_to_string(&error.path).unwrap_or_default();
155 let footer = if error.profile_set {
156 "define this variable in [<profile>.variables] in project.toml".to_string()
157 } else {
158 "no profile is selected; run `mz-deploy profile set <name>` and define \
159 this variable in [<profile>.variables] in project.toml"
160 .to_string()
161 };
162 error
163 .unresolved
164 .iter()
165 .map(|uv| PositionalDiagnostic {
166 severity: Severity::Error,
167 file: error.path.clone(),
168 source: source.clone(),
169 byte_range: uv.byte_offset..(uv.byte_offset + uv.byte_len),
170 message: format!("undefined variable ':{}'", uv.name),
171 footers: vec![footer.clone()],
172 suggestions: Vec::new(),
173 })
174 .collect()
175}
176
177fn validation_to_positional(errors: &ValidationErrors) -> Vec<PositionalDiagnostic> {
178 errors
179 .errors
180 .iter()
181 .map(validation_error_to_positional)
182 .collect()
183}
184
185fn validation_error_to_positional(error: &ValidationError) -> PositionalDiagnostic {
186 let file = error.context.file.clone();
187
188 if let Ok(source) = std::fs::read_to_string(&file) {
189 let offset = error.context.byte_offset.unwrap_or(0);
190 let primary_range =
191 crate::diagnostics::locate_validation(&error.kind, &source, Some(offset))
192 .unwrap_or(offset..offset);
193 let (message, footers, suggestions) =
194 crate::diagnostics::format_validation_kind(&error.kind, &source, &primary_range);
195 return PositionalDiagnostic {
196 severity: Severity::Error,
197 file,
198 source,
199 byte_range: primary_range,
200 message,
201 footers,
202 suggestions,
203 };
204 }
205
206 PositionalDiagnostic {
207 severity: Severity::Error,
208 file,
209 source: error.context.sql_statement.clone().unwrap_or_default(),
210 byte_range: 0..0,
211 message: error.kind.message(),
212 footers: error.kind.help().into_iter().collect(),
213 suggestions: Vec::new(),
214 }
215}
216
217fn typecheck_to_positional(error: &TypeCheckError) -> Vec<PositionalDiagnostic> {
218 let errors: Vec<&ObjectTypeCheckError> = match error {
219 TypeCheckError::Multiple(es) => es.iter().collect(),
220 TypeCheckError::DatabaseSetupError(_)
221 | TypeCheckError::SortError(_)
222 | TypeCheckError::TypesCacheWriteFailed(_) => return Vec::new(),
223 };
224
225 errors
226 .iter()
227 .map(|e| object_typecheck_to_positional(e))
228 .collect()
229}
230
231fn object_typecheck_to_positional(error: &ObjectTypeCheckError) -> PositionalDiagnostic {
232 let source = std::fs::read_to_string(&error.file_path).unwrap_or_default();
233 let primary_range = crate::diagnostics::locate_typecheck(&error.kind, &source).unwrap_or(0..0);
234
235 let (message, footers, suggestions) =
236 crate::diagnostics::format_typecheck_kind(&error.kind, &source, &primary_range);
237
238 let mut full_message = message;
239 if let Some(detail) = error.detail() {
240 full_message.push_str("\ndetail: ");
241 full_message.push_str(&detail);
242 }
243
244 PositionalDiagnostic {
245 severity: Severity::Error,
246 file: error.file_path.clone(),
247 source,
248 byte_range: primary_range,
249 message: full_message,
250 footers,
251 suggestions,
252 }
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258 use std::path::PathBuf;
259
260 fn pd(source: &str, range: std::ops::Range<usize>, message: &str) -> PositionalDiagnostic {
261 PositionalDiagnostic {
262 severity: Severity::Error,
263 file: PathBuf::from("test.sql"),
264 source: source.to_string(),
265 byte_range: range,
266 message: message.to_string(),
267 footers: Vec::new(),
268 suggestions: Vec::new(),
269 }
270 }
271
272 #[mz_ore::test]
273 fn render_includes_message_and_origin() {
274 let out = render(&pd("SELECT bogus", 7..12, "unknown column"));
275 assert!(out.contains("unknown column"));
276 assert!(out.contains("test.sql"));
277 }
278
279 #[mz_ore::test]
280 fn render_message_only_when_source_empty() {
281 let out = render(&pd("", 0..0, "missing CREATE statement"));
282 assert!(out.contains("missing CREATE statement"));
283 assert!(!out.contains("test.sql"));
285 }
286
287 #[mz_ore::test]
288 fn render_with_footer() {
289 let mut diag = pd("SELECT 1", 7..8, "type mismatch");
290 diag.footers.push("convert with CAST".to_string());
291 let out = render(&diag);
292 assert!(out.contains("type mismatch"));
293 assert!(out.contains("convert with CAST"));
294 }
295
296 #[mz_ore::test]
297 fn clamped_range_caps_at_source_len() {
298 let diag = pd("abc", 100..200, "out of range");
299 assert_eq!(clamped_range(&diag), 3..3);
300 }
301
302 #[mz_ore::test]
303 fn clamped_range_preserves_in_bounds() {
304 let diag = pd("abcdef", 1..4, "ok");
305 assert_eq!(clamped_range(&diag), 1..4);
306 }
307
308 #[mz_ore::test]
309 fn clamp_snaps_to_char_boundary() {
310 assert_eq!(clamp("café", &(4..4)), 3..5);
312 assert_eq!(clamp("café", &(0..3)), 0..3);
313 }
314
315 #[mz_ore::test]
316 fn render_handles_multibyte_span() {
317 let out = render(&pd("café", 4..4, "unexpected token"));
318 assert!(out.contains("unexpected token"));
319 }
320
321 #[mz_ore::test]
322 fn origin_string_strips_curdir() {
323 assert_eq!(
324 origin_string(std::path::Path::new("././models/app/foo.sql")),
325 "models/app/foo.sql"
326 );
327 }
328
329 #[mz_ore::test]
330 fn origin_string_preserves_absolute() {
331 assert_eq!(
332 origin_string(std::path::Path::new("/abs/models/foo.sql")),
333 "/abs/models/foo.sql"
334 );
335 }
336
337 #[mz_ore::test]
338 fn origin_string_preserves_bare_curdir() {
339 assert_eq!(origin_string(std::path::Path::new(".")), ".");
340 }
341}