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
100/// Clamp `range` to `[0, source.len()]` and snap its endpoints outward to the
101/// enclosing char boundaries (start down, end up).
102///
103/// annotate-snippets slices `source` at these byte offsets and panics on an
104/// offset that is out of bounds or falls inside a multi-byte char.
105fn 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
118/// Extract any positional diagnostics carried by `error`.
119///
120/// Returns an empty `Vec` for errors that don't reference SQL source — those
121/// fall back to plain [`std::fmt::Display`] rendering at the call site.
122pub(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
147/// One [`PositionalDiagnostic`] per unresolved variable, pointed at its
148/// reference in the source. The hint footer differs based on whether a
149/// profile is active: with no profile, it directs the user to set one;
150/// otherwise, it points at the profile's `[variables]` table.
151fn 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        // No snippet block → no origin pointer.
284        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        // `é` occupies bytes 3..5 of "café", so 4 is mid-char.
311        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}