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    fn suggestions_to_data_empty_returns_none() {
291        let rope = Rope::from_str("SELECT 1");
292        assert!(suggestions_to_data(&[], &rope).is_none());
293    }
294
295    #[mz_ore::test]
296    fn suggestions_to_data_maps_byte_range_to_line_col() {
297        let source = "SELECT custoser_name FROM users";
298        let rope = Rope::from_str(source);
299        let suggestion = Suggestion {
300            label: "did you mean `customer_name`?".to_string(),
301            alternatives: vec![Replacement {
302                byte_range: 7..20,
303                replacement: "customer_name".to_string(),
304            }],
305        };
306        let data = suggestions_to_data(&[suggestion], &rope).expect("non-empty");
307        assert_eq!(data.suggestions.len(), 1);
308        let alt = &data.suggestions[0].alternatives[0];
309        assert_eq!(alt.range.start, Position::new(0, 7));
310        assert_eq!(alt.range.end, Position::new(0, 20));
311        assert_eq!(alt.new_text, "customer_name");
312    }
313
314    fn lsp_range(sl: u32, sc: u32, el: u32, ec: u32) -> Range {
315        Range::new(Position::new(sl, sc), Position::new(el, ec))
316    }
317
318    fn diag_with_quickfix(qf: QuickFixData) -> Diagnostic {
319        Diagnostic {
320            range: lsp_range(0, 7, 0, 20),
321            severity: Some(DiagnosticSeverity::ERROR),
322            source: Some("mz-deploy".to_string()),
323            message: "column custoser_name does not exist".to_string(),
324            data: Some(serde_json::to_value(qf).unwrap()),
325            ..Default::default()
326        }
327    }
328
329    fn params_with(uri: Url, diag: Diagnostic) -> CodeActionParams {
330        CodeActionParams {
331            text_document: TextDocumentIdentifier { uri },
332            range: diag.range,
333            context: CodeActionContext {
334                diagnostics: vec![diag],
335                only: None,
336                trigger_kind: None,
337            },
338            work_done_progress_params: WorkDoneProgressParams::default(),
339            partial_result_params: PartialResultParams::default(),
340        }
341    }
342
343    #[mz_ore::test]
344    fn builder_emits_one_action_per_alternative() {
345        let uri = Url::parse("file:///tmp/v.sql").unwrap();
346        let qf = QuickFixData {
347            suggestions: vec![SuggestionData {
348                label: "did you mean one of these?".to_string(),
349                alternatives: vec![
350                    ReplacementData {
351                        range: lsp_range(0, 7, 0, 20),
352                        new_text: "customer_name".to_string(),
353                    },
354                    ReplacementData {
355                        range: lsp_range(0, 7, 0, 20),
356                        new_text: "customer_id".to_string(),
357                    },
358                ],
359            }],
360        };
361        let params = params_with(uri.clone(), diag_with_quickfix(qf));
362        let actions = build_code_actions(&params);
363        assert_eq!(actions.len(), 2);
364        for action in &actions {
365            let CodeActionOrCommand::CodeAction(ca) = action else {
366                panic!("expected CodeAction, got {:?}", action);
367            };
368            assert_eq!(ca.kind.as_ref(), Some(&CodeActionKind::QUICKFIX));
369            assert_eq!(ca.is_preferred, Some(false));
370            let edits = ca
371                .edit
372                .as_ref()
373                .and_then(|w| w.changes.as_ref())
374                .and_then(|c| c.get(&uri))
375                .expect("edit for file");
376            assert_eq!(edits.len(), 1);
377        }
378    }
379
380    #[mz_ore::test]
381    fn builder_marks_single_alternative_preferred() {
382        let uri = Url::parse("file:///tmp/v.sql").unwrap();
383        let qf = QuickFixData {
384            suggestions: vec![SuggestionData {
385                label: "did you mean `customer_name`?".to_string(),
386                alternatives: vec![ReplacementData {
387                    range: lsp_range(0, 7, 0, 20),
388                    new_text: "customer_name".to_string(),
389                }],
390            }],
391        };
392        let params = params_with(uri, diag_with_quickfix(qf));
393        let actions = build_code_actions(&params);
394        assert_eq!(actions.len(), 1);
395        let CodeActionOrCommand::CodeAction(ca) = &actions[0] else {
396            panic!("expected CodeAction");
397        };
398        assert_eq!(ca.is_preferred, Some(true));
399        assert!(ca.title.contains("customer_name"));
400    }
401
402    #[mz_ore::test]
403    fn builder_skips_diagnostics_without_quickfix_data() {
404        let uri = Url::parse("file:///tmp/v.sql").unwrap();
405        let diag = Diagnostic {
406            range: lsp_range(0, 7, 0, 20),
407            severity: Some(DiagnosticSeverity::ERROR),
408            source: Some("mz-deploy".to_string()),
409            message: "boring parse error".to_string(),
410            data: None,
411            ..Default::default()
412        };
413        let params = params_with(uri, diag);
414        assert!(build_code_actions(&params).is_empty());
415    }
416
417    #[mz_ore::test]
418    fn did_you_mean_returns_empty_for_no_close_match() {
419        let candidates = ["customer_name", "customer_id", "shipping_address"];
420        let out = did_you_mean("xyz", candidates.iter().map(|s| s.to_string()));
421        assert!(out.is_empty(), "expected no matches, got {:?}", out);
422    }
423
424    #[mz_ore::test]
425    fn did_you_mean_returns_exact_match_first() {
426        let candidates = ["customer_name", "customer_id"];
427        let out = did_you_mean("customer_name", candidates.iter().map(|s| s.to_string()));
428        // "customer_name" (distance 0) and "customer_id" (distance 4) are both within
429        // threshold max(2, 13/3) = 4, so both are returned, sorted by distance.
430        assert_eq!(
431            out,
432            vec!["customer_name".to_string(), "customer_id".to_string()]
433        );
434    }
435
436    #[mz_ore::test]
437    fn did_you_mean_handles_transposition() {
438        // Damerau-Levenshtein treats one transposition as distance 1.
439        let candidates = ["customer_name"];
440        let out = did_you_mean("cusotmer_name", candidates.iter().map(|s| s.to_string()));
441        assert_eq!(out, vec!["customer_name".to_string()]);
442    }
443
444    #[mz_ore::test]
445    fn did_you_mean_respects_max_three_limit() {
446        // Provide many candidates that are all close enough to hit the limit.
447        // "custoser_name" (distance 1 to): customer_name, custumer_name, cust_name, etc.
448        let candidates = [
449            "customer_name",   // distance 1
450            "custumer_name",   // distance 1 (typo: transposition)
451            "custoser_name_x", // distance 2 (one extra char)
452            "customers",       // distance 6 (exceeds threshold, excluded)
453            "x_custoser_name", // distance 2 (one extra char prefix)
454        ];
455        let out = did_you_mean("custoser_name", candidates.iter().map(|s| s.to_string()));
456        // Should return at most 3, even though multiple candidates match.
457        assert!(out.len() <= 3, "should cap at 3, got {:?}", out);
458        // Best match (distance 1) comes first.
459        assert_eq!(out[0], "customer_name");
460    }
461
462    #[mz_ore::test]
463    fn did_you_mean_skips_empty_candidates() {
464        let candidates: Vec<String> = Vec::new();
465        let out = did_you_mean("anything", candidates);
466        assert!(out.is_empty());
467    }
468
469    fn cands(
470        items: &[&str],
471        schemas: &[&str],
472        databases: &[&str],
473        clusters: &[&str],
474    ) -> Candidates {
475        Candidates {
476            items: items.iter().map(|s| s.to_string()).collect(),
477            schemas: schemas.iter().map(|s| s.to_string()).collect(),
478            databases: databases.iter().map(|s| s.to_string()).collect(),
479            clusters: clusters.iter().map(|s| s.to_string()).collect(),
480        }
481    }
482
483    #[mz_ore::test]
484    fn fuzzy_suggestions_for_unknown_item_uses_items_pool() {
485        let source = "SELECT * FROM cusotmers";
486        let primary = 14..23; // "cusotmers"
487        let kind =
488            ObjectTypeCheckErrorKind::Catalog(CatalogError::UnknownItem("cusotmers".to_string()));
489        let c = cands(&["customers", "products"], &[], &[], &[]);
490        let out = fuzzy_suggestions(&kind, source, &primary, &c);
491        assert_eq!(out.len(), 1);
492        assert_eq!(out[0].alternatives.len(), 1);
493        assert_eq!(out[0].alternatives[0].replacement, "customers");
494        assert_eq!(out[0].alternatives[0].byte_range, 14..23);
495    }
496
497    #[mz_ore::test]
498    fn fuzzy_suggestions_for_unknown_schema_uses_schemas_pool() {
499        let source = "SELECT * FROM publik.t";
500        let primary = 14..20; // "publik"
501        let kind =
502            ObjectTypeCheckErrorKind::Catalog(CatalogError::UnknownSchema("publik".to_string()));
503        let c = cands(&[], &["public", "private"], &[], &[]);
504        let out = fuzzy_suggestions(&kind, source, &primary, &c);
505        assert_eq!(out.len(), 1);
506        assert_eq!(out[0].alternatives[0].replacement, "public");
507    }
508
509    #[mz_ore::test]
510    fn fuzzy_suggestions_for_unknown_cluster_uses_clusters_pool() {
511        let source = "CREATE VIEW v IN CLUSTER quikstart AS SELECT 1";
512        let primary = 25..34; // "quikstart"
513        let kind = ObjectTypeCheckErrorKind::Catalog(CatalogError::UnknownCluster(
514            "quikstart".to_string(),
515        ));
516        let c = cands(&[], &[], &[], &["quickstart", "compute"]);
517        let out = fuzzy_suggestions(&kind, source, &primary, &c);
518        assert_eq!(out.len(), 1);
519        assert_eq!(out[0].alternatives[0].replacement, "quickstart");
520    }
521
522    #[mz_ore::test]
523    fn fuzzy_suggestions_for_kind_without_matches_returns_empty() {
524        let source = "SELECT 1";
525        let primary = 0..0;
526        let kind =
527            ObjectTypeCheckErrorKind::Catalog(CatalogError::UnknownItem("zzzzzzz".to_string()));
528        let c = cands(&["customers"], &[], &[], &[]);
529        let out = fuzzy_suggestions(&kind, source, &primary, &c);
530        assert!(out.is_empty());
531    }
532
533    #[mz_ore::test]
534    fn fuzzy_suggestions_for_unhandled_kind_returns_empty() {
535        let source = "SELECT 1";
536        let primary = 0..0;
537        let kind = ObjectTypeCheckErrorKind::Internal("whatever".to_string());
538        let c = cands(
539            &["customers"],
540            &["public"],
541            &["materialize"],
542            &["quickstart"],
543        );
544        let out = fuzzy_suggestions(&kind, source, &primary, &c);
545        assert!(out.is_empty());
546    }
547
548    #[mz_ore::test]
549    fn harvest_candidates_none_returns_default() {
550        let c = harvest_candidates(None);
551        assert!(c.items.is_empty());
552        assert!(c.schemas.is_empty());
553        assert!(c.databases.is_empty());
554        assert!(c.clusters.is_empty());
555    }
556}