1use 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#[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
57pub(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
91pub(crate) fn build_code_actions(params: &CodeActionParams) -> Vec<CodeActionOrCommand> {
95 let uri = ¶ms.text_document.uri;
96 let mut actions = Vec::new();
97 for diag in ¶ms.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#[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
158pub(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
208pub(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
248const MAX_DID_YOU_MEAN: usize = 3;
250
251pub(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(¶ms);
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(¶ms);
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(¶ms).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 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 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 let candidates = [
449 "customer_name", "custumer_name", "custoser_name_x", "customers", "x_custoser_name", ];
455 let out = did_you_mean("custoser_name", candidates.iter().map(|s| s.to_string()));
456 assert!(out.len() <= 3, "should cap at 3, got {:?}", out);
458 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; 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; 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; 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}