Skip to main content

dada_lang/main_lib/test/
expected.rs

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    /// The span where this diagnostic is expected to start.
21    /// The start of some actual diagnostic must fall within this span.
22    pub span: ExpectedSpan,
23
24    /// The span of the annotation itself
25    pub annotation_span: AbsoluteSpan,
26
27    /// regular expression that message must match
28    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/// A "probe" is a test where we inspect some piece of compiler state
50/// at a particular location, for example, to find out the inferred
51/// type of a variable or expression.
52///
53/// Probes are denoted with `#? kind: expected` or `#? ^^^ kind: expected`.
54///
55/// The first syntax indicates the probe occurs at the column of the `#`.
56///
57/// The second syntax indicates a probe with a span.
58///
59/// The "kind" is some string matching `[a-z_]+` indicating the sort of probe
60/// to perform.
61///
62/// The "expected" part is a string (or `/string` for a regular expression)
63/// that the probe should return.
64#[derive(Clone, Debug)]
65pub struct Probe {
66    /// Location of the probe.
67    pub span: AbsoluteSpan,
68
69    /// Kind of probe.
70    pub kind: ProbeKind,
71
72    /// Message expected
73    pub message: Regex,
74
75    /// 1-based line number of the `#?` annotation in the source file.
76    pub annotation_line: usize,
77}
78
79#[derive(Copy, Clone, Debug)]
80pub enum ProbeKind {
81    /// Tests the type of the variable declared here
82    VariableType,
83
84    /// Tests the type of the smallest containing expression
85    ExprType,
86
87    /// Dumps the compact AST representation of the smallest containing expression
88    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            // Allow `#:` configuration lines, but only at the start of the file.
156            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            // Otherwise error if we see `#:`.
166            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            // Track the last "interesting" line (non-empty, basically).
176            // Any future `#!` errors will be assumed to start on that line.
177            if !UNINTERESTING_RE.is_match(line) {
178                last_interesting_line = Some(line_index);
179            }
180
181            // Check if this line contains an expected diagnostic.
182            if let Some(c) = DIAGNOSTIC_RE.captures(line) {
183                // Find the line on which the diagnostic will be expected to occur.
184                let Some(last_interesting_line) = last_interesting_line else {
185                    bail!("found diagnostic on line with no previous interesting line");
186                };
187
188                // Extract the expected span: if the comment contains `^^^` markers, it needs to be
189                // exactly as given, but otherwise it just has to start somewhere on the line.
190                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                // Find the expected message (which may be a regular expression).
214                let message = match c.name("re") {
215                    Some(_) => Regex::new(c.name("msg").unwrap().as_str())?,
216                    None => Regex::new(&regex::escape(c.name("msg").unwrap().as_str()))?,
217                };
218
219                // Where did the *annotation* appear
220                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                // Push onto the list of expected diagnostics.
227                self.expected_diagnostics.push(ExpectedDiagnostic {
228                    span,
229                    annotation_span,
230                    message,
231                });
232            } else if let Some(c) = PROBE_RE.captures(line) {
233                // Find the line on which the diagnostic will be expected to occur.
234                let Some(last_interesting_line) = last_interesting_line else {
235                    bail!("found probe on line with no previous interesting line");
236                };
237
238                // Extract the expected span: if the probe contains `^^^` markers, use the span
239                // of the `^^^` markers, but otherwise use the single `#` character.
240                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                // Find the expected message (which may be a regular expression).
285                // Probes use exact (anchored) matching, unlike diagnostics which use substring matching.
286                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                // Push onto the list of expected diagnostics.
295                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        // Permit `#` comment on the same line as configuration; ignore it
321        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        // Make sure that every actual diagnostic matches some expected diagnostic
513        let mut failures = vec![];
514
515        for actual_diagnostic in actual_diagnostics {
516            // Check whether this matches an expected diagnostic that
517            // has not yet been matched.
518            if let Some(index) = self.find_match(actual_diagnostic, &matched) {
519                matched[index] = true; // Good!
520                continue;
521            }
522
523            // Check whether this matches an expected diagnostic that
524            // had already matched.
525            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            // Find the best match (with the narrowest span)
559            .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    /// Validates all spec references in this test file
590    fn validate_spec_refs(&self) -> Vec<Failure> {
591        // Skip validation if no spec refs
592        if self.spec_refs.is_empty() {
593            return vec![];
594        }
595
596        // Create spec validator - if it fails, we'll report all spec refs as invalid
597        let validator = match SpecValidator::new() {
598            Ok(validator) => validator,
599            Err(_) => {
600                // If we can't load the spec, report all spec refs as invalid
601                return self
602                    .spec_refs
603                    .iter()
604                    .map(|spec_ref| Failure::InvalidSpecReference(spec_ref.clone()))
605                    .collect();
606            }
607        };
608
609        // Validate each spec reference
610        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}