1use 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#[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
58pub(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
92pub(crate) fn build_code_actions(params: &CodeActionParams) -> Vec<CodeActionOrCommand> {
96 let uri = ¶ms.text_document.uri;
97 let mut actions = Vec::new();
98 for diag in ¶ms.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#[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
159pub(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
209pub(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)] 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)] 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(¶ms);
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(¶ms);
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(¶ms).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; 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; 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; 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)] 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}