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> {
101 let len = source.len();
102 let start = range.start.min(len);
103 let end = range.end.min(len).max(start);
104 start..end
105}
106
107pub(crate) fn to_positional(error: &CliError) -> Vec<PositionalDiagnostic> {
112 match error {
113 CliError::Project(ProjectError::Parse(pe)) => parse_to_positional(pe),
114 CliError::Project(ProjectError::Validation(ves)) => validation_to_positional(ves),
115 CliError::TypeCheckFailed(tce) => typecheck_to_positional(tce),
116 _ => Vec::new(),
117 }
118}
119
120fn parse_to_positional(error: &ParseError) -> Vec<PositionalDiagnostic> {
121 match error {
122 ParseError::SqlParseFailed { path, sql, source } => vec![PositionalDiagnostic {
123 severity: Severity::Error,
124 file: path.clone(),
125 source: sql.clone(),
126 byte_range: source.error.pos..source.error.pos,
127 message: source.error.message.clone(),
128 footers: Vec::new(),
129 suggestions: Vec::new(),
130 }],
131 ParseError::UnresolvedVariables(ve) => unresolved_variables_to_positional(ve),
132 ParseError::StatementsParseFailed { .. } => Vec::new(),
133 }
134}
135
136fn unresolved_variables_to_positional(
141 error: &crate::project::syntax::variables::VariableError,
142) -> Vec<PositionalDiagnostic> {
143 let source = std::fs::read_to_string(&error.path).unwrap_or_default();
144 let footer = if error.profile_set {
145 "define this variable in [<profile>.variables] in project.toml".to_string()
146 } else {
147 "no profile is selected; run `mz-deploy profile set <name>` and define \
148 this variable in [<profile>.variables] in project.toml"
149 .to_string()
150 };
151 error
152 .unresolved
153 .iter()
154 .map(|uv| PositionalDiagnostic {
155 severity: Severity::Error,
156 file: error.path.clone(),
157 source: source.clone(),
158 byte_range: uv.byte_offset..(uv.byte_offset + uv.byte_len),
159 message: format!("undefined variable ':{}'", uv.name),
160 footers: vec![footer.clone()],
161 suggestions: Vec::new(),
162 })
163 .collect()
164}
165
166fn validation_to_positional(errors: &ValidationErrors) -> Vec<PositionalDiagnostic> {
167 errors
168 .errors
169 .iter()
170 .map(validation_error_to_positional)
171 .collect()
172}
173
174fn validation_error_to_positional(error: &ValidationError) -> PositionalDiagnostic {
175 let file = error.context.file.clone();
176
177 if let Ok(source) = std::fs::read_to_string(&file) {
178 let offset = error.context.byte_offset.unwrap_or(0);
179 let primary_range =
180 crate::diagnostics::locate_validation(&error.kind, &source, Some(offset))
181 .unwrap_or(offset..offset);
182 let (message, footers, suggestions) =
183 crate::diagnostics::format_validation_kind(&error.kind, &source, &primary_range);
184 return PositionalDiagnostic {
185 severity: Severity::Error,
186 file,
187 source,
188 byte_range: primary_range,
189 message,
190 footers,
191 suggestions,
192 };
193 }
194
195 PositionalDiagnostic {
196 severity: Severity::Error,
197 file,
198 source: error.context.sql_statement.clone().unwrap_or_default(),
199 byte_range: 0..0,
200 message: error.kind.message(),
201 footers: error.kind.help().into_iter().collect(),
202 suggestions: Vec::new(),
203 }
204}
205
206fn typecheck_to_positional(error: &TypeCheckError) -> Vec<PositionalDiagnostic> {
207 let errors: Vec<&ObjectTypeCheckError> = match error {
208 TypeCheckError::Multiple(es) => es.iter().collect(),
209 TypeCheckError::DatabaseSetupError(_)
210 | TypeCheckError::SortError(_)
211 | TypeCheckError::TypesCacheWriteFailed(_) => return Vec::new(),
212 };
213
214 errors
215 .iter()
216 .map(|e| object_typecheck_to_positional(e))
217 .collect()
218}
219
220fn object_typecheck_to_positional(error: &ObjectTypeCheckError) -> PositionalDiagnostic {
221 let source = std::fs::read_to_string(&error.file_path).unwrap_or_default();
222 let primary_range = crate::diagnostics::locate_typecheck(&error.kind, &source).unwrap_or(0..0);
223
224 let (message, footers, suggestions) =
225 crate::diagnostics::format_typecheck_kind(&error.kind, &source, &primary_range);
226
227 let mut full_message = message;
228 if let Some(detail) = error.detail() {
229 full_message.push_str("\ndetail: ");
230 full_message.push_str(&detail);
231 }
232
233 PositionalDiagnostic {
234 severity: Severity::Error,
235 file: error.file_path.clone(),
236 source,
237 byte_range: primary_range,
238 message: full_message,
239 footers,
240 suggestions,
241 }
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247 use std::path::PathBuf;
248
249 fn pd(source: &str, range: std::ops::Range<usize>, message: &str) -> PositionalDiagnostic {
250 PositionalDiagnostic {
251 severity: Severity::Error,
252 file: PathBuf::from("test.sql"),
253 source: source.to_string(),
254 byte_range: range,
255 message: message.to_string(),
256 footers: Vec::new(),
257 suggestions: Vec::new(),
258 }
259 }
260
261 #[mz_ore::test]
262 fn render_includes_message_and_origin() {
263 let out = render(&pd("SELECT bogus", 7..12, "unknown column"));
264 assert!(out.contains("unknown column"));
265 assert!(out.contains("test.sql"));
266 }
267
268 #[mz_ore::test]
269 fn render_message_only_when_source_empty() {
270 let out = render(&pd("", 0..0, "missing CREATE statement"));
271 assert!(out.contains("missing CREATE statement"));
272 assert!(!out.contains("test.sql"));
274 }
275
276 #[mz_ore::test]
277 fn render_with_footer() {
278 let mut diag = pd("SELECT 1", 7..8, "type mismatch");
279 diag.footers.push("convert with CAST".to_string());
280 let out = render(&diag);
281 assert!(out.contains("type mismatch"));
282 assert!(out.contains("convert with CAST"));
283 }
284
285 #[mz_ore::test]
286 fn clamped_range_caps_at_source_len() {
287 let diag = pd("abc", 100..200, "out of range");
288 assert_eq!(clamped_range(&diag), 3..3);
289 }
290
291 #[mz_ore::test]
292 fn clamped_range_preserves_in_bounds() {
293 let diag = pd("abcdef", 1..4, "ok");
294 assert_eq!(clamped_range(&diag), 1..4);
295 }
296
297 #[mz_ore::test]
298 fn origin_string_strips_curdir() {
299 assert_eq!(
300 origin_string(std::path::Path::new("././models/app/foo.sql")),
301 "models/app/foo.sql"
302 );
303 }
304
305 #[mz_ore::test]
306 fn origin_string_preserves_absolute() {
307 assert_eq!(
308 origin_string(std::path::Path::new("/abs/models/foo.sql")),
309 "/abs/models/foo.sql"
310 );
311 }
312
313 #[mz_ore::test]
314 fn origin_string_preserves_bare_curdir() {
315 assert_eq!(origin_string(std::path::Path::new(".")), ".");
316 }
317}