1use std::ops::Range;
29use std::path::PathBuf;
30
31use mz_repr::ColumnName;
32use mz_sql::catalog::CatalogError;
33use mz_sql::names::PartialItemName;
34use mz_sql::plan::PlanError;
35
36use crate::project::compiler::typecheck::ObjectTypeCheckErrorKind;
37use crate::project::error::ValidationErrorKind;
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub(crate) enum Severity {
41 Error,
42 Warning,
43}
44
45#[derive(Debug, Clone)]
50pub(crate) struct PositionalDiagnostic {
51 pub severity: Severity,
52 pub file: PathBuf,
53 pub source: String,
56 pub byte_range: Range<usize>,
59 pub message: String,
60 pub footers: Vec<String>,
62 pub suggestions: Vec<Suggestion>,
65}
66
67#[derive(Debug, Clone)]
81pub(crate) struct Suggestion {
82 pub label: String,
83 pub alternatives: Vec<Replacement>,
84}
85
86#[derive(Debug, Clone)]
89pub(crate) struct Replacement {
90 pub byte_range: Range<usize>,
91 pub replacement: String,
92}
93
94pub(crate) fn locate_typecheck(
100 kind: &ObjectTypeCheckErrorKind,
101 source: &str,
102) -> Option<Range<usize>> {
103 match kind {
104 ObjectTypeCheckErrorKind::Parser(e) => {
105 let pos = e.error.pos;
106 Some(pos..pos)
107 }
108 ObjectTypeCheckErrorKind::Plan(e) => locate_plan(e, source),
109 ObjectTypeCheckErrorKind::Catalog(e) => locate_catalog(e, source),
110 ObjectTypeCheckErrorKind::Internal(_) => None,
111 }
112}
113
114pub(crate) fn locate_plan(e: &PlanError, source: &str) -> Option<Range<usize>> {
120 use PlanError::*;
121 match e {
122 UnknownColumn { column, .. }
123 | UngroupedColumn { column, .. }
124 | UnknownColumnInUsingClause { column, .. }
125 | AmbiguousColumnInUsingClause { column, .. }
126 | WrongJoinTypeForLateralColumn { column, .. } => find_identifier(source, column.as_str()),
127 AmbiguousColumn(column) => find_identifier(source, column.as_str()),
128 AmbiguousTable(name) => find_identifier(source, name.item.as_str()),
129 UnknownFunction { name, .. }
130 | IndistinctFunction { name, .. }
131 | UnknownOperator { name, .. }
132 | IndistinctOperator { name, .. } => find_identifier(source, last_component(name)),
133 Parser(p) => Some(p.pos..p.pos),
134 ParserStatement(p) => Some(p.error.pos..p.error.pos),
135 Catalog(c) => locate_catalog(c, source),
136 _ => None,
137 }
138}
139
140pub(crate) fn locate_catalog(e: &CatalogError, source: &str) -> Option<Range<usize>> {
142 use CatalogError::*;
143 match e {
144 UnknownDatabase(name)
145 | UnknownSchema(name)
146 | UnknownRole(name)
147 | UnknownCluster(name)
148 | UnknownClusterReplica(name)
149 | UnknownConnection(name)
150 | UnknownNetworkPolicy(name)
151 | UnknownItem(name) => find_identifier(source, last_component(name)),
152 UnknownFunction { name, .. } | UnknownType { name, .. } => {
153 find_identifier(source, last_component(name))
154 }
155 _ => None,
156 }
157}
158
159pub(crate) fn last_component(s: &str) -> &str {
162 s.rsplit_once('.').map(|(_, last)| last).unwrap_or(s)
163}
164
165pub(crate) fn find_identifier(source: &str, name: &str) -> Option<Range<usize>> {
171 if name.is_empty() {
172 return None;
173 }
174 let bytes = source.as_bytes();
175 let needle = name.as_bytes();
176 if needle.len() > bytes.len() {
177 return None;
178 }
179 for start in 0..=(bytes.len() - needle.len()) {
180 if &bytes[start..start + needle.len()] != needle {
181 continue;
182 }
183 let before_ok = start == 0 || !is_ident_byte(bytes[start - 1]);
184 let end = start + needle.len();
185 let after_ok = end == bytes.len() || !is_ident_byte(bytes[end]);
186 if before_ok && after_ok {
187 return Some(start..end);
188 }
189 }
190 None
191}
192
193fn is_ident_byte(b: u8) -> bool {
194 b.is_ascii_alphanumeric() || b == b'_'
195}
196
197pub(crate) fn find_identifier_after(
200 source: &str,
201 name: &str,
202 start_byte: usize,
203) -> Option<Range<usize>> {
204 let slice = source.get(start_byte..)?;
205 let local = find_identifier(slice, name)?;
206 Some((start_byte + local.start)..(start_byte + local.end))
207}
208
209pub(crate) fn format_typecheck_kind(
216 kind: &ObjectTypeCheckErrorKind,
217 source: &str,
218 primary_range: &Range<usize>,
219) -> (String, Vec<String>, Vec<Suggestion>) {
220 match kind {
221 ObjectTypeCheckErrorKind::Plan(e) => format_plan(e, source, primary_range),
222 ObjectTypeCheckErrorKind::Catalog(e) => format_catalog(e, source, primary_range),
223 ObjectTypeCheckErrorKind::Parser(e) => (e.to_string(), Vec::new(), Vec::new()),
224 ObjectTypeCheckErrorKind::Internal(msg) => (msg.clone(), Vec::new(), Vec::new()),
225 }
226}
227
228fn format_plan(
229 e: &PlanError,
230 source: &str,
231 primary_range: &Range<usize>,
232) -> (String, Vec<String>, Vec<Suggestion>) {
233 if let PlanError::UnknownColumn {
234 table,
235 column,
236 similar,
237 } = e
238 {
239 let qualified = column_display(table.as_ref(), column);
240 let message = format!("column {qualified} does not exist");
241 if similar.is_empty() {
242 return (message, Vec::new(), Vec::new());
243 }
244 let span = locate_replacement(source, primary_range, column.as_str());
245 let label = match similar.as_ref() {
246 [single] => format!("did you mean `{}`?", column_display(table.as_ref(), single)),
247 _ => "did you mean one of these?".to_string(),
248 };
249 let alternatives = similar
250 .iter()
251 .map(|alt| Replacement {
252 byte_range: span.clone(),
253 replacement: alt.as_str().to_string(),
254 })
255 .collect();
256 return (
257 message,
258 Vec::new(),
259 vec![Suggestion {
260 label,
261 alternatives,
262 }],
263 );
264 }
265 fallback_plan(e)
266}
267
268fn fallback_plan(e: &PlanError) -> (String, Vec<String>, Vec<Suggestion>) {
269 let footers = e.hint().into_iter().collect();
270 (e.to_string(), footers, Vec::new())
271}
272
273fn format_catalog(
274 e: &CatalogError,
275 source: &str,
276 primary_range: &Range<usize>,
277) -> (String, Vec<String>, Vec<Suggestion>) {
278 match e {
279 CatalogError::UnknownFunction {
280 name,
281 alternative: Some(alt),
282 } => {
283 let message = format!("function {name} does not exist");
284 let suggestion = Suggestion {
285 label: format!("did you mean `{alt}`?"),
286 alternatives: vec![Replacement {
287 byte_range: locate_replacement(source, primary_range, last_component(name)),
288 replacement: alt.clone(),
289 }],
290 };
291 (message, Vec::new(), vec![suggestion])
292 }
293 other => fallback_catalog(other),
294 }
295}
296
297fn fallback_catalog(e: &CatalogError) -> (String, Vec<String>, Vec<Suggestion>) {
298 let footers = e.hint().into_iter().collect();
299 (e.to_string(), footers, Vec::new())
300}
301
302fn column_display(table: Option<&PartialItemName>, column: &ColumnName) -> String {
307 match table {
308 Some(t) => format!("{}.{}", t.item, column),
309 None => column.as_str().to_string(),
310 }
311}
312
313pub(crate) fn locate_replacement(
320 source: &str,
321 primary_range: &Range<usize>,
322 needle: &str,
323) -> Range<usize> {
324 let in_bounds = primary_range.end <= source.len()
325 && primary_range.start <= primary_range.end
326 && source.is_char_boundary(primary_range.start)
327 && source.is_char_boundary(primary_range.end);
328 if in_bounds && &source[primary_range.clone()] == needle {
329 return primary_range.clone();
330 }
331 find_identifier(source, needle).unwrap_or_else(|| primary_range.clone())
332}
333
334pub(crate) fn locate_validation(
338 kind: &ValidationErrorKind,
339 source: &str,
340 statement_offset: Option<usize>,
341) -> Option<Range<usize>> {
342 let (needle, _) = mismatch_pair(kind)?;
343 find_identifier_after(source, needle, statement_offset.unwrap_or(0))
344}
345
346pub(crate) fn format_validation_kind(
352 kind: &ValidationErrorKind,
353 source: &str,
354 primary_range: &Range<usize>,
355) -> (String, Vec<String>, Vec<Suggestion>) {
356 let message = kind.message();
357 let footers: Vec<String> = kind.help().into_iter().collect();
358 let suggestions = mismatch_suggestion(kind, source, primary_range);
359 (message, footers, suggestions)
360}
361
362fn mismatch_pair(kind: &ValidationErrorKind) -> Option<(&str, &str)> {
366 use ValidationErrorKind::*;
367 match kind {
368 ObjectNameMismatch { declared, expected }
369 | SchemaMismatch { declared, expected }
370 | DatabaseMismatch { declared, expected }
371 | ClusterNameMismatch { declared, expected }
372 | RoleNameMismatch { declared, expected }
373 | NetworkPolicyNameMismatch { declared, expected } => {
374 Some((declared.as_str(), expected.as_str()))
375 }
376 _ => None,
377 }
378}
379
380fn mismatch_suggestion(
381 kind: &ValidationErrorKind,
382 source: &str,
383 primary_range: &Range<usize>,
384) -> Vec<Suggestion> {
385 let Some((declared, expected)) = mismatch_pair(kind) else {
386 return Vec::new();
387 };
388 let span = locate_replacement(source, primary_range, declared);
389 vec![Suggestion {
390 label: format!("rename to `{expected}`"),
391 alternatives: vec![Replacement {
392 byte_range: span,
393 replacement: expected.to_string(),
394 }],
395 }]
396}
397
398#[cfg(test)]
399mod tests {
400 use super::*;
401 use mz_repr::ColumnName;
402 use std::sync::Arc;
403
404 #[mz_ore::test]
405 fn find_identifier_skips_substrings() {
406 let source = "SELECT customer_id, id FROM t";
407 let r = find_identifier(source, "id").unwrap();
408 assert_eq!(&source[r.clone()], "id");
409 assert_eq!(r.start, 20);
410 }
411
412 #[mz_ore::test]
413 fn find_identifier_empty_needle() {
414 assert!(find_identifier("anything", "").is_none());
415 }
416
417 #[mz_ore::test]
418 fn find_identifier_absent() {
419 assert!(find_identifier("SELECT 1", "missing").is_none());
420 }
421
422 #[mz_ore::test]
423 fn find_identifier_at_start() {
424 let r = find_identifier("foo bar", "foo").unwrap();
425 assert_eq!(r, 0..3);
426 }
427
428 #[mz_ore::test]
429 fn find_identifier_at_end() {
430 let r = find_identifier("foo bar", "bar").unwrap();
431 assert_eq!(r, 4..7);
432 }
433
434 #[mz_ore::test]
435 fn find_identifier_needle_longer_than_haystack() {
436 assert!(find_identifier("ab", "abcd").is_none());
437 }
438
439 #[mz_ore::test]
440 fn last_component_strips_qualifier() {
441 assert_eq!(last_component("foo"), "foo");
442 assert_eq!(last_component("schema.table"), "table");
443 assert_eq!(last_component("db.schema.table"), "table");
444 }
445
446 #[mz_ore::test]
447 fn locate_plan_unknown_column() {
448 let source = "CREATE VIEW v AS SELECT bogus FROM t";
449 let e = PlanError::UnknownColumn {
450 table: None,
451 column: ColumnName::from("bogus"),
452 similar: Box::new([]),
453 };
454 let r = locate_plan(&e, source).unwrap();
455 assert_eq!(&source[r.clone()], "bogus");
456 assert_eq!(r, 24..29);
457 }
458
459 #[mz_ore::test]
460 fn locate_plan_unknown_function() {
461 let source = "SELECT bogus_fn(1) FROM t";
462 let e = PlanError::UnknownFunction {
463 name: "bogus_fn".to_string(),
464 arg_types: vec!["int4".to_string()],
465 };
466 let r = locate_plan(&e, source).unwrap();
467 assert_eq!(&source[r], "bogus_fn");
468 }
469
470 #[mz_ore::test]
471 fn locate_plan_unhandled_variant_returns_none() {
472 let e = PlanError::Unstructured("anything".into());
473 assert!(locate_plan(&e, "SELECT 1").is_none());
474 }
475
476 #[mz_ore::test]
477 fn locate_catalog_unknown_item_strips_qualifier() {
478 let source = "SELECT * FROM bogus_table";
479 let e = CatalogError::UnknownItem("schema.bogus_table".to_string());
480 let r = locate_catalog(&e, source).unwrap();
481 assert_eq!(&source[r], "bogus_table");
482 }
483
484 #[mz_ore::test]
485 fn locate_typecheck_internal_returns_none() {
486 let kind = ObjectTypeCheckErrorKind::Internal("boom".into());
487 assert!(locate_typecheck(&kind, "anything").is_none());
488 }
489
490 #[mz_ore::test]
491 fn locate_typecheck_dispatches_to_plan() {
492 let source = "CREATE VIEW v AS SELECT bogus FROM t";
493 let kind = ObjectTypeCheckErrorKind::Plan(Arc::new(PlanError::UnknownColumn {
494 table: None,
495 column: ColumnName::from("bogus"),
496 similar: Box::new([]),
497 }));
498 let r = locate_typecheck(&kind, source).unwrap();
499 assert_eq!(&source[r], "bogus");
500 }
501
502 #[mz_ore::test]
503 fn locate_typecheck_dispatches_to_catalog() {
504 let source = "SELECT * FROM bogus_table";
505 let kind =
506 ObjectTypeCheckErrorKind::Catalog(CatalogError::UnknownItem("bogus_table".into()));
507 let r = locate_typecheck(&kind, source).unwrap();
508 assert_eq!(&source[r], "bogus_table");
509 }
510
511 #[mz_ore::test]
512 fn locate_replacement_prefers_primary_range_when_matches() {
513 let r = locate_replacement("SELECT emails FROM t", &(7..13), "emails");
514 assert_eq!(r, 7..13);
515 }
516
517 #[mz_ore::test]
518 fn locate_replacement_falls_back_to_search() {
519 let r = locate_replacement("SELECT emails FROM t", &(0..0), "emails");
520 assert_eq!(r, 7..13);
521 }
522
523 #[mz_ore::test]
524 fn locate_replacement_mid_char_primary_range_does_not_panic() {
525 let source = "café customers";
528 let r = locate_replacement(source, &(4..4), "customers");
529 assert_eq!(&source[r], "customers");
530 }
531
532 #[mz_ore::test]
533 fn find_identifier_after_skips_earlier_occurrence() {
534 let source = "CREATE TABLE foo (...);\nCREATE VIEW v AS SELECT * FROM foo;";
535 let r = find_identifier_after(source, "foo", 24).unwrap();
536 assert!(r.start > 24);
538 assert_eq!(&source[r.clone()], "foo");
539 }
540
541 #[mz_ore::test]
542 fn locate_validation_object_name_mismatch_finds_declared_token() {
543 use crate::project::error::ValidationErrorKind;
544 let source = "CREATE TABLE customers (id INT);";
545 let kind = ValidationErrorKind::ObjectNameMismatch {
546 declared: "customers".to_string(),
547 expected: "users".to_string(),
548 };
549 let r = locate_validation(&kind, source, Some(0)).unwrap();
550 assert_eq!(&source[r], "customers");
551 }
552
553 #[mz_ore::test]
554 fn format_validation_kind_object_name_mismatch_yields_rename_suggestion() {
555 use crate::project::error::ValidationErrorKind;
556 let source = "CREATE TABLE customers (id INT);";
557 let kind = ValidationErrorKind::ObjectNameMismatch {
558 declared: "customers".to_string(),
559 expected: "users".to_string(),
560 };
561 let primary = locate_validation(&kind, source, Some(0)).unwrap();
562 let (msg, footers, suggestions) = format_validation_kind(&kind, source, &primary);
563 assert!(msg.contains("declared 'customers'"));
564 assert!(msg.contains("expected 'users'"));
565 assert!(
567 footers
568 .iter()
569 .any(|f| f.contains("must match the .sql file name"))
570 );
571 assert!(suggestions[0].label.contains("users"));
573 assert_eq!(suggestions.len(), 1);
574 assert_eq!(suggestions[0].alternatives.len(), 1);
575 assert_eq!(suggestions[0].alternatives[0].replacement, "users");
576 assert_eq!(
577 &source[suggestions[0].alternatives[0].byte_range.clone()],
578 "customers"
579 );
580 }
581
582 #[mz_ore::test]
583 fn format_validation_kind_unhandled_returns_no_suggestions() {
584 use crate::project::error::ValidationErrorKind;
585 let kind = ValidationErrorKind::NoMainStatement {
586 object_name: "x".to_string(),
587 };
588 let (_msg, _footers, sugg) = format_validation_kind(&kind, "", &(0..0));
589 assert!(sugg.is_empty());
590 }
591}