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> {
345 let line = usize::try_from(position.line).ok()?;
346 let target_col = usize::try_from(position.character).ok()?;
347 let line_start_char = rope.try_line_to_char(line).ok()?;
348 let line_text = rope.line(line);
349
350 let mut utf16_col = 0usize;
351 let mut char_delta = 0usize;
352 for ch in line_text.chars() {
353 if utf16_col >= target_col {
354 break;
355 }
356 let next = utf16_col + ch.len_utf16();
357 if next > target_col {
358 break;
359 }
360 utf16_col = next;
361 char_delta += 1;
362 }
363
364 let char_offset = line_start_char + char_delta;
365 rope.try_char_to_byte(char_offset).ok()
366}
367
368fn utf16_len(text: &str) -> usize {
369 text.chars().map(char::len_utf16).sum()
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375
376 #[cfg_attr(miri, ignore)] #[mz_ore::test]
378 fn valid_sql_produces_no_diagnostics() {
379 let text = "CREATE VIEW foo AS SELECT 1;";
380 let rope = Rope::from_str(text);
381 assert!(diagnose(text, &rope, &BTreeMap::new(), None).is_empty());
382 }
383
384 #[cfg_attr(miri, ignore)] #[mz_ore::test]
386 fn syntax_error_produces_diagnostic_at_correct_position() {
387 let text = "CREATE VIEW foo AS SELECTT 1;";
388 let rope = Rope::from_str(text);
389 let diags = diagnose(text, &rope, &BTreeMap::new(), None);
390 assert_eq!(diags.len(), 1);
391 assert_eq!(diags[0].severity, Some(DiagnosticSeverity::ERROR));
392 assert_eq!(diags[0].range.start.line, 0);
394 }
395
396 #[cfg_attr(miri, ignore)] #[mz_ore::test]
398 fn multiline_error_position() {
399 let text = "CREATE VIEW foo AS\nSELECT 1;\nCREATE VIEW bar AS SELECTT 2;";
400 let rope = Rope::from_str(text);
401 let diags = diagnose(text, &rope, &BTreeMap::new(), None);
402 assert_eq!(diags.len(), 1);
403 assert_eq!(diags[0].range.start.line, 2);
405 }
406
407 #[mz_ore::test]
408 fn empty_file_produces_no_diagnostics() {
409 let text = "";
410 let rope = Rope::from_str(text);
411 assert!(diagnose(text, &rope, &BTreeMap::new(), None).is_empty());
412 }
413
414 #[mz_ore::test]
415 fn offset_to_position_uses_utf16_columns() {
416 let text = "SELECT 😀FROM";
417 let rope = Rope::from_str(text);
418 assert_eq!(offset_to_position(11, &rope), Some(Position::new(0, 9)));
420 }
421
422 #[mz_ore::test]
423 fn position_to_offset_uses_utf16_columns() {
424 let text = "SELECT 😀foo";
425 let rope = Rope::from_str(text);
426 assert_eq!(position_to_offset(Position::new(0, 9), &rope), Some(11));
428 assert_eq!(
429 position_to_offset(Position::new(0, 12), &rope),
430 Some(text.len())
431 );
432 }
433
434 #[mz_ore::test]
435 fn whitespace_only_file_produces_no_diagnostics() {
436 let text = " \n \n ";
437 let rope = Rope::from_str(text);
438 assert!(diagnose(text, &rope, &BTreeMap::new(), None).is_empty());
439 }
440
441 fn vars(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
444 pairs
445 .iter()
446 .map(|(k, v)| (k.to_string(), v.to_string()))
447 .collect()
448 }
449
450 #[cfg_attr(miri, ignore)] #[mz_ore::test]
452 fn resolved_variable_no_diagnostics() {
453 let text = "CREATE MATERIALIZED VIEW mv IN CLUSTER quickstart AS SELECT 1";
454 let rope = Rope::from_str(text);
455 let diags = diagnose(text, &rope, &BTreeMap::new(), None);
456 assert!(diags.is_empty());
457 }
458
459 #[cfg_attr(miri, ignore)] #[mz_ore::test]
461 fn resolved_variable_produces_clean_parse() {
462 let v = vars(&[("cluster", "quickstart")]);
463 let text = "CREATE MATERIALIZED VIEW mv IN CLUSTER :cluster AS SELECT 1";
464 let rope = Rope::from_str(text);
465 let diags = diagnose(text, &rope, &v, None);
466 assert!(diags.is_empty());
467 }
468
469 #[mz_ore::test]
470 fn unresolved_variable_produces_error() {
471 let text = "CREATE MATERIALIZED VIEW mv IN CLUSTER :cluster AS SELECT 1";
472 let rope = Rope::from_str(text);
473 let diags = diagnose(text, &rope, &BTreeMap::new(), None);
474 let var_diags: Vec<_> = diags
476 .iter()
477 .filter(|d| d.message.contains("undefined variable"))
478 .collect();
479 assert_eq!(var_diags.len(), 1);
480 assert_eq!(var_diags[0].severity, Some(DiagnosticSeverity::ERROR));
481 assert!(var_diags[0].message.contains(":cluster"));
483 }
484
485 #[mz_ore::test]
486 fn unresolved_variable_with_pragma_produces_warning() {
487 let text = "-- PRAGMA WARN_ON_MISSING_VARIABLES;\nCREATE MATERIALIZED VIEW mv IN CLUSTER :cluster AS SELECT 1";
488 let rope = Rope::from_str(text);
489 let diags = diagnose(text, &rope, &BTreeMap::new(), None);
490 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::WARNING));
496 }
497
498 #[cfg_attr(miri, ignore)] #[mz_ore::test]
500 fn parse_error_maps_back_to_original_position() {
501 let v = vars(&[("x", "ab")]);
504 let text = "CREATE VIEW :x AS SELECTT 1";
506 let rope = Rope::from_str(text);
507 let diags = diagnose(text, &rope, &v, None);
508 let parse_diags: Vec<_> = diags
510 .iter()
511 .filter(|d| !d.message.contains("undefined variable"))
512 .collect();
513 assert_eq!(parse_diags.len(), 1);
514 assert_eq!(parse_diags[0].severity, Some(DiagnosticSeverity::ERROR));
515 assert_eq!(parse_diags[0].range.start.line, 0);
516 }
517
518 #[cfg_attr(miri, ignore)] #[mz_ore::test]
520 fn no_variables_unchanged_behavior() {
521 let text = "CREATE VIEW foo AS SELECT 1;";
522 let rope = Rope::from_str(text);
523 assert!(diagnose(text, &rope, &BTreeMap::new(), None).is_empty());
524 }
525
526 #[mz_ore::test]
527 fn typecheck_unknown_column_attaches_quickfix_data() {
528 use crate::lsp::code_action::{Candidates, QuickFixData};
529 use crate::project::compiler::typecheck::{ObjectTypeCheckError, ObjectTypeCheckErrorKind};
530 use crate::project::ir::object_id::ObjectId;
531 use mz_repr::ColumnName;
532 use mz_sql::plan::PlanError;
533 use std::sync::Arc;
534
535 let source = "SELECT custoser_name FROM users";
536 let path = std::env::temp_dir().join("typecheck_qf_test.sql");
537 std::fs::write(&path, source).unwrap();
538
539 let plan_err = PlanError::UnknownColumn {
540 table: None,
541 column: ColumnName::from("custoser_name"),
542 similar: Box::new([ColumnName::from("customer_name")]),
543 };
544 let err = ObjectTypeCheckError {
545 object_id: ObjectId::new(
546 "materialize".to_string(),
547 "public".to_string(),
548 "v".to_string(),
549 ),
550 file_path: path.clone(),
551 kind: ObjectTypeCheckErrorKind::Plan(Arc::new(plan_err)),
552 };
553 let tc = TypeCheckError::Multiple(vec![err]);
554
555 let fs = FileSystem::default();
556 let candidates = Candidates::default();
557 let map = typecheck_diagnostics(&fs, &tc, &candidates);
558 let diags = map.get(&path).expect("diags for file");
559 assert_eq!(diags.len(), 1);
560
561 let data = diags[0]
562 .data
563 .as_ref()
564 .expect("Diagnostic.data should be set when suggestions exist");
565 let qf: QuickFixData = serde_json::from_value(data.clone()).expect("decodes");
566 assert_eq!(qf.suggestions.len(), 1);
567 assert_eq!(qf.suggestions[0].alternatives.len(), 1);
568 assert_eq!(qf.suggestions[0].alternatives[0].new_text, "customer_name");
569 assert!(
570 diags[0]
571 .message
572 .contains("column custoser_name does not exist")
573 );
574 let _ = std::fs::remove_file(&path);
575 }
576
577 #[mz_ore::test]
578 fn typecheck_unknown_item_attaches_fuzzy_quickfix_data() {
579 use crate::lsp::code_action::{Candidates, QuickFixData};
580 use crate::project::compiler::typecheck::{ObjectTypeCheckError, ObjectTypeCheckErrorKind};
581 use crate::project::ir::object_id::ObjectId;
582 use mz_sql::catalog::CatalogError;
583
584 let source = "SELECT * FROM cusotmers";
585 let path = std::env::temp_dir().join("typecheck_fuzzy_test.sql");
586 std::fs::write(&path, source).unwrap();
587
588 let err = ObjectTypeCheckError {
589 object_id: ObjectId::new(
590 "materialize".to_string(),
591 "public".to_string(),
592 "v".to_string(),
593 ),
594 file_path: path.clone(),
595 kind: ObjectTypeCheckErrorKind::Catalog(CatalogError::UnknownItem(
596 "cusotmers".to_string(),
597 )),
598 };
599 let tc = TypeCheckError::Multiple(vec![err]);
600
601 let fs = FileSystem::default();
602 let candidates = Candidates {
603 items: vec!["customers".to_string()],
604 ..Default::default()
605 };
606 let map = typecheck_diagnostics(&fs, &tc, &candidates);
607 let diags = map.get(&path).expect("diags for file");
608 assert_eq!(diags.len(), 1);
609
610 let data = diags[0]
611 .data
612 .as_ref()
613 .expect("Diagnostic.data should be set");
614 let qf: QuickFixData = serde_json::from_value(data.clone()).expect("decodes");
615 assert_eq!(qf.suggestions.len(), 1);
616 assert_eq!(qf.suggestions[0].alternatives.len(), 1);
617 assert_eq!(qf.suggestions[0].alternatives[0].new_text, "customers");
618 let _ = std::fs::remove_file(&path);
619 }
620
621 #[mz_ore::test]
622 fn validation_object_name_mismatch_attaches_quickfix_data() {
623 use crate::lsp::code_action::QuickFixData;
624 use crate::project::error::validation::ErrorContext;
625 use crate::project::error::{ValidationError, ValidationErrorKind};
626
627 let source = "CREATE TABLE customers (id INT);";
628 let path = std::env::temp_dir().join("validation_qf_test.sql");
629 std::fs::write(&path, source).unwrap();
630
631 let err = ValidationError {
632 kind: ValidationErrorKind::ObjectNameMismatch {
633 declared: "customers".to_string(),
634 expected: "users".to_string(),
635 },
636 context: ErrorContext {
637 file: path.clone(),
638 sql_statement: Some(source.to_string()),
639 byte_offset: Some(0),
640 },
641 };
642
643 let fs = FileSystem::default();
644 let map = validation_diagnostics(&fs, &[err]);
645 let diags = map.get(&path).expect("diags for file");
646 assert_eq!(diags.len(), 1);
647
648 let data = diags[0]
649 .data
650 .as_ref()
651 .expect("Diagnostic.data should be set");
652 let qf: QuickFixData = serde_json::from_value(data.clone()).expect("decodes");
653 assert_eq!(qf.suggestions[0].alternatives[0].new_text, "users");
654 let _ = std::fs::remove_file(&path);
655 }
656}