Skip to main content

mz_deploy/lsp/
diagnostics.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//! LSP-specific diagnostic emission.
11//!
12//! Each producer in this module first builds a
13//! `PositionalDiagnostic` using the shared locator
14//! helpers, then converts it to a [`tower_lsp::lsp_types::Diagnostic`] via
15//! `to_lsp` using a [`Rope`] for byte-offset → line/column conversion.
16//!
17//! Three tiers of diagnostics:
18//!
19//! - **Per-keystroke diagnostics** ([`diagnose()`]) — Resolves psql-style
20//!   variables before parsing. Unresolved variables produce positioned
21//!   diagnostics (ERROR or WARNING depending on the warn pragma). The resolved
22//!   SQL is then parsed with [`mz_sql_parser::parser::parse_statements()`] and
23//!   any parse error positions are mapped back to original-text offsets via
24//!   `resolved_to_original`.
25//!
26//! - **On-save validation errors** (`validation_diagnostics()`) — Converts
27//!   project-level `ValidationError`s into LSP diagnostics grouped by file.
28//!   When an error carries a byte offset (most statement-level errors), the
29//!   diagnostic is positioned at the correct line/column. File-level errors
30//!   (e.g., missing CREATE statement) fall back to `(0, 0)`.
31//!
32//! - **On-save typecheck errors** (`typecheck_diagnostics()`) — Inspects
33//!   the structured upstream error to position the diagnostic. See
34//!   `locate_typecheck` for the dispatch.
35
36use crate::diagnostics::{PositionalDiagnostic, Severity, Suggestion, locate_typecheck};
37use crate::fs::FileSystem;
38use crate::project::compiler::typecheck::{ObjectTypeCheckError, TypeCheckError};
39use crate::project::error::ValidationError;
40use crate::project::syntax::variables::{resolve_variables, resolved_to_original};
41use ropey::Rope;
42use std::collections::BTreeMap;
43use std::path::{Path, PathBuf};
44use tower_lsp::lsp_types::{Diagnostic, DiagnosticSeverity, Position, Range};
45
46/// Parse `text` as SQL and return diagnostics for any parse errors and variable issues.
47///
48/// Resolves psql-style variables before parsing. Unresolved variables produce
49/// diagnostics at their position in the original text. Parse errors from the
50/// resolved SQL are mapped back to original-text positions via the substitution log.
51///
52/// # Arguments
53/// * `text` — The original SQL source text (as the editor shows it).
54/// * `rope` — A [`Rope`] built from the same `text`, used for byte-offset to
55///   line/column conversion.
56/// * `variables` — Variable definitions from the project config.
57/// * `profile_name` — Active profile name (if any), shown in undefined-variable
58///   messages. When `None`, the diagnostic suggests setting a profile.
59///
60/// # Returns
61/// A (possibly empty) vec of LSP diagnostics. Variable diagnostics are
62/// `WARNING` if the file has the warn pragma, `ERROR` otherwise. Parse errors
63/// are always `ERROR`.
64pub fn diagnose(
65    text: &str,
66    rope: &Rope,
67    variables: &BTreeMap<String, String>,
68    profile_name: Option<&str>,
69) -> Vec<Diagnostic> {
70    if text.trim().is_empty() {
71        return Vec::new();
72    }
73
74    parse_positional(text, variables, profile_name)
75        .iter()
76        .map(|pd| to_lsp(pd, rope))
77        .collect()
78}
79
80/// Build [`PositionalDiagnostic`]s for parse errors and unresolved variables
81/// in `text`. All positions are in original-text byte space (post-substitution
82/// parser offsets are mapped back via `resolved_to_original`).
83fn parse_positional(
84    text: &str,
85    variables: &BTreeMap<String, String>,
86    profile_name: Option<&str>,
87) -> Vec<PositionalDiagnostic> {
88    let resolved = resolve_variables(text, variables);
89    let mut pds = Vec::new();
90
91    let var_severity = if resolved.has_warn_pragma {
92        Severity::Warning
93    } else {
94        Severity::Error
95    };
96    for uv in &resolved.unresolved {
97        let message = match profile_name {
98            Some(name) => format!(
99                "undefined variable ':{}'  — define in [{}.variables] in project.toml",
100                uv.name, name
101            ),
102            None => format!(
103                "undefined variable ':{}'  — no profile is selected; run `mz-deploy profile set <name>` and define in [<profile>.variables] in project.toml",
104                uv.name
105            ),
106        };
107        pds.push(PositionalDiagnostic {
108            severity: var_severity,
109            file: PathBuf::new(),
110            source: text.to_string(),
111            byte_range: uv.byte_offset..(uv.byte_offset + uv.byte_len),
112            message,
113            footers: Vec::new(),
114            suggestions: Vec::new(),
115        });
116    }
117
118    if let Err(e) = mz_sql_parser::parser::parse_statements(&resolved.sql) {
119        let original_offset = resolved_to_original(e.error.pos, &resolved.substitutions);
120        pds.push(PositionalDiagnostic {
121            severity: Severity::Error,
122            file: PathBuf::new(),
123            source: text.to_string(),
124            byte_range: original_offset..original_offset,
125            message: e.error.message.clone(),
126            footers: Vec::new(),
127            suggestions: Vec::new(),
128        });
129    }
130
131    pds
132}
133
134/// Convert `ValidationError`s into LSP diagnostics grouped by file path.
135///
136/// When an error carries a `byte_offset`, the file is read and a [`Rope`] is
137/// built so the offset can be converted to a precise line/column position.
138/// Errors without an offset (file-level) fall back to `(0, 0)`.
139///
140/// Returns an empty map when `errors` is empty.
141pub(crate) fn validation_diagnostics(
142    fs: &FileSystem,
143    errors: &[ValidationError],
144) -> BTreeMap<PathBuf, Vec<Diagnostic>> {
145    let mut map: BTreeMap<PathBuf, Vec<Diagnostic>> = BTreeMap::new();
146    let mut source_cache: BTreeMap<PathBuf, Option<(String, Rope)>> = BTreeMap::new();
147    let zero = Position::new(0, 0);
148
149    for error in errors {
150        let entry = source_cache
151            .entry(error.context.file.clone())
152            .or_insert_with(|| read_source(fs, &error.context.file));
153
154        let diag = match (entry.as_ref(), error.context.byte_offset) {
155            (Some((source, rope)), Some(offset)) => {
156                let primary_range =
157                    crate::diagnostics::locate_validation(&error.kind, source, Some(offset))
158                        .unwrap_or(offset..offset);
159                let (body, footers, suggestions) =
160                    crate::diagnostics::format_validation_kind(&error.kind, source, &primary_range);
161                let mut message = body;
162                append_detail_and_hints(&mut message, None, &footers);
163                let mut diag = build_error_diagnostic(primary_range, message, rope);
164                attach_quickfix_data(&mut diag, &suggestions, rope);
165                diag
166            }
167            _ => Diagnostic {
168                range: Range::new(zero, zero),
169                severity: Some(DiagnosticSeverity::ERROR),
170                source: Some("mz-deploy".to_string()),
171                message: error.kind.message(),
172                ..Default::default()
173            },
174        };
175
176        map.entry(error.context.file.clone())
177            .or_default()
178            .push(diag);
179    }
180
181    map
182}
183
184/// Convert a [`TypeCheckError`] into LSP diagnostics grouped by file path.
185///
186/// Per-object errors (`TypeCheckFailed`, `Multiple`) are positioned by
187/// inspecting the underlying error via [`locate_typecheck`]. The on-disk
188/// source file is read once per file and cached. If the read fails, all
189/// diagnostics for that file fall back to `(0, 0)`.
190///
191/// Non-object variants (`DatabaseSetupError`, `SortError`,
192/// `TypesCacheWriteFailed`) have no per-file context and return an empty map;
193/// callers should log them to the client message stream instead.
194pub(crate) fn typecheck_diagnostics(
195    fs: &FileSystem,
196    error: &TypeCheckError,
197    candidates: &crate::lsp::code_action::Candidates,
198) -> BTreeMap<PathBuf, Vec<Diagnostic>> {
199    let errors: &[ObjectTypeCheckError] = match error {
200        TypeCheckError::Multiple(errs) => errs.as_slice(),
201        TypeCheckError::DatabaseSetupError(_)
202        | TypeCheckError::SortError(_)
203        | TypeCheckError::TypesCacheWriteFailed(_) => &[],
204    };
205
206    let mut map: BTreeMap<PathBuf, Vec<Diagnostic>> = BTreeMap::new();
207    let mut source_cache: BTreeMap<PathBuf, Option<(String, Rope)>> = BTreeMap::new();
208    let zero = Position::new(0, 0);
209
210    for e in errors {
211        let entry = source_cache
212            .entry(e.file_path.clone())
213            .or_insert_with(|| read_source(fs, &e.file_path));
214
215        let diag = match entry.as_ref() {
216            Some((source, rope)) => {
217                let byte_range = locate_typecheck(&e.kind, source).unwrap_or(0..0);
218                let (body, footers, format_suggestions) =
219                    crate::diagnostics::format_typecheck_kind(&e.kind, source, &byte_range);
220                let suggestions = if format_suggestions.is_empty() {
221                    crate::lsp::code_action::fuzzy_suggestions(
222                        &e.kind,
223                        source,
224                        &byte_range,
225                        candidates,
226                    )
227                } else {
228                    format_suggestions
229                };
230
231                let mut message = body;
232                append_detail_and_hints(&mut message, e.detail().as_deref(), &footers);
233                let mut diag = build_error_diagnostic(byte_range, message, rope);
234                attach_quickfix_data(&mut diag, &suggestions, rope);
235                diag
236            }
237            None => {
238                // No source available — fall back to the upstream Display.
239                let mut message = e.error_message();
240                let footers: Vec<String> = e.hint().into_iter().collect();
241                append_detail_and_hints(&mut message, e.detail().as_deref(), &footers);
242                Diagnostic {
243                    range: Range::new(zero, zero),
244                    severity: Some(DiagnosticSeverity::ERROR),
245                    source: Some("mz-deploy".to_string()),
246                    message,
247                    ..Default::default()
248                }
249            }
250        };
251
252        map.entry(e.file_path.clone()).or_default().push(diag);
253    }
254
255    map
256}
257
258fn read_source(fs: &FileSystem, path: &Path) -> Option<(String, Rope)> {
259    let text = fs.read_to_string(path).ok()?;
260    let rope = Rope::from_str(&text);
261    Some((text, rope))
262}
263
264/// Build an error-severity LSP `Diagnostic` for `byte_range` in `rope`.
265///
266/// Both LSP diagnostic flows always emit `ERROR`; warnings come from the
267/// per-keystroke parse path via `to_lsp` below.
268fn build_error_diagnostic(
269    byte_range: std::ops::Range<usize>,
270    message: String,
271    rope: &Rope,
272) -> Diagnostic {
273    let zero = Position::new(0, 0);
274    let start = offset_to_position(byte_range.start, rope).unwrap_or(zero);
275    let end = offset_to_position(byte_range.end, rope).unwrap_or(start);
276    Diagnostic {
277        range: Range::new(start, end),
278        severity: Some(DiagnosticSeverity::ERROR),
279        source: Some("mz-deploy".to_string()),
280        message,
281        ..Default::default()
282    }
283}
284
285/// Append `\ndetail: <detail>` (when present) and one `\nhint: <footer>`
286/// line per footer to `message`. Preserves the human-readable advice for
287/// editors that don't render code actions.
288fn append_detail_and_hints(message: &mut String, detail: Option<&str>, footers: &[String]) {
289    if let Some(detail) = detail {
290        message.push_str("\ndetail: ");
291        message.push_str(detail);
292    }
293    for footer in footers {
294        message.push_str("\nhint: ");
295        message.push_str(footer);
296    }
297}
298
299/// Encode `suggestions` as the `QuickFixData` JSON payload on `diag.data`
300/// for the LSP code-action handler. No-op when `suggestions` is empty.
301fn attach_quickfix_data(diag: &mut Diagnostic, suggestions: &[Suggestion], rope: &Rope) {
302    if let Some(qf) = crate::lsp::code_action::suggestions_to_data(suggestions, rope) {
303        diag.data = Some(serde_json::to_value(qf).expect("serializable"));
304    }
305}
306
307/// Convert a [`PositionalDiagnostic`] to an LSP [`Diagnostic`].
308///
309/// Used by the per-keystroke parse path which builds `PositionalDiagnostic`s
310/// with both error and warning severity. The validation / typecheck flows
311/// build `Diagnostic`s directly via [`build_error_diagnostic`].
312fn to_lsp(pd: &PositionalDiagnostic, rope: &Rope) -> Diagnostic {
313    let zero = Position::new(0, 0);
314    let start = offset_to_position(pd.byte_range.start, rope).unwrap_or(zero);
315    let end = offset_to_position(pd.byte_range.end, rope).unwrap_or(start);
316    let severity = match pd.severity {
317        Severity::Error => DiagnosticSeverity::ERROR,
318        Severity::Warning => DiagnosticSeverity::WARNING,
319    };
320    Diagnostic {
321        range: Range::new(start, end),
322        severity: Some(severity),
323        source: Some("mz-deploy".to_string()),
324        message: pd.message.clone(),
325        ..Default::default()
326    }
327}
328
329/// Convert a byte offset to an LSP [`Position`] (line, column) using a [`Rope`].
330pub(crate) fn offset_to_position(offset: usize, rope: &Rope) -> Option<Position> {
331    let char_offset = rope.try_byte_to_char(offset).ok()?;
332    let line = rope.try_char_to_line(char_offset).ok()?;
333    let first_char_of_line = rope.try_line_to_char(line).ok()?;
334    let line_start_byte = rope.try_char_to_byte(first_char_of_line).ok()?;
335    let column = utf16_len(rope.byte_slice(line_start_byte..offset).as_str()?);
336
337    let line_u32 = line.try_into().ok()?;
338    let column_u32 = column.try_into().ok()?;
339
340    Some(Position::new(line_u32, column_u32))
341}
342
343/// Convert an LSP [`Position`] into a byte offset using a [`Rope`].
344///
345/// Returns `None` if the position lies past the end of the document.
346pub(crate) fn position_to_offset(position: Position, rope: &Rope) -> Option<usize> {
347    let line = usize::try_from(position.line).ok()?;
348    let target_col = usize::try_from(position.character).ok()?;
349    let line_start_char = rope.try_line_to_char(line).ok()?;
350    let line_text = rope.get_line(line)?;
351
352    let mut utf16_col = 0usize;
353    let mut char_delta = 0usize;
354    for ch in line_text.chars() {
355        if utf16_col >= target_col {
356            break;
357        }
358        let next = utf16_col + ch.len_utf16();
359        if next > target_col {
360            break;
361        }
362        utf16_col = next;
363        char_delta += 1;
364    }
365
366    let char_offset = line_start_char + char_delta;
367    rope.try_char_to_byte(char_offset).ok()
368}
369
370fn utf16_len(text: &str) -> usize {
371    text.chars().map(char::len_utf16).sum()
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377
378    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
379    #[mz_ore::test]
380    fn valid_sql_produces_no_diagnostics() {
381        let text = "CREATE VIEW foo AS SELECT 1;";
382        let rope = Rope::from_str(text);
383        assert!(diagnose(text, &rope, &BTreeMap::new(), None).is_empty());
384    }
385
386    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
387    #[mz_ore::test]
388    fn syntax_error_produces_diagnostic_at_correct_position() {
389        let text = "CREATE VIEW foo AS SELECTT 1;";
390        let rope = Rope::from_str(text);
391        let diags = diagnose(text, &rope, &BTreeMap::new(), None);
392        assert_eq!(diags.len(), 1);
393        assert_eq!(diags[0].severity, Some(DiagnosticSeverity::ERROR));
394        // Error should be on line 0 (first line)
395        assert_eq!(diags[0].range.start.line, 0);
396    }
397
398    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
399    #[mz_ore::test]
400    fn multiline_error_position() {
401        let text = "CREATE VIEW foo AS\nSELECT 1;\nCREATE VIEW bar AS SELECTT 2;";
402        let rope = Rope::from_str(text);
403        let diags = diagnose(text, &rope, &BTreeMap::new(), None);
404        assert_eq!(diags.len(), 1);
405        // Error should be on line 2 (third line, zero-indexed)
406        assert_eq!(diags[0].range.start.line, 2);
407    }
408
409    #[mz_ore::test]
410    #[cfg_attr(miri, ignore)] // can't call foreign function `llvm.aarch64.neon.uaddlv.i32.v16i8` on OS `linux`
411    fn empty_file_produces_no_diagnostics() {
412        let text = "";
413        let rope = Rope::from_str(text);
414        assert!(diagnose(text, &rope, &BTreeMap::new(), None).is_empty());
415    }
416
417    #[mz_ore::test]
418    #[cfg_attr(miri, ignore)] // can't call foreign function `llvm.aarch64.neon.uaddlv.i32.v16i8` on OS `linux`
419    fn offset_to_position_uses_utf16_columns() {
420        let text = "SELECT 😀FROM";
421        let rope = Rope::from_str(text);
422        // `FROM` starts after 7 ASCII code units plus 2 UTF-16 code units for 😀.
423        assert_eq!(offset_to_position(11, &rope), Some(Position::new(0, 9)));
424    }
425
426    #[mz_ore::test]
427    #[cfg_attr(miri, ignore)] // can't call foreign function `llvm.aarch64.neon.uaddlv.i32.v16i8` on OS `linux`
428    fn position_to_offset_uses_utf16_columns() {
429        let text = "SELECT 😀foo";
430        let rope = Rope::from_str(text);
431        // `foo` begins after 7 ASCII code units plus 2 UTF-16 code units for 😀.
432        assert_eq!(position_to_offset(Position::new(0, 9), &rope), Some(11));
433        assert_eq!(
434            position_to_offset(Position::new(0, 12), &rope),
435            Some(text.len())
436        );
437    }
438
439    #[mz_ore::test]
440    #[cfg_attr(miri, ignore)] // can't call foreign function `llvm.aarch64.neon.uaddlv.i32.v16i8` on OS `linux`
441    fn position_to_offset_at_line_past_end_returns_none() {
442        let rope = Rope::from_str("a\nb");
443        let past_end = u32::try_from(rope.len_lines()).unwrap();
444        assert_eq!(position_to_offset(Position::new(past_end, 0), &rope), None);
445    }
446
447    #[mz_ore::test]
448    #[cfg_attr(miri, ignore)] // can't call foreign function `llvm.aarch64.neon.uaddlv.i32.v16i8` on OS `linux`
449    fn whitespace_only_file_produces_no_diagnostics() {
450        let text = "   \n  \n  ";
451        let rope = Rope::from_str(text);
452        assert!(diagnose(text, &rope, &BTreeMap::new(), None).is_empty());
453    }
454
455    // --- Variable-aware diagnose tests ---
456
457    fn vars(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
458        pairs
459            .iter()
460            .map(|(k, v)| (k.to_string(), v.to_string()))
461            .collect()
462    }
463
464    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
465    #[mz_ore::test]
466    fn resolved_variable_no_diagnostics() {
467        let text = "CREATE MATERIALIZED VIEW mv IN CLUSTER quickstart AS SELECT 1";
468        let rope = Rope::from_str(text);
469        let diags = diagnose(text, &rope, &BTreeMap::new(), None);
470        assert!(diags.is_empty());
471    }
472
473    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
474    #[mz_ore::test]
475    fn resolved_variable_produces_clean_parse() {
476        let v = vars(&[("cluster", "quickstart")]);
477        let text = "CREATE MATERIALIZED VIEW mv IN CLUSTER :cluster AS SELECT 1";
478        let rope = Rope::from_str(text);
479        let diags = diagnose(text, &rope, &v, None);
480        assert!(diags.is_empty());
481    }
482
483    #[mz_ore::test]
484    #[cfg_attr(miri, ignore)] // can't call foreign function `llvm.aarch64.neon.uaddlv.i32.v16i8` on OS `linux`
485    fn unresolved_variable_produces_error() {
486        let text = "CREATE MATERIALIZED VIEW mv IN CLUSTER :cluster AS SELECT 1";
487        let rope = Rope::from_str(text);
488        let diags = diagnose(text, &rope, &BTreeMap::new(), None);
489        // Should have at least the variable error
490        let var_diags: Vec<_> = diags
491            .iter()
492            .filter(|d| d.message.contains("undefined variable"))
493            .collect();
494        assert_eq!(var_diags.len(), 1);
495        assert_eq!(var_diags[0].severity, Some(DiagnosticSeverity::ERROR));
496        // `:cluster` starts at byte 39
497        assert!(var_diags[0].message.contains(":cluster"));
498    }
499
500    #[mz_ore::test]
501    #[cfg_attr(miri, ignore)] // can't call foreign function `llvm.aarch64.neon.uaddlv.i32.v16i8` on OS `linux`
502    fn unresolved_variable_with_pragma_produces_warning() {
503        let text = "-- PRAGMA WARN_ON_MISSING_VARIABLES;\nCREATE MATERIALIZED VIEW mv IN CLUSTER :cluster AS SELECT 1";
504        let rope = Rope::from_str(text);
505        let diags = diagnose(text, &rope, &BTreeMap::new(), None);
506        let var_diags: Vec<_> = diags
507            .iter()
508            .filter(|d| d.message.contains("undefined variable"))
509            .collect();
510        assert_eq!(var_diags.len(), 1);
511        assert_eq!(var_diags[0].severity, Some(DiagnosticSeverity::WARNING));
512    }
513
514    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
515    #[mz_ore::test]
516    fn parse_error_maps_back_to_original_position() {
517        // After resolving :x → "ab", the parse error in resolved text
518        // should map back to the original text position.
519        let v = vars(&[("x", "ab")]);
520        // "CREATE VIEW :x AS SELECTT 1" → "CREATE VIEW ab AS SELECTT 1"
521        let text = "CREATE VIEW :x AS SELECTT 1";
522        let rope = Rope::from_str(text);
523        let diags = diagnose(text, &rope, &v, None);
524        // Should have exactly one parse error diagnostic.
525        let parse_diags: Vec<_> = diags
526            .iter()
527            .filter(|d| !d.message.contains("undefined variable"))
528            .collect();
529        assert_eq!(parse_diags.len(), 1);
530        assert_eq!(parse_diags[0].severity, Some(DiagnosticSeverity::ERROR));
531        assert_eq!(parse_diags[0].range.start.line, 0);
532    }
533
534    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
535    #[mz_ore::test]
536    fn no_variables_unchanged_behavior() {
537        let text = "CREATE VIEW foo AS SELECT 1;";
538        let rope = Rope::from_str(text);
539        assert!(diagnose(text, &rope, &BTreeMap::new(), None).is_empty());
540    }
541
542    #[mz_ore::test]
543    #[cfg_attr(miri, ignore)] // can't call foreign function `llvm.aarch64.neon.uaddlv.i32.v16i8` on OS `linux`
544    fn typecheck_unknown_column_attaches_quickfix_data() {
545        use crate::lsp::code_action::{Candidates, QuickFixData};
546        use crate::project::compiler::typecheck::{ObjectTypeCheckError, ObjectTypeCheckErrorKind};
547        use crate::project::ir::object_id::ObjectId;
548        use mz_repr::ColumnName;
549        use mz_sql::plan::PlanError;
550        use std::sync::Arc;
551
552        let source = "SELECT custoser_name FROM users";
553        let path = std::env::temp_dir().join("typecheck_qf_test.sql");
554        std::fs::write(&path, source).unwrap();
555
556        let plan_err = PlanError::UnknownColumn {
557            table: None,
558            column: ColumnName::from("custoser_name"),
559            similar: Box::new([ColumnName::from("customer_name")]),
560        };
561        let err = ObjectTypeCheckError {
562            object_id: ObjectId::new(
563                "materialize".to_string(),
564                "public".to_string(),
565                "v".to_string(),
566            ),
567            file_path: path.clone(),
568            kind: ObjectTypeCheckErrorKind::Plan(Arc::new(plan_err)),
569        };
570        let tc = TypeCheckError::Multiple(vec![err]);
571
572        let fs = FileSystem::default();
573        let candidates = Candidates::default();
574        let map = typecheck_diagnostics(&fs, &tc, &candidates);
575        let diags = map.get(&path).expect("diags for file");
576        assert_eq!(diags.len(), 1);
577
578        let data = diags[0]
579            .data
580            .as_ref()
581            .expect("Diagnostic.data should be set when suggestions exist");
582        let qf: QuickFixData = serde_json::from_value(data.clone()).expect("decodes");
583        assert_eq!(qf.suggestions.len(), 1);
584        assert_eq!(qf.suggestions[0].alternatives.len(), 1);
585        assert_eq!(qf.suggestions[0].alternatives[0].new_text, "customer_name");
586        assert!(
587            diags[0]
588                .message
589                .contains("column custoser_name does not exist")
590        );
591        let _ = std::fs::remove_file(&path);
592    }
593
594    #[mz_ore::test]
595    #[cfg_attr(miri, ignore)] // can't call foreign function `llvm.aarch64.neon.uaddlv.i32.v16i8` on OS `linux`
596    fn typecheck_unknown_item_attaches_fuzzy_quickfix_data() {
597        use crate::lsp::code_action::{Candidates, QuickFixData};
598        use crate::project::compiler::typecheck::{ObjectTypeCheckError, ObjectTypeCheckErrorKind};
599        use crate::project::ir::object_id::ObjectId;
600        use mz_sql::catalog::CatalogError;
601
602        let source = "SELECT * FROM cusotmers";
603        let path = std::env::temp_dir().join("typecheck_fuzzy_test.sql");
604        std::fs::write(&path, source).unwrap();
605
606        let err = ObjectTypeCheckError {
607            object_id: ObjectId::new(
608                "materialize".to_string(),
609                "public".to_string(),
610                "v".to_string(),
611            ),
612            file_path: path.clone(),
613            kind: ObjectTypeCheckErrorKind::Catalog(CatalogError::UnknownItem(
614                "cusotmers".to_string(),
615            )),
616        };
617        let tc = TypeCheckError::Multiple(vec![err]);
618
619        let fs = FileSystem::default();
620        let candidates = Candidates {
621            items: vec!["customers".to_string()],
622            ..Default::default()
623        };
624        let map = typecheck_diagnostics(&fs, &tc, &candidates);
625        let diags = map.get(&path).expect("diags for file");
626        assert_eq!(diags.len(), 1);
627
628        let data = diags[0]
629            .data
630            .as_ref()
631            .expect("Diagnostic.data should be set");
632        let qf: QuickFixData = serde_json::from_value(data.clone()).expect("decodes");
633        assert_eq!(qf.suggestions.len(), 1);
634        assert_eq!(qf.suggestions[0].alternatives.len(), 1);
635        assert_eq!(qf.suggestions[0].alternatives[0].new_text, "customers");
636        let _ = std::fs::remove_file(&path);
637    }
638
639    #[mz_ore::test]
640    #[cfg_attr(miri, ignore)] // can't call foreign function `llvm.aarch64.neon.uaddlv.i32.v16i8` on OS `linux`
641    fn validation_object_name_mismatch_attaches_quickfix_data() {
642        use crate::lsp::code_action::QuickFixData;
643        use crate::project::error::validation::ErrorContext;
644        use crate::project::error::{ValidationError, ValidationErrorKind};
645
646        let source = "CREATE TABLE customers (id INT);";
647        let path = std::env::temp_dir().join("validation_qf_test.sql");
648        std::fs::write(&path, source).unwrap();
649
650        let err = ValidationError {
651            kind: ValidationErrorKind::ObjectNameMismatch {
652                declared: "customers".to_string(),
653                expected: "users".to_string(),
654            },
655            context: ErrorContext {
656                file: path.clone(),
657                sql_statement: Some(source.to_string()),
658                byte_offset: Some(0),
659            },
660        };
661
662        let fs = FileSystem::default();
663        let map = validation_diagnostics(&fs, &[err]);
664        let diags = map.get(&path).expect("diags for file");
665        assert_eq!(diags.len(), 1);
666
667        let data = diags[0]
668            .data
669            .as_ref()
670            .expect("Diagnostic.data should be set");
671        let qf: QuickFixData = serde_json::from_value(data.clone()).expect("decodes");
672        assert_eq!(qf.suggestions[0].alternatives[0].new_text, "users");
673        let _ = std::fs::remove_file(&path);
674    }
675}