Skip to main content

mz_deploy/cli/
render.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10//! Rich CLI rendering for [`PositionalDiagnostic`]s.
11//!
12//! [`render`] turns one diagnostic into a styled [`annotate_snippets`] string.
13//! [`to_positional`] inspects a [`CliError`] and pulls out any positional
14//! diagnostics it carries so `display_error` can render rustc-quality output:
15//! caret under the offending token, file/line origin, plain `help:` footers,
16//! and `did you mean` patches that show the suggested replacement inline.
17//!
18//! Errors that don't carry source positions (configuration errors, network
19//! failures, etc.) return an empty `Vec`; the caller falls back to the plain
20//! [`std::fmt::Display`] path.
21
22use 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
29/// Render a single [`PositionalDiagnostic`] to a styled string.
30///
31/// Includes the primary annotated snippet, plain footers, and any
32/// structured replacement suggestions as inline `did you mean` patches.
33pub(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
80/// Render `path` as a snippet origin, dropping redundant `./` components
81/// so paths like `././models/foo.sql` print as `models/foo.sql`.
82fn 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
94/// Clamp the byte range to `[0, source.len()]` so an out-of-bounds offset
95/// (e.g. a parser pos past EOF) doesn't panic inside annotate-snippets.
96fn 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
107/// Extract any positional diagnostics carried by `error`.
108///
109/// Returns an empty `Vec` for errors that don't reference SQL source — those
110/// fall back to plain [`std::fmt::Display`] rendering at the call site.
111pub(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
136/// One [`PositionalDiagnostic`] per unresolved variable, pointed at its
137/// reference in the source. The hint footer differs based on whether a
138/// profile is active: with no profile, it directs the user to set one;
139/// otherwise, it points at the profile's `[variables]` table.
140fn 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        // No snippet block → no origin pointer.
273        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}