1use std::path::{Path, PathBuf};
2
3use dada_compiler::Compiler;
4use dada_ir_ast::{
5 diagnostic::Diagnostic,
6 inputs::SourceFile,
7 span::{AbsoluteOffset, AbsoluteSpan},
8};
9use dada_util::{Context, Fallible, bail};
10use prettydiff::text::ContextConfig;
11use regex::Regex;
12
13use crate::GlobalOptions;
14
15use super::spec_validation::SpecValidator;
16use super::{FailedTest, Failure};
17
18#[derive(Clone, Debug)]
19pub struct ExpectedDiagnostic {
20 pub span: ExpectedSpan,
23
24 pub annotation_span: AbsoluteSpan,
26
27 pub message: Regex,
29}
30
31#[derive(Copy, Clone, Debug)]
32pub enum ExpectedSpan {
33 MustStartWithin(AbsoluteSpan),
34 MustEqual(AbsoluteSpan),
35}
36
37pub struct TestExpectations {
38 source_file: SourceFile,
39 bless: Bless,
40 expected_diagnostics: Vec<ExpectedDiagnostic>,
41 fn_asts: bool,
42 codegen: bool,
43 fixme: bool,
44 fixme_ice: bool,
45 probes: Vec<Probe>,
46 spec_refs: Vec<String>,
47}
48
49#[derive(Clone, Debug)]
65pub struct Probe {
66 pub span: AbsoluteSpan,
68
69 pub kind: ProbeKind,
71
72 pub message: Regex,
74
75 pub annotation_line: usize,
77}
78
79#[derive(Copy, Clone, Debug)]
80pub enum ProbeKind {
81 VariableType,
83
84 ExprType,
86
87 Ast,
89}
90
91enum Bless {
92 None,
93 All,
94 File(String),
95}
96
97lazy_static::lazy_static! {
98 static ref UNINTERESTING_RE: Regex = Regex::new(r"^\s*(#.*)?$").unwrap();
99}
100
101lazy_static::lazy_static! {
102 static ref DIAGNOSTIC_RE: Regex = Regex::new(r"^(?P<pre>[^#]*)#!(?P<pad>\s*)(?P<col>\^+)?\s*(?P<re>/)?(?P<msg>.*)").unwrap();
103}
104
105lazy_static::lazy_static! {
106 static ref PROBE_RE: Regex = Regex::new(r"^(?P<pre>[^#]*)#\?(?P<pad>\s*)(?P<col>\^+)?\s+(?P<kind>[A-Za-z_]+):\s*(?P<re>/)?(?P<msg>.*)").unwrap();
107}
108
109lazy_static::lazy_static! {
110 static ref ERROR_RE: Regex = Regex::new(r"^(?P<pre>[^#]*)(?<suspicious>#[^ a-zA-Z0-9#])").unwrap();
111}
112
113impl TestExpectations {
114 pub fn new(db: &dyn crate::Db, source_file: SourceFile) -> Fallible<Self> {
115 let bless = match std::env::var("UPDATE_EXPECT") {
116 Ok(s) => {
117 if s == "1" {
118 Bless::All
119 } else {
120 Bless::File(s)
121 }
122 }
123 Err(_) => Bless::None,
124 };
125
126 let mut expectations = TestExpectations {
127 source_file,
128 bless,
129 expected_diagnostics: vec![],
130 fn_asts: false,
131 codegen: true,
132 fixme: false,
133 fixme_ice: false,
134 probes: vec![],
135 spec_refs: vec![],
136 };
137 expectations.initialize(db)?;
138 Ok(expectations)
139 }
140
141 fn initialize(&mut self, db: &dyn crate::Db) -> Fallible<()> {
142 let source = self.source_file.contents_if_ok(db);
143 let line_starts = std::iter::once(0)
144 .chain(
145 source
146 .char_indices()
147 .filter_map(|(i, c)| (c == '\n').then_some(i + 1)),
148 )
149 .chain(std::iter::once(source.len()))
150 .collect::<Vec<_>>();
151
152 let mut in_header = true;
153 let mut last_interesting_line = None;
154 for (line, line_index) in source.lines().zip(0..) {
155 if in_header {
157 if let Some(suffix) = line.strip_prefix("#:") {
158 self.configuration(db, line_index, suffix.trim())?;
159 continue;
160 } else if line.starts_with("#") || line.trim().is_empty() {
161 continue;
162 }
163 }
164
165 in_header = false;
167 if line.contains("#:") {
168 bail!(
169 "{}:{}: configuration comment outside of file header",
170 self.source_file.url_display(db),
171 line_index + 1,
172 );
173 }
174
175 if !UNINTERESTING_RE.is_match(line) {
178 last_interesting_line = Some(line_index);
179 }
180
181 if let Some(c) = DIAGNOSTIC_RE.captures(line) {
183 let Some(last_interesting_line) = last_interesting_line else {
185 bail!("found diagnostic on line with no previous interesting line");
186 };
187
188 let pre = c.name("pre").unwrap().as_str();
191 let pad = c.name("pad").unwrap().as_str();
192 let span = match c.name("col") {
193 Some(c) => {
194 let carrot_start =
195 line_starts[last_interesting_line] + pre.len() + 2 + pad.len();
196 let carrot_end = carrot_start + c.as_str().len();
197
198 ExpectedSpan::MustEqual(AbsoluteSpan {
199 source_file: self.source_file,
200 start: AbsoluteOffset::from(carrot_start),
201 end: AbsoluteOffset::from(carrot_end),
202 })
203 }
204 None => ExpectedSpan::MustStartWithin(AbsoluteSpan {
205 source_file: self.source_file,
206 start: AbsoluteOffset::from(line_starts[last_interesting_line]),
207 end: AbsoluteOffset::from(
208 line_starts[last_interesting_line + 1].saturating_sub(1),
209 ),
210 }),
211 };
212
213 let message = match c.name("re") {
215 Some(_) => Regex::new(c.name("msg").unwrap().as_str())?,
216 None => Regex::new(®ex::escape(c.name("msg").unwrap().as_str()))?,
217 };
218
219 let annotation_span = AbsoluteSpan {
221 source_file: self.source_file,
222 start: AbsoluteOffset::from(line_starts[line_index] + pre.len()),
223 end: AbsoluteOffset::from(line_starts[line_index] + line.len()),
224 };
225
226 self.expected_diagnostics.push(ExpectedDiagnostic {
228 span,
229 annotation_span,
230 message,
231 });
232 } else if let Some(c) = PROBE_RE.captures(line) {
233 let Some(last_interesting_line) = last_interesting_line else {
235 bail!("found probe on line with no previous interesting line");
236 };
237
238 let pre = c.name("pre").unwrap().as_str();
241 let pad = c.name("pad").unwrap().as_str();
242 let span = match c.name("col") {
243 Some(c) => {
244 let carrot_start =
245 line_starts[last_interesting_line] + pre.len() + 2 + pad.len();
246 let carrot_end = carrot_start + c.as_str().len();
247
248 AbsoluteSpan {
249 source_file: self.source_file,
250 start: AbsoluteOffset::from(carrot_start),
251 end: AbsoluteOffset::from(carrot_end),
252 }
253 }
254 None => {
255 let hash_start = line_starts[last_interesting_line] + pre.len();
256 AbsoluteSpan {
257 source_file: self.source_file,
258 start: AbsoluteOffset::from(hash_start),
259 end: AbsoluteOffset::from(hash_start + 1),
260 }
261 }
262 };
263
264 let valid_probe_kinds = &[
265 ("VariableType", ProbeKind::VariableType),
266 ("ExprType", ProbeKind::ExprType),
267 ("Ast", ProbeKind::Ast),
268 ];
269 let user_probe_kind = c.name("kind").unwrap().as_str();
270 let Some(&(_, kind)) = valid_probe_kinds
271 .iter()
272 .find(|pair| pair.0 == user_probe_kind)
273 else {
274 bail!(
275 "unknown probe kind: `{user_probe_kind}`, valid probes are: {}",
276 valid_probe_kinds
277 .iter()
278 .map(|pair| pair.0)
279 .collect::<Vec<_>>()
280 .join(", ")
281 )
282 };
283
284 let message = match c.name("re") {
287 Some(_) => Regex::new(&format!("^(?:{})$", c.name("msg").unwrap().as_str()))?,
288 None => Regex::new(&format!(
289 "^{}$",
290 regex::escape(c.name("msg").unwrap().as_str())
291 ))?,
292 };
293
294 self.probes.push(Probe {
296 span,
297 kind,
298 message,
299 annotation_line: line_index + 1,
300 });
301 } else if let Some(c) = ERROR_RE.captures(line) {
302 bail!(
303 "comment starting with `{p}` looks suspiciously like an annotation but we didn't recognize it",
304 p = c.name("suspicious").unwrap().as_str()
305 );
306 }
307 }
308
309 self.expected_diagnostics.sort_by_key(|e| *e.span());
310
311 Ok(())
312 }
313
314 fn configuration(
315 &mut self,
316 db: &dyn crate::Db,
317 line_index: usize,
318 mut line: &str,
319 ) -> Fallible<()> {
320 if let Some(index) = line.find('#') {
322 line = line[..index].trim();
323 }
324
325 if line == "fn_asts" {
326 self.fn_asts = true;
327 return Ok(());
328 }
329
330 if line == "skip_codegen" {
331 self.codegen = false;
332 return Ok(());
333 }
334
335 if line == "FIXME" {
336 self.fixme = true;
337 return Ok(());
338 }
339
340 if line == "FIXME_ICE" {
341 self.fixme_ice = true;
342 return Ok(());
343 }
344
345 if let Some(spec_ref) = line.strip_prefix("spec ") {
346 self.spec_refs.push(spec_ref.trim().to_string());
347 return Ok(());
348 }
349
350 bail!(
351 "{}:{}: unrecognized configuration comment",
352 self.source_file.url_display(db),
353 line_index + 1,
354 );
355 }
356
357 pub fn fn_asts(&self) -> bool {
358 self.fn_asts
359 }
360
361 pub fn codegen(&self) -> bool {
362 self.codegen
363 }
364
365 pub fn fixme(&self) -> bool {
366 self.fixme
367 }
368
369 pub fn fixme_ice(&self) -> bool {
370 self.fixme_ice
371 }
372
373 pub fn spec_refs(&self) -> &[String] {
374 &self.spec_refs
375 }
376
377 pub fn compare(self, compiler: &mut Compiler) -> Fallible<(Option<FailedTest>, bool)> {
378 use std::fmt::Write;
379
380 let is_fixme = self.fixme;
381 let mut test = FailedTest {
382 path: self.source_file.url(compiler).to_file_path().unwrap(),
383 full_compiler_output: Default::default(),
384 failures: vec![],
385 };
386
387 test.failures.extend(self.compare_auxiliary(
388 compiler,
389 "fn_asts",
390 self.fn_asts,
391 Self::generate_fn_asts,
392 )?);
393
394 let actual_diagnostics = compiler.check_all(self.source_file);
395
396 if self.codegen {
397 let _wasm_bytes = compiler.codegen_main_fn(self.source_file);
398 }
399
400 test.failures.extend(self.perform_probes(compiler));
401 test.failures.extend(self.validate_spec_refs());
402
403 for diagnostic in &actual_diagnostics {
404 writeln!(
405 test.full_compiler_output,
406 "{}",
407 diagnostic.render(compiler, &GlobalOptions::test_options().render_opts())
408 )?;
409 }
410
411 test.failures
412 .extend(self.compare_diagnostics(actual_diagnostics));
413
414 if test.failures.is_empty() {
415 Ok((None, is_fixme))
416 } else {
417 Ok((Some(test), is_fixme))
418 }
419 }
420
421 fn perform_probes(&self, compiler: &Compiler) -> Vec<Failure> {
422 self.probes
423 .iter()
424 .filter_map(|probe| {
425 let actual = match probe.kind {
426 ProbeKind::VariableType => compiler
427 .probe_variable_type(probe.span)
428 .unwrap_or_else(|| "<no variable found>".to_string()),
429 ProbeKind::ExprType => compiler
430 .probe_expression_type(probe.span)
431 .unwrap_or_else(|| "<no expression found>".to_string()),
432 ProbeKind::Ast => compiler
433 .probe_ast(probe.span)
434 .unwrap_or_else(|| "<no expression found>".to_string()),
435 };
436
437 if probe.message.is_match(&actual) {
438 None
439 } else {
440 Some(Failure::Probe {
441 probe: probe.clone(),
442 actual,
443 })
444 }
445 })
446 .collect()
447 }
448
449 fn generate_fn_asts(&self, compiler: &mut Compiler) -> String {
450 compiler.fn_asts(self.source_file)
451 }
452
453 fn compare_auxiliary(
454 &self,
455 compiler: &mut Compiler,
456 ext: &str,
457 enabled: bool,
458 generate_fn: impl Fn(&Self, &mut Compiler) -> String,
459 ) -> Fallible<Vec<Failure>> {
460 let ref_path = self.ref_path(compiler, ext);
461 let txt_path = self.txt_path(compiler, ext);
462
463 if !enabled {
464 self.remove_stale_file(&ref_path)?;
465 self.remove_stale_file(&txt_path)?;
466 return Ok(vec![]);
467 }
468
469 let actual = generate_fn(self, compiler);
470 self.write_file(&txt_path, &actual)?;
471
472 if self.bless.bless_path(&ref_path) {
473 self.write_file(&ref_path, &actual)?;
474 return Ok(vec![]);
475 }
476
477 let expected = std::fs::read_to_string(&ref_path).unwrap_or_default();
478 if actual == expected {
479 return Ok(vec![]);
480 }
481
482 let diff = self.diff_lines(&expected, &actual);
483 Ok(vec![Failure::Auxiliary {
484 kind: format!(":{ext}"),
485 ref_path,
486 txt_path,
487 diff,
488 }])
489 }
490
491 fn remove_stale_file(&self, path: &Path) -> Fallible<()> {
492 if path.exists() {
493 std::fs::remove_file(path)
494 .with_context(|| format!("removing stale file `{}`", path.display()))?;
495 }
496
497 Ok(())
498 }
499
500 fn write_file(&self, path: &Path, contents: &str) -> Fallible<()> {
501 std::fs::write(path, contents)
502 .with_context(|| format!("writing to file `{}`", path.display()))?;
503 Ok(())
504 }
505
506 fn compare_diagnostics(self, mut actual_diagnostics: Vec<&Diagnostic>) -> Vec<Failure> {
507 actual_diagnostics.sort_by_key(|d| d.span);
508
509 let empty_matched = vec![false; self.expected_diagnostics.len()];
510 let mut matched = empty_matched.clone();
511
512 let mut failures = vec![];
514
515 for actual_diagnostic in actual_diagnostics {
516 if let Some(index) = self.find_match(actual_diagnostic, &matched) {
519 matched[index] = true; continue;
521 }
522
523 match self.find_match(actual_diagnostic, &empty_matched) {
526 Some(index) => {
527 failures.push(Failure::MultipleMatches(
528 self.expected_diagnostics[index].clone(),
529 actual_diagnostic.clone(),
530 ));
531 }
532 None => {
533 failures.push(Failure::UnexpectedDiagnostic(actual_diagnostic.clone()));
534 }
535 }
536 }
537
538 for (expected_diagnostic, matched) in self.expected_diagnostics.into_iter().zip(matched) {
539 if !matched {
540 failures.push(Failure::MissingDiagnostic(expected_diagnostic));
541 }
542 }
543
544 failures
545 }
546
547 fn find_match(&self, actual_diagnostic: &Diagnostic, matched: &[bool]) -> Option<usize> {
548 self.expected_diagnostics
549 .iter()
550 .zip(0_usize..)
551 .filter(|&(expected_diagnostic, index)| {
552 !matched[index]
553 && expected_diagnostic.span.matches(&actual_diagnostic.span)
554 && expected_diagnostic
555 .message
556 .is_match(&actual_diagnostic.message)
557 })
558 .min_by_key(|(expected_diagnostic, _)| expected_diagnostic.span())
560 .map(|(_, index)| index)
561 }
562
563 pub fn source_path(&self, db: &dyn crate::Db) -> PathBuf {
564 self.source_file.url(db).to_file_path().unwrap()
565 }
566
567 fn ref_path(&self, db: &dyn crate::Db, ext: &str) -> PathBuf {
568 let path_buf = self.source_path(db);
569 path_buf.with_extension(format!("{ext}.ref"))
570 }
571
572 fn txt_path(&self, db: &dyn crate::Db, ext: &str) -> PathBuf {
573 let path_buf = self.source_path(db);
574 path_buf.with_extension(format!("{ext}.txt"))
575 }
576
577 fn diff_lines(&self, expected: &str, actual: &str) -> String {
578 prettydiff::diff_lines(expected, actual)
579 .set_diff_only(true)
580 .format_with_context(
581 Some(ContextConfig {
582 context_size: 3,
583 skipping_marker: "...",
584 }),
585 true,
586 )
587 }
588
589 fn validate_spec_refs(&self) -> Vec<Failure> {
591 if self.spec_refs.is_empty() {
593 return vec![];
594 }
595
596 let validator = match SpecValidator::new() {
598 Ok(validator) => validator,
599 Err(_) => {
600 return self
602 .spec_refs
603 .iter()
604 .map(|spec_ref| Failure::InvalidSpecReference(spec_ref.clone()))
605 .collect();
606 }
607 };
608
609 validator
611 .validate_spec_refs(&self.spec_refs)
612 .into_iter()
613 .map(Failure::InvalidSpecReference)
614 .collect()
615 }
616}
617
618impl ExpectedDiagnostic {
619 pub fn span(&self) -> &AbsoluteSpan {
620 self.span.span()
621 }
622}
623
624impl ExpectedSpan {
625 pub fn matches(&self, actual_span: &AbsoluteSpan) -> bool {
626 match self {
627 ExpectedSpan::MustStartWithin(expected_span) => {
628 expected_span.start <= actual_span.start && actual_span.start <= expected_span.end
629 }
630 ExpectedSpan::MustEqual(expected_span) => expected_span == actual_span,
631 }
632 }
633
634 pub fn span(&self) -> &AbsoluteSpan {
635 match self {
636 ExpectedSpan::MustStartWithin(span) => span,
637 ExpectedSpan::MustEqual(span) => span,
638 }
639 }
640}
641
642impl Bless {
643 fn bless_path(&self, path: &Path) -> bool {
644 match self {
645 Bless::None => false,
646 Bless::All => true,
647 Bless::File(s) => path.file_name().unwrap() == &s[..],
648 }
649 }
650}