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 #[cfg_attr(miri, ignore)] 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)] 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(¶ms);
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(¶ms);
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(¶ms).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 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 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 let candidates = [
451 "customer_name", "custumer_name", "custoser_name_x", "customers", "x_custoser_name", ];
457 let out = did_you_mean("custoser_name", candidates.iter().map(|s| s.to_string()));
458 assert!(out.len() <= 3, "should cap at 3, got {:?}", out);
460 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; 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; 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; 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)] 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}