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