Skip to main content

mz_deploy/lsp/
code_action.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 code-action support.
11//!
12//! Owns four concerns that all serve the `textDocument/codeAction` flow:
13//!
14//! - **`QuickFixData` payload** (`SuggestionData`, `ReplacementData`,
15//!   `suggestions_to_data`) — JSON sidecar attached to `Diagnostic.data` so
16//!   the same suggestion data round-trips from a diagnostic to a follow-up
17//!   `codeAction` request.
18//! - **Builder** (`build_code_actions`) — turns a `CodeActionParams` request
19//!   back into one `CodeAction` per alternative. Pure; no I/O.
20//! - **Fuzzy enrichment** (`Candidates`, `harvest_candidates`,
21//!   `fuzzy_suggestions`, `did_you_mean`) — for catalog errors the
22//!   typechecker doesn't suggest replacements for (`UnknownItem`,
23//!   `UnknownSchema`, `UnknownDatabase`, `UnknownCluster`), generate
24//!   suggestions LSP-side by Damerau-Levenshtein-matching against names
25//!   harvested from the project cache.
26
27use crate::diagnostics::{Replacement, Suggestion, last_component, locate_replacement};
28use crate::project::compiler::cache::ProjectCache;
29use crate::project::compiler::typecheck::ObjectTypeCheckErrorKind;
30use mz_sql::catalog::CatalogError;
31use ropey::Rope;
32use serde::{Deserialize, Serialize};
33use tower_lsp::lsp_types::{
34    CodeAction, CodeActionKind, CodeActionOrCommand, CodeActionParams, Diagnostic, Range, TextEdit,
35    Url, WorkspaceEdit,
36};
37
38/// JSON payload riding on `Diagnostic.data` so the `code_action` handler
39/// can rebuild a `WorkspaceEdit` without re-running the typecheck.
40#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
41pub(crate) struct QuickFixData {
42    pub suggestions: Vec<SuggestionData>,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
46pub(crate) struct SuggestionData {
47    pub label: String,
48    pub alternatives: Vec<ReplacementData>,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
52pub(crate) struct ReplacementData {
53    pub range: Range,
54    pub new_text: String,
55}
56
57/// Convert the byte-range-flavored [`Suggestion`]s produced by the diagnostics
58/// formatter into LSP-shaped [`SuggestionData`] using `rope` to map byte
59/// offsets to line/column. Returns `None` when `suggestions` is empty so the
60/// caller can leave `Diagnostic.data` unset.
61pub(crate) fn suggestions_to_data(suggestions: &[Suggestion], rope: &Rope) -> Option<QuickFixData> {
62    if suggestions.is_empty() {
63        return None;
64    }
65    let suggestions = suggestions
66        .iter()
67        .map(|s| SuggestionData {
68            label: s.label.clone(),
69            alternatives: s
70                .alternatives
71                .iter()
72                .map(|alt| ReplacementData {
73                    range: byte_range_to_lsp(alt.byte_range.clone(), rope),
74                    new_text: alt.replacement.clone(),
75                })
76                .collect(),
77        })
78        .collect();
79    Some(QuickFixData { suggestions })
80}
81
82fn byte_range_to_lsp(range: std::ops::Range<usize>, rope: &Rope) -> Range {
83    use crate::lsp::diagnostics::offset_to_position;
84    use tower_lsp::lsp_types::Position;
85    let zero = Position::new(0, 0);
86    let start = offset_to_position(range.start, rope).unwrap_or(zero);
87    let end = offset_to_position(range.end, rope).unwrap_or(start);
88    Range::new(start, end)
89}
90
91/// Build the list of quick-fix code actions for a `textDocument/codeAction`
92/// request. Inspects each diagnostic's `data` field for [`QuickFixData`] and
93/// emits one [`CodeAction`] per alternative.
94pub(crate) fn build_code_actions(params: &CodeActionParams) -> Vec<CodeActionOrCommand> {
95    let uri = &params.text_document.uri;
96    let mut actions = Vec::new();
97    for diag in &params.context.diagnostics {
98        let Some(data) = diag.data.as_ref() else {
99            continue;
100        };
101        let Ok(qf) = serde_json::from_value::<QuickFixData>(data.clone()) else {
102            continue;
103        };
104        let total_alternatives: usize = qf.suggestions.iter().map(|s| s.alternatives.len()).sum();
105        let unique_best = total_alternatives == 1;
106        for suggestion in qf.suggestions {
107            for alt in suggestion.alternatives {
108                actions.push(CodeActionOrCommand::CodeAction(action_for_alt(
109                    uri,
110                    diag.clone(),
111                    alt,
112                    unique_best,
113                )));
114            }
115        }
116    }
117    actions
118}
119
120fn action_for_alt(
121    uri: &Url,
122    diag: Diagnostic,
123    alt: ReplacementData,
124    is_preferred: bool,
125) -> CodeAction {
126    let title = format!("Replace with `{}`", alt.new_text);
127    let edit = TextEdit {
128        range: alt.range,
129        new_text: alt.new_text,
130    };
131    #[allow(clippy::disallowed_types)]
132    let mut changes = std::collections::HashMap::new();
133    changes.insert(uri.clone(), vec![edit]);
134    CodeAction {
135        title,
136        kind: Some(CodeActionKind::QUICKFIX),
137        diagnostics: Some(vec![diag]),
138        edit: Some(WorkspaceEdit {
139            changes: Some(changes),
140            document_changes: None,
141            change_annotations: None,
142        }),
143        is_preferred: Some(is_preferred),
144        ..Default::default()
145    }
146}
147
148/// Per-kind candidate name pools harvested from the project cache. Empty
149/// vectors are valid — they just mean no fuzzy suggestions for that kind.
150#[derive(Debug, Default, Clone)]
151pub(crate) struct Candidates {
152    pub items: Vec<String>,
153    pub schemas: Vec<String>,
154    pub databases: Vec<String>,
155    pub clusters: Vec<String>,
156}
157
158/// LSP-side enrichment: for `Catalog::Unknown{Item,Schema,Database,Cluster}`,
159/// fuzzy-match the offending name against the corresponding pool and return
160/// one [`Suggestion`] containing the closest alternatives. Returns an empty
161/// vec for variants we don't enrich (everything else, including
162/// `UnknownColumn`/`UnknownFunction` whose suggestions come from upstream).
163pub(crate) fn fuzzy_suggestions(
164    kind: &ObjectTypeCheckErrorKind,
165    source: &str,
166    primary_range: &std::ops::Range<usize>,
167    candidates: &Candidates,
168) -> Vec<Suggestion> {
169    let (needle, pool): (&str, &[String]) = match kind {
170        ObjectTypeCheckErrorKind::Catalog(CatalogError::UnknownItem(name)) => {
171            (last_component(name), &candidates.items)
172        }
173        ObjectTypeCheckErrorKind::Catalog(CatalogError::UnknownSchema(name)) => {
174            (last_component(name), &candidates.schemas)
175        }
176        ObjectTypeCheckErrorKind::Catalog(CatalogError::UnknownDatabase(name)) => {
177            (last_component(name), &candidates.databases)
178        }
179        ObjectTypeCheckErrorKind::Catalog(CatalogError::UnknownCluster(name)) => {
180            (name.as_str(), &candidates.clusters)
181        }
182        _ => return Vec::new(),
183    };
184
185    let matches = did_you_mean(needle, pool);
186    if matches.is_empty() {
187        return Vec::new();
188    }
189
190    let span = locate_replacement(source, primary_range, needle);
191    let label = match matches.as_slice() {
192        [single] => format!("did you mean `{single}`?"),
193        _ => "did you mean one of these?".to_string(),
194    };
195    let alternatives = matches
196        .into_iter()
197        .map(|alt| Replacement {
198            byte_range: span.clone(),
199            replacement: alt,
200        })
201        .collect();
202    vec![Suggestion {
203        label,
204        alternatives,
205    }]
206}
207
208/// Build a [`Candidates`] set from the project cache: every project item
209/// name into `items`, every schema into `schemas`, every database into
210/// `databases`, and the unique non-empty cluster names referenced by
211/// project objects into `clusters`. Returns an empty `Candidates` when
212/// `cache` is `None`.
213pub(crate) fn harvest_candidates(cache: Option<&ProjectCache>) -> Candidates {
214    let Some(cache) = cache else {
215        return Candidates::default();
216    };
217    let dbs = cache.list_databases_with_objects();
218    let mut databases = Vec::with_capacity(dbs.len());
219    let mut schemas: Vec<String> = Vec::new();
220    for db in &dbs {
221        databases.push(db.name.clone());
222        for s in &db.schemas {
223            schemas.push(s.name.clone());
224        }
225    }
226    databases.sort();
227    databases.dedup();
228    schemas.sort();
229    schemas.dedup();
230
231    let summaries = cache.list_objects();
232    let mut items: Vec<String> = summaries.iter().map(|s| s.name.clone()).collect();
233    items.sort();
234    items.dedup();
235
236    let mut clusters: Vec<String> = summaries.iter().filter_map(|s| s.cluster.clone()).collect();
237    clusters.sort();
238    clusters.dedup();
239
240    Candidates {
241        items,
242        schemas,
243        databases,
244        clusters,
245    }
246}
247
248/// Maximum number of suggestions returned by [`did_you_mean`].
249const MAX_DID_YOU_MEAN: usize = 3;
250
251/// Return up to [`MAX_DID_YOU_MEAN`] closest names from `candidates` to
252/// `needle`, sorted by Damerau-Levenshtein distance ascending. Names whose
253/// distance exceeds `max(2, needle.len() / 3)` are filtered out so unrelated
254/// matches don't surface as quick fixes.
255///
256/// Allocations only happen for surviving candidates, so passing a borrowed
257/// slice (e.g. `pool.iter()`) is cheap even when the pool has thousands of
258/// names.
259pub(crate) fn did_you_mean<I, S>(needle: &str, candidates: I) -> Vec<String>
260where
261    I: IntoIterator<Item = S>,
262    S: AsRef<str>,
263{
264    let threshold = std::cmp::max(2, needle.len() / 3);
265    let mut scored: Vec<(usize, String)> = candidates
266        .into_iter()
267        .filter_map(|c| {
268            let s = c.as_ref();
269            let d = strsim::damerau_levenshtein(needle, s);
270            (d <= threshold).then(|| (d, s.to_string()))
271        })
272        .collect();
273    scored.sort_by_key(|(d, _)| *d);
274    scored.truncate(MAX_DID_YOU_MEAN);
275    scored.into_iter().map(|(_, s)| s).collect()
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use crate::diagnostics::Replacement;
282    use tower_lsp::lsp_types::Position;
283    use tower_lsp::lsp_types::{
284        CodeActionContext, CodeActionKind, CodeActionOrCommand, CodeActionParams, Diagnostic,
285        DiagnosticSeverity, PartialResultParams, TextDocumentIdentifier, Url,
286        WorkDoneProgressParams,
287    };
288
289    #[mz_ore::test]
290    #[cfg_attr(miri, ignore)] // can't call foreign function `llvm.aarch64.neon.uaddlv.i32.v16i8` on OS `linux`
291    fn suggestions_to_data_empty_returns_none() {
292        let rope = Rope::from_str("SELECT 1");
293        assert!(suggestions_to_data(&[], &rope).is_none());
294    }
295
296    #[mz_ore::test]
297    #[cfg_attr(miri, ignore)] // can't call foreign function `llvm.aarch64.neon.uaddlv.i32.v16i8` on OS `linux`
298    fn suggestions_to_data_maps_byte_range_to_line_col() {
299        let source = "SELECT custoser_name FROM users";
300        let rope = Rope::from_str(source);
301        let suggestion = Suggestion {
302            label: "did you mean `customer_name`?".to_string(),
303            alternatives: vec![Replacement {
304                byte_range: 7..20,
305                replacement: "customer_name".to_string(),
306            }],
307        };
308        let data = suggestions_to_data(&[suggestion], &rope).expect("non-empty");
309        assert_eq!(data.suggestions.len(), 1);
310        let alt = &data.suggestions[0].alternatives[0];
311        assert_eq!(alt.range.start, Position::new(0, 7));
312        assert_eq!(alt.range.end, Position::new(0, 20));
313        assert_eq!(alt.new_text, "customer_name");
314    }
315
316    fn lsp_range(sl: u32, sc: u32, el: u32, ec: u32) -> Range {
317        Range::new(Position::new(sl, sc), Position::new(el, ec))
318    }
319
320    fn diag_with_quickfix(qf: QuickFixData) -> Diagnostic {
321        Diagnostic {
322            range: lsp_range(0, 7, 0, 20),
323            severity: Some(DiagnosticSeverity::ERROR),
324            source: Some("mz-deploy".to_string()),
325            message: "column custoser_name does not exist".to_string(),
326            data: Some(serde_json::to_value(qf).unwrap()),
327            ..Default::default()
328        }
329    }
330
331    fn params_with(uri: Url, diag: Diagnostic) -> CodeActionParams {
332        CodeActionParams {
333            text_document: TextDocumentIdentifier { uri },
334            range: diag.range,
335            context: CodeActionContext {
336                diagnostics: vec![diag],
337                only: None,
338                trigger_kind: None,
339            },
340            work_done_progress_params: WorkDoneProgressParams::default(),
341            partial_result_params: PartialResultParams::default(),
342        }
343    }
344
345    #[mz_ore::test]
346    fn builder_emits_one_action_per_alternative() {
347        let uri = Url::parse("file:///tmp/v.sql").unwrap();
348        let qf = QuickFixData {
349            suggestions: vec![SuggestionData {
350                label: "did you mean one of these?".to_string(),
351                alternatives: vec![
352                    ReplacementData {
353                        range: lsp_range(0, 7, 0, 20),
354                        new_text: "customer_name".to_string(),
355                    },
356                    ReplacementData {
357                        range: lsp_range(0, 7, 0, 20),
358                        new_text: "customer_id".to_string(),
359                    },
360                ],
361            }],
362        };
363        let params = params_with(uri.clone(), diag_with_quickfix(qf));
364        let actions = build_code_actions(&params);
365        assert_eq!(actions.len(), 2);
366        for action in &actions {
367            let CodeActionOrCommand::CodeAction(ca) = action else {
368                panic!("expected CodeAction, got {:?}", action);
369            };
370            assert_eq!(ca.kind.as_ref(), Some(&CodeActionKind::QUICKFIX));
371            assert_eq!(ca.is_preferred, Some(false));
372            let edits = ca
373                .edit
374                .as_ref()
375                .and_then(|w| w.changes.as_ref())
376                .and_then(|c| c.get(&uri))
377                .expect("edit for file");
378            assert_eq!(edits.len(), 1);
379        }
380    }
381
382    #[mz_ore::test]
383    fn builder_marks_single_alternative_preferred() {
384        let uri = Url::parse("file:///tmp/v.sql").unwrap();
385        let qf = QuickFixData {
386            suggestions: vec![SuggestionData {
387                label: "did you mean `customer_name`?".to_string(),
388                alternatives: vec![ReplacementData {
389                    range: lsp_range(0, 7, 0, 20),
390                    new_text: "customer_name".to_string(),
391                }],
392            }],
393        };
394        let params = params_with(uri, diag_with_quickfix(qf));
395        let actions = build_code_actions(&params);
396        assert_eq!(actions.len(), 1);
397        let CodeActionOrCommand::CodeAction(ca) = &actions[0] else {
398            panic!("expected CodeAction");
399        };
400        assert_eq!(ca.is_preferred, Some(true));
401        assert!(ca.title.contains("customer_name"));
402    }
403
404    #[mz_ore::test]
405    fn builder_skips_diagnostics_without_quickfix_data() {
406        let uri = Url::parse("file:///tmp/v.sql").unwrap();
407        let diag = Diagnostic {
408            range: lsp_range(0, 7, 0, 20),
409            severity: Some(DiagnosticSeverity::ERROR),
410            source: Some("mz-deploy".to_string()),
411            message: "boring parse error".to_string(),
412            data: None,
413            ..Default::default()
414        };
415        let params = params_with(uri, diag);
416        assert!(build_code_actions(&params).is_empty());
417    }
418
419    #[mz_ore::test]
420    fn did_you_mean_returns_empty_for_no_close_match() {
421        let candidates = ["customer_name", "customer_id", "shipping_address"];
422        let out = did_you_mean("xyz", candidates.iter().map(|s| s.to_string()));
423        assert!(out.is_empty(), "expected no matches, got {:?}", out);
424    }
425
426    #[mz_ore::test]
427    fn did_you_mean_returns_exact_match_first() {
428        let candidates = ["customer_name", "customer_id"];
429        let out = did_you_mean("customer_name", candidates.iter().map(|s| s.to_string()));
430        // "customer_name" (distance 0) and "customer_id" (distance 4) are both within
431        // threshold max(2, 13/3) = 4, so both are returned, sorted by distance.
432        assert_eq!(
433            out,
434            vec!["customer_name".to_string(), "customer_id".to_string()]
435        );
436    }
437
438    #[mz_ore::test]
439    fn did_you_mean_handles_transposition() {
440        // Damerau-Levenshtein treats one transposition as distance 1.
441        let candidates = ["customer_name"];
442        let out = did_you_mean("cusotmer_name", candidates.iter().map(|s| s.to_string()));
443        assert_eq!(out, vec!["customer_name".to_string()]);
444    }
445
446    #[mz_ore::test]
447    fn did_you_mean_respects_max_three_limit() {
448        // Provide many candidates that are all close enough to hit the limit.
449        // "custoser_name" (distance 1 to): customer_name, custumer_name, cust_name, etc.
450        let candidates = [
451            "customer_name",   // distance 1
452            "custumer_name",   // distance 1 (typo: transposition)
453            "custoser_name_x", // distance 2 (one extra char)
454            "customers",       // distance 6 (exceeds threshold, excluded)
455            "x_custoser_name", // distance 2 (one extra char prefix)
456        ];
457        let out = did_you_mean("custoser_name", candidates.iter().map(|s| s.to_string()));
458        // Should return at most 3, even though multiple candidates match.
459        assert!(out.len() <= 3, "should cap at 3, got {:?}", out);
460        // Best match (distance 1) comes first.
461        assert_eq!(out[0], "customer_name");
462    }
463
464    #[mz_ore::test]
465    fn did_you_mean_skips_empty_candidates() {
466        let candidates: Vec<String> = Vec::new();
467        let out = did_you_mean("anything", candidates);
468        assert!(out.is_empty());
469    }
470
471    fn cands(
472        items: &[&str],
473        schemas: &[&str],
474        databases: &[&str],
475        clusters: &[&str],
476    ) -> Candidates {
477        Candidates {
478            items: items.iter().map(|s| s.to_string()).collect(),
479            schemas: schemas.iter().map(|s| s.to_string()).collect(),
480            databases: databases.iter().map(|s| s.to_string()).collect(),
481            clusters: clusters.iter().map(|s| s.to_string()).collect(),
482        }
483    }
484
485    #[mz_ore::test]
486    fn fuzzy_suggestions_for_unknown_item_uses_items_pool() {
487        let source = "SELECT * FROM cusotmers";
488        let primary = 14..23; // "cusotmers"
489        let kind =
490            ObjectTypeCheckErrorKind::Catalog(CatalogError::UnknownItem("cusotmers".to_string()));
491        let c = cands(&["customers", "products"], &[], &[], &[]);
492        let out = fuzzy_suggestions(&kind, source, &primary, &c);
493        assert_eq!(out.len(), 1);
494        assert_eq!(out[0].alternatives.len(), 1);
495        assert_eq!(out[0].alternatives[0].replacement, "customers");
496        assert_eq!(out[0].alternatives[0].byte_range, 14..23);
497    }
498
499    #[mz_ore::test]
500    fn fuzzy_suggestions_for_unknown_schema_uses_schemas_pool() {
501        let source = "SELECT * FROM publik.t";
502        let primary = 14..20; // "publik"
503        let kind =
504            ObjectTypeCheckErrorKind::Catalog(CatalogError::UnknownSchema("publik".to_string()));
505        let c = cands(&[], &["public", "private"], &[], &[]);
506        let out = fuzzy_suggestions(&kind, source, &primary, &c);
507        assert_eq!(out.len(), 1);
508        assert_eq!(out[0].alternatives[0].replacement, "public");
509    }
510
511    #[mz_ore::test]
512    fn fuzzy_suggestions_for_unknown_cluster_uses_clusters_pool() {
513        let source = "CREATE VIEW v IN CLUSTER quikstart AS SELECT 1";
514        let primary = 25..34; // "quikstart"
515        let kind = ObjectTypeCheckErrorKind::Catalog(CatalogError::UnknownCluster(
516            "quikstart".to_string(),
517        ));
518        let c = cands(&[], &[], &[], &["quickstart", "compute"]);
519        let out = fuzzy_suggestions(&kind, source, &primary, &c);
520        assert_eq!(out.len(), 1);
521        assert_eq!(out[0].alternatives[0].replacement, "quickstart");
522    }
523
524    #[mz_ore::test]
525    fn fuzzy_suggestions_for_kind_without_matches_returns_empty() {
526        let source = "SELECT 1";
527        let primary = 0..0;
528        let kind =
529            ObjectTypeCheckErrorKind::Catalog(CatalogError::UnknownItem("zzzzzzz".to_string()));
530        let c = cands(&["customers"], &[], &[], &[]);
531        let out = fuzzy_suggestions(&kind, source, &primary, &c);
532        assert!(out.is_empty());
533    }
534
535    #[mz_ore::test]
536    fn fuzzy_suggestions_for_unhandled_kind_returns_empty() {
537        let source = "SELECT 1";
538        let primary = 0..0;
539        let kind = ObjectTypeCheckErrorKind::Internal("whatever".to_string());
540        let c = cands(
541            &["customers"],
542            &["public"],
543            &["materialize"],
544            &["quickstart"],
545        );
546        let out = fuzzy_suggestions(&kind, source, &primary, &c);
547        assert!(out.is_empty());
548    }
549
550    #[mz_ore::test]
551    #[cfg_attr(miri, ignore)] // can't call foreign function `llvm.aarch64.neon.uaddlv.i32.v16i8` on OS `linux`
552    fn harvest_candidates_none_returns_default() {
553        let c = harvest_candidates(None);
554        assert!(c.items.is_empty());
555        assert!(c.schemas.is_empty());
556        assert!(c.databases.is_empty());
557        assert!(c.clusters.is_empty());
558    }
559}