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`].
344pub(crate) fn position_to_offset(position: Position, rope: &Rope) -> Option<usize> {
345    let line = usize::try_from(position.line).ok()?;
346    let target_col = usize::try_from(position.character).ok()?;
347    let line_start_char = rope.try_line_to_char(line).ok()?;
348    let line_text = rope.line(line);
349
350    let mut utf16_col = 0usize;
351    let mut char_delta = 0usize;
352    for ch in line_text.chars() {
353        if utf16_col >= target_col {
354            break;
355        }
356        let next = utf16_col + ch.len_utf16();
357        if next > target_col {
358            break;
359        }
360        utf16_col = next;
361        char_delta += 1;
362    }
363
364    let char_offset = line_start_char + char_delta;
365    rope.try_char_to_byte(char_offset).ok()
366}
367
368fn utf16_len(text: &str) -> usize {
369    text.chars().map(char::len_utf16).sum()
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375
376    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
377    #[mz_ore::test]
378    fn valid_sql_produces_no_diagnostics() {
379        let text = "CREATE VIEW foo AS SELECT 1;";
380        let rope = Rope::from_str(text);
381        assert!(diagnose(text, &rope, &BTreeMap::new(), None).is_empty());
382    }
383
384    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
385    #[mz_ore::test]
386    fn syntax_error_produces_diagnostic_at_correct_position() {
387        let text = "CREATE VIEW foo AS SELECTT 1;";
388        let rope = Rope::from_str(text);
389        let diags = diagnose(text, &rope, &BTreeMap::new(), None);
390        assert_eq!(diags.len(), 1);
391        assert_eq!(diags[0].severity, Some(DiagnosticSeverity::ERROR));
392        // Error should be on line 0 (first line)
393        assert_eq!(diags[0].range.start.line, 0);
394    }
395
396    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
397    #[mz_ore::test]
398    fn multiline_error_position() {
399        let text = "CREATE VIEW foo AS\nSELECT 1;\nCREATE VIEW bar AS SELECTT 2;";
400        let rope = Rope::from_str(text);
401        let diags = diagnose(text, &rope, &BTreeMap::new(), None);
402        assert_eq!(diags.len(), 1);
403        // Error should be on line 2 (third line, zero-indexed)
404        assert_eq!(diags[0].range.start.line, 2);
405    }
406
407    #[mz_ore::test]
408    fn empty_file_produces_no_diagnostics() {
409        let text = "";
410        let rope = Rope::from_str(text);
411        assert!(diagnose(text, &rope, &BTreeMap::new(), None).is_empty());
412    }
413
414    #[mz_ore::test]
415    fn offset_to_position_uses_utf16_columns() {
416        let text = "SELECT 😀FROM";
417        let rope = Rope::from_str(text);
418        // `FROM` starts after 7 ASCII code units plus 2 UTF-16 code units for 😀.
419        assert_eq!(offset_to_position(11, &rope), Some(Position::new(0, 9)));
420    }
421
422    #[mz_ore::test]
423    fn position_to_offset_uses_utf16_columns() {
424        let text = "SELECT 😀foo";
425        let rope = Rope::from_str(text);
426        // `foo` begins after 7 ASCII code units plus 2 UTF-16 code units for 😀.
427        assert_eq!(position_to_offset(Position::new(0, 9), &rope), Some(11));
428        assert_eq!(
429            position_to_offset(Position::new(0, 12), &rope),
430            Some(text.len())
431        );
432    }
433
434    #[mz_ore::test]
435    fn whitespace_only_file_produces_no_diagnostics() {
436        let text = "   \n  \n  ";
437        let rope = Rope::from_str(text);
438        assert!(diagnose(text, &rope, &BTreeMap::new(), None).is_empty());
439    }
440
441    // --- Variable-aware diagnose tests ---
442
443    fn vars(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
444        pairs
445            .iter()
446            .map(|(k, v)| (k.to_string(), v.to_string()))
447            .collect()
448    }
449
450    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
451    #[mz_ore::test]
452    fn resolved_variable_no_diagnostics() {
453        let text = "CREATE MATERIALIZED VIEW mv IN CLUSTER quickstart AS SELECT 1";
454        let rope = Rope::from_str(text);
455        let diags = diagnose(text, &rope, &BTreeMap::new(), None);
456        assert!(diags.is_empty());
457    }
458
459    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
460    #[mz_ore::test]
461    fn resolved_variable_produces_clean_parse() {
462        let v = vars(&[("cluster", "quickstart")]);
463        let text = "CREATE MATERIALIZED VIEW mv IN CLUSTER :cluster AS SELECT 1";
464        let rope = Rope::from_str(text);
465        let diags = diagnose(text, &rope, &v, None);
466        assert!(diags.is_empty());
467    }
468
469    #[mz_ore::test]
470    fn unresolved_variable_produces_error() {
471        let text = "CREATE MATERIALIZED VIEW mv IN CLUSTER :cluster AS SELECT 1";
472        let rope = Rope::from_str(text);
473        let diags = diagnose(text, &rope, &BTreeMap::new(), None);
474        // Should have at least the variable error
475        let var_diags: Vec<_> = diags
476            .iter()
477            .filter(|d| d.message.contains("undefined variable"))
478            .collect();
479        assert_eq!(var_diags.len(), 1);
480        assert_eq!(var_diags[0].severity, Some(DiagnosticSeverity::ERROR));
481        // `:cluster` starts at byte 39
482        assert!(var_diags[0].message.contains(":cluster"));
483    }
484
485    #[mz_ore::test]
486    fn unresolved_variable_with_pragma_produces_warning() {
487        let text = "-- PRAGMA WARN_ON_MISSING_VARIABLES;\nCREATE MATERIALIZED VIEW mv IN CLUSTER :cluster AS SELECT 1";
488        let rope = Rope::from_str(text);
489        let diags = diagnose(text, &rope, &BTreeMap::new(), None);
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::WARNING));
496    }
497
498    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
499    #[mz_ore::test]
500    fn parse_error_maps_back_to_original_position() {
501        // After resolving :x → "ab", the parse error in resolved text
502        // should map back to the original text position.
503        let v = vars(&[("x", "ab")]);
504        // "CREATE VIEW :x AS SELECTT 1" → "CREATE VIEW ab AS SELECTT 1"
505        let text = "CREATE VIEW :x AS SELECTT 1";
506        let rope = Rope::from_str(text);
507        let diags = diagnose(text, &rope, &v, None);
508        // Should have exactly one parse error diagnostic.
509        let parse_diags: Vec<_> = diags
510            .iter()
511            .filter(|d| !d.message.contains("undefined variable"))
512            .collect();
513        assert_eq!(parse_diags.len(), 1);
514        assert_eq!(parse_diags[0].severity, Some(DiagnosticSeverity::ERROR));
515        assert_eq!(parse_diags[0].range.start.line, 0);
516    }
517
518    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
519    #[mz_ore::test]
520    fn no_variables_unchanged_behavior() {
521        let text = "CREATE VIEW foo AS SELECT 1;";
522        let rope = Rope::from_str(text);
523        assert!(diagnose(text, &rope, &BTreeMap::new(), None).is_empty());
524    }
525
526    #[mz_ore::test]
527    fn typecheck_unknown_column_attaches_quickfix_data() {
528        use crate::lsp::code_action::{Candidates, QuickFixData};
529        use crate::project::compiler::typecheck::{ObjectTypeCheckError, ObjectTypeCheckErrorKind};
530        use crate::project::ir::object_id::ObjectId;
531        use mz_repr::ColumnName;
532        use mz_sql::plan::PlanError;
533        use std::sync::Arc;
534
535        let source = "SELECT custoser_name FROM users";
536        let path = std::env::temp_dir().join("typecheck_qf_test.sql");
537        std::fs::write(&path, source).unwrap();
538
539        let plan_err = PlanError::UnknownColumn {
540            table: None,
541            column: ColumnName::from("custoser_name"),
542            similar: Box::new([ColumnName::from("customer_name")]),
543        };
544        let err = ObjectTypeCheckError {
545            object_id: ObjectId::new(
546                "materialize".to_string(),
547                "public".to_string(),
548                "v".to_string(),
549            ),
550            file_path: path.clone(),
551            kind: ObjectTypeCheckErrorKind::Plan(Arc::new(plan_err)),
552        };
553        let tc = TypeCheckError::Multiple(vec![err]);
554
555        let fs = FileSystem::default();
556        let candidates = Candidates::default();
557        let map = typecheck_diagnostics(&fs, &tc, &candidates);
558        let diags = map.get(&path).expect("diags for file");
559        assert_eq!(diags.len(), 1);
560
561        let data = diags[0]
562            .data
563            .as_ref()
564            .expect("Diagnostic.data should be set when suggestions exist");
565        let qf: QuickFixData = serde_json::from_value(data.clone()).expect("decodes");
566        assert_eq!(qf.suggestions.len(), 1);
567        assert_eq!(qf.suggestions[0].alternatives.len(), 1);
568        assert_eq!(qf.suggestions[0].alternatives[0].new_text, "customer_name");
569        assert!(
570            diags[0]
571                .message
572                .contains("column custoser_name does not exist")
573        );
574        let _ = std::fs::remove_file(&path);
575    }
576
577    #[mz_ore::test]
578    fn typecheck_unknown_item_attaches_fuzzy_quickfix_data() {
579        use crate::lsp::code_action::{Candidates, QuickFixData};
580        use crate::project::compiler::typecheck::{ObjectTypeCheckError, ObjectTypeCheckErrorKind};
581        use crate::project::ir::object_id::ObjectId;
582        use mz_sql::catalog::CatalogError;
583
584        let source = "SELECT * FROM cusotmers";
585        let path = std::env::temp_dir().join("typecheck_fuzzy_test.sql");
586        std::fs::write(&path, source).unwrap();
587
588        let err = ObjectTypeCheckError {
589            object_id: ObjectId::new(
590                "materialize".to_string(),
591                "public".to_string(),
592                "v".to_string(),
593            ),
594            file_path: path.clone(),
595            kind: ObjectTypeCheckErrorKind::Catalog(CatalogError::UnknownItem(
596                "cusotmers".to_string(),
597            )),
598        };
599        let tc = TypeCheckError::Multiple(vec![err]);
600
601        let fs = FileSystem::default();
602        let candidates = Candidates {
603            items: vec!["customers".to_string()],
604            ..Default::default()
605        };
606        let map = typecheck_diagnostics(&fs, &tc, &candidates);
607        let diags = map.get(&path).expect("diags for file");
608        assert_eq!(diags.len(), 1);
609
610        let data = diags[0]
611            .data
612            .as_ref()
613            .expect("Diagnostic.data should be set");
614        let qf: QuickFixData = serde_json::from_value(data.clone()).expect("decodes");
615        assert_eq!(qf.suggestions.len(), 1);
616        assert_eq!(qf.suggestions[0].alternatives.len(), 1);
617        assert_eq!(qf.suggestions[0].alternatives[0].new_text, "customers");
618        let _ = std::fs::remove_file(&path);
619    }
620
621    #[mz_ore::test]
622    fn validation_object_name_mismatch_attaches_quickfix_data() {
623        use crate::lsp::code_action::QuickFixData;
624        use crate::project::error::validation::ErrorContext;
625        use crate::project::error::{ValidationError, ValidationErrorKind};
626
627        let source = "CREATE TABLE customers (id INT);";
628        let path = std::env::temp_dir().join("validation_qf_test.sql");
629        std::fs::write(&path, source).unwrap();
630
631        let err = ValidationError {
632            kind: ValidationErrorKind::ObjectNameMismatch {
633                declared: "customers".to_string(),
634                expected: "users".to_string(),
635            },
636            context: ErrorContext {
637                file: path.clone(),
638                sql_statement: Some(source.to_string()),
639                byte_offset: Some(0),
640            },
641        };
642
643        let fs = FileSystem::default();
644        let map = validation_diagnostics(&fs, &[err]);
645        let diags = map.get(&path).expect("diags for file");
646        assert_eq!(diags.len(), 1);
647
648        let data = diags[0]
649            .data
650            .as_ref()
651            .expect("Diagnostic.data should be set");
652        let qf: QuickFixData = serde_json::from_value(data.clone()).expect("decodes");
653        assert_eq!(qf.suggestions[0].alternatives[0].new_text, "users");
654        let _ = std::fs::remove_file(&path);
655    }
656}