1use crate::diagnostics::{PositionalDiagnostic, Severity, Suggestion, locate_typecheck};
37use crate::fs::FileSystem;
38use crate::project::compiler::typecheck::{ObjectTypeCheckError, TypeCheckError};
39use crate::project::error::ValidationError;
40use crate::project::syntax::variables::{resolve_variables, resolved_to_original};
41use ropey::Rope;
42use std::collections::BTreeMap;
43use std::path::{Path, PathBuf};
44use tower_lsp::lsp_types::{Diagnostic, DiagnosticSeverity, Position, Range};
45
46pub fn diagnose(
65 text: &str,
66 rope: &Rope,
67 variables: &BTreeMap<String, String>,
68 profile_name: Option<&str>,
69) -> Vec<Diagnostic> {
70 if text.trim().is_empty() {
71 return Vec::new();
72 }
73
74 parse_positional(text, variables, profile_name)
75 .iter()
76 .map(|pd| to_lsp(pd, rope))
77 .collect()
78}
79
80fn parse_positional(
84 text: &str,
85 variables: &BTreeMap<String, String>,
86 profile_name: Option<&str>,
87) -> Vec<PositionalDiagnostic> {
88 let resolved = resolve_variables(text, variables);
89 let mut pds = Vec::new();
90
91 let var_severity = if resolved.has_warn_pragma {
92 Severity::Warning
93 } else {
94 Severity::Error
95 };
96 for uv in &resolved.unresolved {
97 let message = match profile_name {
98 Some(name) => format!(
99 "undefined variable ':{}' — define in [{}.variables] in project.toml",
100 uv.name, name
101 ),
102 None => format!(
103 "undefined variable ':{}' — no profile is selected; run `mz-deploy profile set <name>` and define in [<profile>.variables] in project.toml",
104 uv.name
105 ),
106 };
107 pds.push(PositionalDiagnostic {
108 severity: var_severity,
109 file: PathBuf::new(),
110 source: text.to_string(),
111 byte_range: uv.byte_offset..(uv.byte_offset + uv.byte_len),
112 message,
113 footers: Vec::new(),
114 suggestions: Vec::new(),
115 });
116 }
117
118 if let Err(e) = mz_sql_parser::parser::parse_statements(&resolved.sql) {
119 let original_offset = resolved_to_original(e.error.pos, &resolved.substitutions);
120 pds.push(PositionalDiagnostic {
121 severity: Severity::Error,
122 file: PathBuf::new(),
123 source: text.to_string(),
124 byte_range: original_offset..original_offset,
125 message: e.error.message.clone(),
126 footers: Vec::new(),
127 suggestions: Vec::new(),
128 });
129 }
130
131 pds
132}
133
134pub(crate) fn validation_diagnostics(
142 fs: &FileSystem,
143 errors: &[ValidationError],
144) -> BTreeMap<PathBuf, Vec<Diagnostic>> {
145 let mut map: BTreeMap<PathBuf, Vec<Diagnostic>> = BTreeMap::new();
146 let mut source_cache: BTreeMap<PathBuf, Option<(String, Rope)>> = BTreeMap::new();
147 let zero = Position::new(0, 0);
148
149 for error in errors {
150 let entry = source_cache
151 .entry(error.context.file.clone())
152 .or_insert_with(|| read_source(fs, &error.context.file));
153
154 let diag = match (entry.as_ref(), error.context.byte_offset) {
155 (Some((source, rope)), Some(offset)) => {
156 let primary_range =
157 crate::diagnostics::locate_validation(&error.kind, source, Some(offset))
158 .unwrap_or(offset..offset);
159 let (body, footers, suggestions) =
160 crate::diagnostics::format_validation_kind(&error.kind, source, &primary_range);
161 let mut message = body;
162 append_detail_and_hints(&mut message, None, &footers);
163 let mut diag = build_error_diagnostic(primary_range, message, rope);
164 attach_quickfix_data(&mut diag, &suggestions, rope);
165 diag
166 }
167 _ => Diagnostic {
168 range: Range::new(zero, zero),
169 severity: Some(DiagnosticSeverity::ERROR),
170 source: Some("mz-deploy".to_string()),
171 message: error.kind.message(),
172 ..Default::default()
173 },
174 };
175
176 map.entry(error.context.file.clone())
177 .or_default()
178 .push(diag);
179 }
180
181 map
182}
183
184pub(crate) fn typecheck_diagnostics(
195 fs: &FileSystem,
196 error: &TypeCheckError,
197 candidates: &crate::lsp::code_action::Candidates,
198) -> BTreeMap<PathBuf, Vec<Diagnostic>> {
199 let errors: &[ObjectTypeCheckError] = match error {
200 TypeCheckError::Multiple(errs) => errs.as_slice(),
201 TypeCheckError::DatabaseSetupError(_)
202 | TypeCheckError::SortError(_)
203 | TypeCheckError::TypesCacheWriteFailed(_) => &[],
204 };
205
206 let mut map: BTreeMap<PathBuf, Vec<Diagnostic>> = BTreeMap::new();
207 let mut source_cache: BTreeMap<PathBuf, Option<(String, Rope)>> = BTreeMap::new();
208 let zero = Position::new(0, 0);
209
210 for e in errors {
211 let entry = source_cache
212 .entry(e.file_path.clone())
213 .or_insert_with(|| read_source(fs, &e.file_path));
214
215 let diag = match entry.as_ref() {
216 Some((source, rope)) => {
217 let byte_range = locate_typecheck(&e.kind, source).unwrap_or(0..0);
218 let (body, footers, format_suggestions) =
219 crate::diagnostics::format_typecheck_kind(&e.kind, source, &byte_range);
220 let suggestions = if format_suggestions.is_empty() {
221 crate::lsp::code_action::fuzzy_suggestions(
222 &e.kind,
223 source,
224 &byte_range,
225 candidates,
226 )
227 } else {
228 format_suggestions
229 };
230
231 let mut message = body;
232 append_detail_and_hints(&mut message, e.detail().as_deref(), &footers);
233 let mut diag = build_error_diagnostic(byte_range, message, rope);
234 attach_quickfix_data(&mut diag, &suggestions, rope);
235 diag
236 }
237 None => {
238 let mut message = e.error_message();
240 let footers: Vec<String> = e.hint().into_iter().collect();
241 append_detail_and_hints(&mut message, e.detail().as_deref(), &footers);
242 Diagnostic {
243 range: Range::new(zero, zero),
244 severity: Some(DiagnosticSeverity::ERROR),
245 source: Some("mz-deploy".to_string()),
246 message,
247 ..Default::default()
248 }
249 }
250 };
251
252 map.entry(e.file_path.clone()).or_default().push(diag);
253 }
254
255 map
256}
257
258fn read_source(fs: &FileSystem, path: &Path) -> Option<(String, Rope)> {
259 let text = fs.read_to_string(path).ok()?;
260 let rope = Rope::from_str(&text);
261 Some((text, rope))
262}
263
264fn build_error_diagnostic(
269 byte_range: std::ops::Range<usize>,
270 message: String,
271 rope: &Rope,
272) -> Diagnostic {
273 let zero = Position::new(0, 0);
274 let start = offset_to_position(byte_range.start, rope).unwrap_or(zero);
275 let end = offset_to_position(byte_range.end, rope).unwrap_or(start);
276 Diagnostic {
277 range: Range::new(start, end),
278 severity: Some(DiagnosticSeverity::ERROR),
279 source: Some("mz-deploy".to_string()),
280 message,
281 ..Default::default()
282 }
283}
284
285fn append_detail_and_hints(message: &mut String, detail: Option<&str>, footers: &[String]) {
289 if let Some(detail) = detail {
290 message.push_str("\ndetail: ");
291 message.push_str(detail);
292 }
293 for footer in footers {
294 message.push_str("\nhint: ");
295 message.push_str(footer);
296 }
297}
298
299fn attach_quickfix_data(diag: &mut Diagnostic, suggestions: &[Suggestion], rope: &Rope) {
302 if let Some(qf) = crate::lsp::code_action::suggestions_to_data(suggestions, rope) {
303 diag.data = Some(serde_json::to_value(qf).expect("serializable"));
304 }
305}
306
307fn to_lsp(pd: &PositionalDiagnostic, rope: &Rope) -> Diagnostic {
313 let zero = Position::new(0, 0);
314 let start = offset_to_position(pd.byte_range.start, rope).unwrap_or(zero);
315 let end = offset_to_position(pd.byte_range.end, rope).unwrap_or(start);
316 let severity = match pd.severity {
317 Severity::Error => DiagnosticSeverity::ERROR,
318 Severity::Warning => DiagnosticSeverity::WARNING,
319 };
320 Diagnostic {
321 range: Range::new(start, end),
322 severity: Some(severity),
323 source: Some("mz-deploy".to_string()),
324 message: pd.message.clone(),
325 ..Default::default()
326 }
327}
328
329pub(crate) fn offset_to_position(offset: usize, rope: &Rope) -> Option<Position> {
331 let char_offset = rope.try_byte_to_char(offset).ok()?;
332 let line = rope.try_char_to_line(char_offset).ok()?;
333 let first_char_of_line = rope.try_line_to_char(line).ok()?;
334 let line_start_byte = rope.try_char_to_byte(first_char_of_line).ok()?;
335 let column = utf16_len(rope.byte_slice(line_start_byte..offset).as_str()?);
336
337 let line_u32 = line.try_into().ok()?;
338 let column_u32 = column.try_into().ok()?;
339
340 Some(Position::new(line_u32, column_u32))
341}
342
343pub(crate) fn position_to_offset(position: Position, rope: &Rope) -> Option<usize> {
347 let line = usize::try_from(position.line).ok()?;
348 let target_col = usize::try_from(position.character).ok()?;
349 let line_start_char = rope.try_line_to_char(line).ok()?;
350 let line_text = rope.get_line(line)?;
351
352 let mut utf16_col = 0usize;
353 let mut char_delta = 0usize;
354 for ch in line_text.chars() {
355 if utf16_col >= target_col {
356 break;
357 }
358 let next = utf16_col + ch.len_utf16();
359 if next > target_col {
360 break;
361 }
362 utf16_col = next;
363 char_delta += 1;
364 }
365
366 let char_offset = line_start_char + char_delta;
367 rope.try_char_to_byte(char_offset).ok()
368}
369
370fn utf16_len(text: &str) -> usize {
371 text.chars().map(char::len_utf16).sum()
372}
373
374#[cfg(test)]
375mod tests {
376 use super::*;
377
378 #[cfg_attr(miri, ignore)] #[mz_ore::test]
380 fn valid_sql_produces_no_diagnostics() {
381 let text = "CREATE VIEW foo AS SELECT 1;";
382 let rope = Rope::from_str(text);
383 assert!(diagnose(text, &rope, &BTreeMap::new(), None).is_empty());
384 }
385
386 #[cfg_attr(miri, ignore)] #[mz_ore::test]
388 fn syntax_error_produces_diagnostic_at_correct_position() {
389 let text = "CREATE VIEW foo AS SELECTT 1;";
390 let rope = Rope::from_str(text);
391 let diags = diagnose(text, &rope, &BTreeMap::new(), None);
392 assert_eq!(diags.len(), 1);
393 assert_eq!(diags[0].severity, Some(DiagnosticSeverity::ERROR));
394 assert_eq!(diags[0].range.start.line, 0);
396 }
397
398 #[cfg_attr(miri, ignore)] #[mz_ore::test]
400 fn multiline_error_position() {
401 let text = "CREATE VIEW foo AS\nSELECT 1;\nCREATE VIEW bar AS SELECTT 2;";
402 let rope = Rope::from_str(text);
403 let diags = diagnose(text, &rope, &BTreeMap::new(), None);
404 assert_eq!(diags.len(), 1);
405 assert_eq!(diags[0].range.start.line, 2);
407 }
408
409 #[mz_ore::test]
410 #[cfg_attr(miri, ignore)] fn empty_file_produces_no_diagnostics() {
412 let text = "";
413 let rope = Rope::from_str(text);
414 assert!(diagnose(text, &rope, &BTreeMap::new(), None).is_empty());
415 }
416
417 #[mz_ore::test]
418 #[cfg_attr(miri, ignore)] fn offset_to_position_uses_utf16_columns() {
420 let text = "SELECT 😀FROM";
421 let rope = Rope::from_str(text);
422 assert_eq!(offset_to_position(11, &rope), Some(Position::new(0, 9)));
424 }
425
426 #[mz_ore::test]
427 #[cfg_attr(miri, ignore)] fn position_to_offset_uses_utf16_columns() {
429 let text = "SELECT 😀foo";
430 let rope = Rope::from_str(text);
431 assert_eq!(position_to_offset(Position::new(0, 9), &rope), Some(11));
433 assert_eq!(
434 position_to_offset(Position::new(0, 12), &rope),
435 Some(text.len())
436 );
437 }
438
439 #[mz_ore::test]
440 #[cfg_attr(miri, ignore)] fn position_to_offset_at_line_past_end_returns_none() {
442 let rope = Rope::from_str("a\nb");
443 let past_end = u32::try_from(rope.len_lines()).unwrap();
444 assert_eq!(position_to_offset(Position::new(past_end, 0), &rope), None);
445 }
446
447 #[mz_ore::test]
448 #[cfg_attr(miri, ignore)] fn whitespace_only_file_produces_no_diagnostics() {
450 let text = " \n \n ";
451 let rope = Rope::from_str(text);
452 assert!(diagnose(text, &rope, &BTreeMap::new(), None).is_empty());
453 }
454
455 fn vars(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
458 pairs
459 .iter()
460 .map(|(k, v)| (k.to_string(), v.to_string()))
461 .collect()
462 }
463
464 #[cfg_attr(miri, ignore)] #[mz_ore::test]
466 fn resolved_variable_no_diagnostics() {
467 let text = "CREATE MATERIALIZED VIEW mv IN CLUSTER quickstart AS SELECT 1";
468 let rope = Rope::from_str(text);
469 let diags = diagnose(text, &rope, &BTreeMap::new(), None);
470 assert!(diags.is_empty());
471 }
472
473 #[cfg_attr(miri, ignore)] #[mz_ore::test]
475 fn resolved_variable_produces_clean_parse() {
476 let v = vars(&[("cluster", "quickstart")]);
477 let text = "CREATE MATERIALIZED VIEW mv IN CLUSTER :cluster AS SELECT 1";
478 let rope = Rope::from_str(text);
479 let diags = diagnose(text, &rope, &v, None);
480 assert!(diags.is_empty());
481 }
482
483 #[mz_ore::test]
484 #[cfg_attr(miri, ignore)] fn unresolved_variable_produces_error() {
486 let text = "CREATE MATERIALIZED VIEW mv IN CLUSTER :cluster AS SELECT 1";
487 let rope = Rope::from_str(text);
488 let diags = diagnose(text, &rope, &BTreeMap::new(), None);
489 let var_diags: Vec<_> = diags
491 .iter()
492 .filter(|d| d.message.contains("undefined variable"))
493 .collect();
494 assert_eq!(var_diags.len(), 1);
495 assert_eq!(var_diags[0].severity, Some(DiagnosticSeverity::ERROR));
496 assert!(var_diags[0].message.contains(":cluster"));
498 }
499
500 #[mz_ore::test]
501 #[cfg_attr(miri, ignore)] fn unresolved_variable_with_pragma_produces_warning() {
503 let text = "-- PRAGMA WARN_ON_MISSING_VARIABLES;\nCREATE MATERIALIZED VIEW mv IN CLUSTER :cluster AS SELECT 1";
504 let rope = Rope::from_str(text);
505 let diags = diagnose(text, &rope, &BTreeMap::new(), None);
506 let var_diags: Vec<_> = diags
507 .iter()
508 .filter(|d| d.message.contains("undefined variable"))
509 .collect();
510 assert_eq!(var_diags.len(), 1);
511 assert_eq!(var_diags[0].severity, Some(DiagnosticSeverity::WARNING));
512 }
513
514 #[cfg_attr(miri, ignore)] #[mz_ore::test]
516 fn parse_error_maps_back_to_original_position() {
517 let v = vars(&[("x", "ab")]);
520 let text = "CREATE VIEW :x AS SELECTT 1";
522 let rope = Rope::from_str(text);
523 let diags = diagnose(text, &rope, &v, None);
524 let parse_diags: Vec<_> = diags
526 .iter()
527 .filter(|d| !d.message.contains("undefined variable"))
528 .collect();
529 assert_eq!(parse_diags.len(), 1);
530 assert_eq!(parse_diags[0].severity, Some(DiagnosticSeverity::ERROR));
531 assert_eq!(parse_diags[0].range.start.line, 0);
532 }
533
534 #[cfg_attr(miri, ignore)] #[mz_ore::test]
536 fn no_variables_unchanged_behavior() {
537 let text = "CREATE VIEW foo AS SELECT 1;";
538 let rope = Rope::from_str(text);
539 assert!(diagnose(text, &rope, &BTreeMap::new(), None).is_empty());
540 }
541
542 #[mz_ore::test]
543 #[cfg_attr(miri, ignore)] fn typecheck_unknown_column_attaches_quickfix_data() {
545 use crate::lsp::code_action::{Candidates, QuickFixData};
546 use crate::project::compiler::typecheck::{ObjectTypeCheckError, ObjectTypeCheckErrorKind};
547 use crate::project::ir::object_id::ObjectId;
548 use mz_repr::ColumnName;
549 use mz_sql::plan::PlanError;
550 use std::sync::Arc;
551
552 let source = "SELECT custoser_name FROM users";
553 let path = std::env::temp_dir().join("typecheck_qf_test.sql");
554 std::fs::write(&path, source).unwrap();
555
556 let plan_err = PlanError::UnknownColumn {
557 table: None,
558 column: ColumnName::from("custoser_name"),
559 similar: Box::new([ColumnName::from("customer_name")]),
560 };
561 let err = ObjectTypeCheckError {
562 object_id: ObjectId::new(
563 "materialize".to_string(),
564 "public".to_string(),
565 "v".to_string(),
566 ),
567 file_path: path.clone(),
568 kind: ObjectTypeCheckErrorKind::Plan(Arc::new(plan_err)),
569 };
570 let tc = TypeCheckError::Multiple(vec![err]);
571
572 let fs = FileSystem::default();
573 let candidates = Candidates::default();
574 let map = typecheck_diagnostics(&fs, &tc, &candidates);
575 let diags = map.get(&path).expect("diags for file");
576 assert_eq!(diags.len(), 1);
577
578 let data = diags[0]
579 .data
580 .as_ref()
581 .expect("Diagnostic.data should be set when suggestions exist");
582 let qf: QuickFixData = serde_json::from_value(data.clone()).expect("decodes");
583 assert_eq!(qf.suggestions.len(), 1);
584 assert_eq!(qf.suggestions[0].alternatives.len(), 1);
585 assert_eq!(qf.suggestions[0].alternatives[0].new_text, "customer_name");
586 assert!(
587 diags[0]
588 .message
589 .contains("column custoser_name does not exist")
590 );
591 let _ = std::fs::remove_file(&path);
592 }
593
594 #[mz_ore::test]
595 #[cfg_attr(miri, ignore)] fn typecheck_unknown_item_attaches_fuzzy_quickfix_data() {
597 use crate::lsp::code_action::{Candidates, QuickFixData};
598 use crate::project::compiler::typecheck::{ObjectTypeCheckError, ObjectTypeCheckErrorKind};
599 use crate::project::ir::object_id::ObjectId;
600 use mz_sql::catalog::CatalogError;
601
602 let source = "SELECT * FROM cusotmers";
603 let path = std::env::temp_dir().join("typecheck_fuzzy_test.sql");
604 std::fs::write(&path, source).unwrap();
605
606 let err = ObjectTypeCheckError {
607 object_id: ObjectId::new(
608 "materialize".to_string(),
609 "public".to_string(),
610 "v".to_string(),
611 ),
612 file_path: path.clone(),
613 kind: ObjectTypeCheckErrorKind::Catalog(CatalogError::UnknownItem(
614 "cusotmers".to_string(),
615 )),
616 };
617 let tc = TypeCheckError::Multiple(vec![err]);
618
619 let fs = FileSystem::default();
620 let candidates = Candidates {
621 items: vec!["customers".to_string()],
622 ..Default::default()
623 };
624 let map = typecheck_diagnostics(&fs, &tc, &candidates);
625 let diags = map.get(&path).expect("diags for file");
626 assert_eq!(diags.len(), 1);
627
628 let data = diags[0]
629 .data
630 .as_ref()
631 .expect("Diagnostic.data should be set");
632 let qf: QuickFixData = serde_json::from_value(data.clone()).expect("decodes");
633 assert_eq!(qf.suggestions.len(), 1);
634 assert_eq!(qf.suggestions[0].alternatives.len(), 1);
635 assert_eq!(qf.suggestions[0].alternatives[0].new_text, "customers");
636 let _ = std::fs::remove_file(&path);
637 }
638
639 #[mz_ore::test]
640 #[cfg_attr(miri, ignore)] fn validation_object_name_mismatch_attaches_quickfix_data() {
642 use crate::lsp::code_action::QuickFixData;
643 use crate::project::error::validation::ErrorContext;
644 use crate::project::error::{ValidationError, ValidationErrorKind};
645
646 let source = "CREATE TABLE customers (id INT);";
647 let path = std::env::temp_dir().join("validation_qf_test.sql");
648 std::fs::write(&path, source).unwrap();
649
650 let err = ValidationError {
651 kind: ValidationErrorKind::ObjectNameMismatch {
652 declared: "customers".to_string(),
653 expected: "users".to_string(),
654 },
655 context: ErrorContext {
656 file: path.clone(),
657 sql_statement: Some(source.to_string()),
658 byte_offset: Some(0),
659 },
660 };
661
662 let fs = FileSystem::default();
663 let map = validation_diagnostics(&fs, &[err]);
664 let diags = map.get(&path).expect("diags for file");
665 assert_eq!(diags.len(), 1);
666
667 let data = diags[0]
668 .data
669 .as_ref()
670 .expect("Diagnostic.data should be set");
671 let qf: QuickFixData = serde_json::from_value(data.clone()).expect("decodes");
672 assert_eq!(qf.suggestions[0].alternatives[0].new_text, "users");
673 let _ = std::fs::remove_file(&path);
674 }
675}