diff --git a/Cargo.lock b/Cargo.lock index bf70396e..18945c0b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -372,6 +372,7 @@ dependencies = [ "dada-ir-ast", "dada-ir-sym", "dada-parser", + "dada-probe", "dada-util", "extension-trait", "rust-embed", @@ -480,6 +481,17 @@ dependencies = [ "salsa", ] +[[package]] +name = "dada-probe" +version = "0.1.0" +dependencies = [ + "dada-ir-ast", + "dada-ir-sym", + "dada-parser", + "dada-util", + "salsa", +] + [[package]] name = "dada-util" version = "0.1.0" diff --git a/components/dada-compiler/Cargo.toml b/components/dada-compiler/Cargo.toml index 5b527cb3..2c11f238 100644 --- a/components/dada-compiler/Cargo.toml +++ b/components/dada-compiler/Cargo.toml @@ -14,3 +14,4 @@ extension-trait = "1.0.2" url = { workspace = true } dada-codegen = { version = "0.1.0", path = "../dada-codegen" } dada-ir-sym = { version = "0.1.0", path = "../dada-ir-sym" } +dada-probe = { version = "0.1.0", path = "../dada-probe" } diff --git a/components/dada-compiler/src/lib.rs b/components/dada-compiler/src/lib.rs index e8256717..3865fa57 100644 --- a/components/dada-compiler/src/lib.rs +++ b/components/dada-compiler/src/lib.rs @@ -8,6 +8,7 @@ use dada_ir_ast::{ ast::{AstFunction, AstItem, AstMember, Identifier}, diagnostic::Diagnostic, inputs::{CompilationRoot, Krate, SourceFile}, + span::AbsoluteSpan, }; use dada_util::{Fallible, FromImpls, Map, Set, bail, debug}; use salsa::{Database as _, Durability, Event, EventKind, Setter}; @@ -163,6 +164,11 @@ impl Compiler { Self::deduplicated(check_all::accumulated::(self, source_file)) } + /// Return type of the variable found at the given `span` or `None` if there is no variable there. + pub fn probe_variable_type(&self, span: AbsoluteSpan) -> Option { + self.attach(|db| dada_probe::probe_variable_type(db, span)) + } + fn deduplicated(mut diagnostics: Vec<&Diagnostic>) -> Vec<&Diagnostic> { let mut new = Set::default(); diagnostics.retain(|&d| new.insert(d)); diff --git a/components/dada-debug/src/assets.rs b/components/dada-debug/src/assets.rs index 2723a839..e50d801d 100644 --- a/components/dada-debug/src/assets.rs +++ b/components/dada-debug/src/assets.rs @@ -9,6 +9,6 @@ handlebars_helper!(index: |events: array, i: usize| events[i].clone()); pub(crate) fn try_asset(path: &str) -> anyhow::Result { let result = Assets::get(path).ok_or_else(|| anyhow::anyhow!("no asset `{path}` found"))?; - let s = str::from_utf8(&result.data)?; - Ok(s.to_string()) + let s = String::from_utf8(result.data.to_vec())?; + Ok(s) } diff --git a/components/dada-ir-ast/src/span.rs b/components/dada-ir-ast/src/span.rs index 73b1a56e..fd64373d 100644 --- a/components/dada-ir-ast/src/span.rs +++ b/components/dada-ir-ast/src/span.rs @@ -112,6 +112,11 @@ impl AbsoluteSpan { end: Offset::from(self.end), } } + + /// True if `self` contains all of `other` + pub fn contains(self, other: AbsoluteSpan) -> bool { + self.source_file == other.source_file && self.start <= other.start && self.end >= other.end + } } impl<'db> Span<'db> { @@ -199,6 +204,14 @@ pub trait Spanned<'db> { fn span(&self, db: &'db dyn crate::Db) -> Span<'db>; } +/// Returns the span of this item in the source. +/// +/// This is distinct from the [`Spanned`] impl, which returns +/// the span best used in error reporting. +pub trait SourceSpanned<'db> { + fn source_span(&self, db: &'db dyn crate::Db) -> Span<'db>; +} + /// Either `Span` or `Option`. pub trait IntoOptionSpan<'db> { fn into_opt_span(self) -> Option>; diff --git a/components/dada-ir-sym/src/check/env.rs b/components/dada-ir-sym/src/check/env.rs index d6d1abce..2991b347 100644 --- a/components/dada-ir-sym/src/check/env.rs +++ b/components/dada-ir-sym/src/check/env.rs @@ -19,7 +19,7 @@ use crate::{ }, }; use dada_ir_ast::{ - ast::{AstTy, VariableDecl}, + ast::VariableDecl, diagnostic::{Diagnostic, Err, Reported}, span::Span, }; diff --git a/components/dada-ir-sym/src/check/env/combinator.rs b/components/dada-ir-sym/src/check/env/combinator.rs index 711b3bb5..2ee0ee78 100644 --- a/components/dada-ir-sym/src/check/env/combinator.rs +++ b/components/dada-ir-sym/src/check/env/combinator.rs @@ -156,7 +156,7 @@ impl<'db> Env<'db> { async move { let this = &*self; let test_fn = &test_fn; - let mut unordered = FuturesUnordered::new(); + let unordered = FuturesUnordered::new(); for (item, index) in items.into_iter().zip(0..) { unordered.push(async move { let mut env = this.fork(|handle| { @@ -192,7 +192,7 @@ impl<'db> Env<'db> { async move { let this = &*self; let test_fn = &test_fn; - let mut unordered = FuturesUnordered::new(); + let unordered = FuturesUnordered::new(); for (item, index) in items.into_iter().zip(0..) { unordered.push(async move { let mut env = this.fork(|handle| { @@ -291,6 +291,7 @@ impl<'db> Env<'db> { /// You get back bounds from the direction you provide or from /// either direction if you provide `None`. Multiple bounds from the same direction /// indicate that the bounds got tighter. + #[expect(dead_code)] pub fn red_ty_bounds( &self, infer: InferVarIndex, diff --git a/components/dada-ir-sym/src/check/env/infer_bounds.rs b/components/dada-ir-sym/src/check/env/infer_bounds.rs index 6a8a5020..95cf1ceb 100644 --- a/components/dada-ir-sym/src/check/env/infer_bounds.rs +++ b/components/dada-ir-sym/src/check/env/infer_bounds.rs @@ -1,7 +1,6 @@ use std::pin::pin; use dada_util::FromImpls; -use either::Either; use crate::{ check::{ diff --git a/components/dada-ir-sym/src/check/functions.rs b/components/dada-ir-sym/src/check/functions.rs index b6d430bd..8c10daa5 100644 --- a/components/dada-ir-sym/src/check/functions.rs +++ b/components/dada-ir-sym/src/check/functions.rs @@ -1,10 +1,8 @@ -use std::process::Output; - use crate::{ check::{CheckTyInEnv, signature::PreparedEnv}, ir::{ classes::SymAggregate, - functions::{SymFunction, SymFunctionSource, SymInputOutput}, + functions::{SymFunction, SymFunctionSource}, }, }; use dada_ir_ast::{ diff --git a/components/dada-ir-sym/src/check/predicates.rs b/components/dada-ir-sym/src/check/predicates.rs index 421ebd03..971ea6e1 100644 --- a/components/dada-ir-sym/src/check/predicates.rs +++ b/components/dada-ir-sym/src/check/predicates.rs @@ -1,3 +1,5 @@ +#![expect(dead_code)] + pub mod is_provably_copy; pub mod is_provably_lent; pub mod is_provably_move; diff --git a/components/dada-ir-sym/src/check/predicates/var_infer.rs b/components/dada-ir-sym/src/check/predicates/var_infer.rs index 966d294f..1d69b59b 100644 --- a/components/dada-ir-sym/src/check/predicates/var_infer.rs +++ b/components/dada-ir-sym/src/check/predicates/var_infer.rs @@ -11,10 +11,7 @@ use crate::{ ir::{indices::InferVarIndex, variables::SymVariable}, }; -use super::{ - is_provably_copy::term_is_provably_copy, is_provably_lent::term_is_provably_lent, - is_provably_owned::term_is_provably_owned, require_term_is, -}; +use super::require_term_is; pub fn test_var_is_provably<'db>( env: &mut Env<'db>, diff --git a/components/dada-ir-sym/src/check/red.rs b/components/dada-ir-sym/src/check/red.rs index 455e815b..9d813474 100644 --- a/components/dada-ir-sym/src/check/red.rs +++ b/components/dada-ir-sym/src/check/red.rs @@ -9,7 +9,7 @@ use serde::Serialize; use crate::ir::{ indices::{FromInfer, InferVarIndex}, - types::{SymGenericTerm, SymPerm, SymPlace, SymTy, SymTyKind, SymTyName}, + types::{SymGenericTerm, SymPerm, SymPlace, SymTy, SymTyName}, variables::SymVariable, }; @@ -41,6 +41,7 @@ impl<'db> RedPerm<'db> { Ok(true) } + #[expect(dead_code)] pub fn is_our(self, env: &Env<'db>) -> Errors { Ok(self.is_provably(env, Predicate::Copy)? && self.is_provably(env, Predicate::Owned)?) } @@ -72,6 +73,7 @@ impl<'db> RedChain<'db> { RedChain::new(db, [RedLink::Our]) } + #[expect(dead_code)] pub fn is_provably(self, env: &Env<'db>, predicate: Predicate) -> Errors { let db = env.db(); match predicate { @@ -117,6 +119,7 @@ impl<'db> RedLink<'db> { first.is_copy(env) } + #[expect(dead_code)] pub fn are_move(env: &Env<'db>, links: &[Self]) -> Errors { for link in links { if !link.is_move(env)? { @@ -126,6 +129,7 @@ impl<'db> RedLink<'db> { Ok(true) } + #[expect(dead_code)] pub fn are_owned(env: &Env<'db>, links: &[Self]) -> Errors { for link in links { if !link.is_owned(env)? { @@ -135,6 +139,7 @@ impl<'db> RedLink<'db> { Ok(true) } + #[expect(dead_code)] pub fn are_lent(env: &Env<'db>, links: &[Self]) -> Errors { for link in links { if !link.is_lent(env)? { @@ -144,6 +149,7 @@ impl<'db> RedLink<'db> { Ok(false) } + #[expect(dead_code)] pub fn is_owned(&self, env: &Env<'db>) -> Errors { match self { RedLink::Our => Ok(true), @@ -153,6 +159,7 @@ impl<'db> RedLink<'db> { } } + #[expect(dead_code)] pub fn is_lent(&self, env: &Env<'db>) -> Errors { match self { RedLink::Ref(..) | RedLink::Mut(..) => Ok(true), @@ -162,6 +169,7 @@ impl<'db> RedLink<'db> { } } + #[expect(dead_code)] pub fn is_move(&self, env: &Env<'db>) -> Errors { match self { RedLink::Mut(..) => Ok(true), diff --git a/components/dada-ir-sym/src/check/report.rs b/components/dada-ir-sym/src/check/report.rs index 2cc52562..25dfbd5a 100644 --- a/components/dada-ir-sym/src/check/report.rs +++ b/components/dada-ir-sym/src/check/report.rs @@ -192,10 +192,6 @@ pub enum Because<'db> { /// program that caused a conflict with the current value InferredPermBound(Direction, RedPerm<'db>, ArcOrElse<'db>), - /// Inference determined that the variable cannot be - /// known to be `Predicate` "or else" the given error would occur. - InferredIsnt(Predicate, ArcOrElse<'db>), - /// Inference determined that the variable must have /// this lower bound "or else" the given error would occur. InferredLowerBound(RedTy<'db>, ArcOrElse<'db>), @@ -296,17 +292,6 @@ impl<'db> Because<'db> { .child(or_else_diagnostic), ) } - Because::InferredIsnt(predicate, or_else) => { - let or_else_diagnostic = or_else.or_else(env, Because::JustSo); - Some(Diagnostic::info( - db, - span, - format!( - "I inferred that `{predicate}` must not be true because otherwise it would cause this error" - ), - ) - .child(or_else_diagnostic)) - } Because::InferredLowerBound(red_ty, or_else) => { let or_else_diagnostic = or_else.or_else(env, Because::JustSo); Some(Diagnostic::info( diff --git a/components/dada-ir-sym/src/check/scope.rs b/components/dada-ir-sym/src/check/scope.rs index e6b4c74d..4e2f522c 100644 --- a/components/dada-ir-sym/src/check/scope.rs +++ b/components/dada-ir-sym/src/check/scope.rs @@ -6,7 +6,7 @@ use dada_ir_ast::{ inputs::Krate, span::{Span, Spanned}, }; -use dada_util::{FromImpls, boxed_async_fn, indirect}; +use dada_util::{FromImpls, boxed_async_fn}; use salsa::Update; use serde::Serialize; @@ -19,7 +19,7 @@ use crate::{ module::SymModule, primitive::{SymPrimitive, primitives}, types::{SymGenericKind, SymGenericTerm}, - variables::{FromVar, SymVariable}, + variables::SymVariable, }, prelude::Symbol, }; diff --git a/components/dada-ir-sym/src/check/subtype/is_numeric.rs b/components/dada-ir-sym/src/check/subtype/is_numeric.rs index 19c5947a..9f9e5aaf 100644 --- a/components/dada-ir-sym/src/check/subtype/is_numeric.rs +++ b/components/dada-ir-sym/src/check/subtype/is_numeric.rs @@ -6,7 +6,6 @@ use crate::{ env::Env, inference::Direction, live_places::LivePlaces, - predicates::{Predicate, var_infer::require_infer_is}, red::RedTy, report::{Because, OrElse, OrElseHelper}, to_red::ToRedTy, diff --git a/components/dada-ir-sym/src/check/subtype/terms.rs b/components/dada-ir-sym/src/check/subtype/terms.rs index f4a4962f..002a4c69 100644 --- a/components/dada-ir-sym/src/check/subtype/terms.rs +++ b/components/dada-ir-sym/src/check/subtype/terms.rs @@ -15,8 +15,7 @@ use crate::{ ir::{ classes::SymAggregateStyle, indices::{FromInfer, InferVarIndex}, - types::{SymGenericKind, SymGenericTerm, SymPerm, SymTy, SymTyKind, SymTyName, Variance}, - variables, + types::{SymGenericKind, SymGenericTerm, SymPerm, SymTy, SymTyKind, SymTyName}, }, }; diff --git a/components/dada-ir-sym/src/ir/classes.rs b/components/dada-ir-sym/src/ir/classes.rs index 8734f87c..1d8d04b3 100644 --- a/components/dada-ir-sym/src/ir/classes.rs +++ b/components/dada-ir-sym/src/ir/classes.rs @@ -2,7 +2,7 @@ use std::borrow::Cow; use dada_ir_ast::{ ast::{AstAggregate, AstAggregateKind, AstFieldDecl, AstMember, Identifier, SpannedIdentifier}, - span::{Span, Spanned}, + span::{SourceSpanned, Span, Spanned}, }; use dada_parser::prelude::*; use dada_util::{FromImpls, SalsaSerialize}; @@ -254,6 +254,18 @@ impl<'db> ScopeTreeNode<'db> for SymAggregate<'db> { } } +impl<'db> Spanned<'db> for SymAggregate<'db> { + fn span(&self, db: &'db dyn dada_ir_ast::Db) -> Span<'db> { + self.name_span(db) + } +} + +impl<'db> SourceSpanned<'db> for SymAggregate<'db> { + fn source_span(&self, db: &'db dyn dada_ir_ast::Db) -> Span<'db> { + self.source(db).span(db) + } +} + #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Debug)] pub enum SymAggregateStyle { Struct, @@ -289,6 +301,15 @@ impl<'db> Spanned<'db> for SymClassMember<'db> { } } +impl<'db> SourceSpanned<'db> for SymClassMember<'db> { + fn source_span(&self, db: &'db dyn dada_ir_ast::Db) -> Span<'db> { + match self { + SymClassMember::SymField(f) => f.source_span(db), + SymClassMember::SymFunction(f) => f.source_span(db), + } + } +} + /// Symbol for a field of a class, struct, or enum #[derive(SalsaSerialize)] #[salsa::tracked(debug)] @@ -327,15 +348,15 @@ impl<'db> SymField<'db> { } } -impl<'db> Spanned<'db> for SymAggregate<'db> { - fn span(&self, db: &'db dyn dada_ir_ast::Db) -> Span<'db> { +impl<'db> Spanned<'db> for SymField<'db> { + fn span(&self, db: &'db dyn dada_ir_ast::Db) -> dada_ir_ast::span::Span<'db> { self.name_span(db) } } -impl<'db> Spanned<'db> for SymField<'db> { - fn span(&self, db: &'db dyn dada_ir_ast::Db) -> dada_ir_ast::span::Span<'db> { - self.name_span(db) +impl<'db> SourceSpanned<'db> for SymField<'db> { + fn source_span(&self, db: &'db dyn dada_ir_ast::Db) -> Span<'db> { + self.source(db).span(db) } } diff --git a/components/dada-ir-sym/src/ir/exprs.rs b/components/dada-ir-sym/src/ir/exprs.rs index 51b649a2..982a0663 100644 --- a/components/dada-ir-sym/src/ir/exprs.rs +++ b/components/dada-ir-sym/src/ir/exprs.rs @@ -22,7 +22,7 @@ use crate::{ use dada_ir_ast::{ ast::{AstBinaryOp, PermissionOp}, diagnostic::{Err, Reported}, - span::Span, + span::{SourceSpanned, Span}, }; use dada_util::SalsaSerialize; use ordered_float::OrderedFloat; @@ -88,6 +88,12 @@ impl<'db> SymExpr<'db> { } } +impl<'db> SourceSpanned<'db> for SymExpr<'db> { + fn source_span(&self, db: &'db dyn dada_ir_ast::Db) -> Span<'db> { + self.span(db) + } +} + impl<'db> Err<'db> for SymExpr<'db> { fn err(db: &'db dyn dada_ir_ast::Db, r: Reported) -> Self { SymExpr::new(db, r.span(db), SymTy::err(db, r), SymExprKind::Error(r)) diff --git a/components/dada-ir-sym/src/ir/functions.rs b/components/dada-ir-sym/src/ir/functions.rs index ea93f91a..61ddfd7f 100644 --- a/components/dada-ir-sym/src/ir/functions.rs +++ b/components/dada-ir-sym/src/ir/functions.rs @@ -5,7 +5,7 @@ use dada_ir_ast::{ AstAggregate, AstFunction, AstFunctionEffects, AstFunctionInput, Identifier, SpannedIdentifier, }, - span::{Span, Spanned}, + span::{SourceSpanned, Span, Spanned}, }; use dada_util::{FromImpls, SalsaSerialize}; use salsa::Update; @@ -69,6 +69,12 @@ impl<'db> Spanned<'db> for SymFunction<'db> { } } +impl<'db> SourceSpanned<'db> for SymFunction<'db> { + fn source_span(&self, db: &'db dyn dada_ir_ast::Db) -> Span<'db> { + self.source(db).source_span(db) + } +} + #[salsa::tracked] impl<'db> SymFunction<'db> { /// Name of the function. @@ -161,6 +167,15 @@ impl<'db> SymFunctionSource<'db> { } } +impl<'db> SourceSpanned<'db> for SymFunctionSource<'db> { + fn source_span(&self, db: &'db dyn dada_ir_ast::Db) -> Span<'db> { + match self { + SymFunctionSource::Function(ast_function) => ast_function.span(db), + SymFunctionSource::Constructor(_, ast_aggregate) => ast_aggregate.span(db), + } + } +} + /// Set of effects that can be declared on the function. #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Debug)] pub struct SymFunctionEffects { diff --git a/components/dada-ir-sym/src/ir/module.rs b/components/dada-ir-sym/src/ir/module.rs index ea17b885..89cecec6 100644 --- a/components/dada-ir-sym/src/ir/module.rs +++ b/components/dada-ir-sym/src/ir/module.rs @@ -2,7 +2,7 @@ use dada_ir_ast::{ ast::{AstItem, AstModule, AstUse, Identifier}, diagnostic::{Diagnostic, Level}, inputs::SourceFile, - span::{Span, Spanned}, + span::{SourceSpanned, Span, Spanned}, }; use dada_parser::prelude::SourceFileParse; use dada_util::{FromImpls, Map, SalsaSerialize}; @@ -243,3 +243,13 @@ impl<'db> Spanned<'db> for SymItem<'db> { } } } + +impl<'db> SourceSpanned<'db> for SymItem<'db> { + fn source_span(&self, db: &'db dyn dada_ir_ast::Db) -> Span<'db> { + match self { + SymItem::SymClass(a) => a.source_span(db), + SymItem::SymFunction(f) => f.source_span(db), + SymItem::SymPrimitive(_) => well_known::prelude_span(db), + } + } +} diff --git a/components/dada-ir-sym/src/lib.rs b/components/dada-ir-sym/src/lib.rs index 5212b712..33acd71f 100644 --- a/components/dada-ir-sym/src/lib.rs +++ b/components/dada-ir-sym/src/lib.rs @@ -1,7 +1,5 @@ //! "Symbolic IR": High-level, type checked representaton. Derived from the AST. -#![feature(let_chains)] - pub use dada_ir_ast::Db; mod check; diff --git a/components/dada-lang/src/main_lib/test.rs b/components/dada-lang/src/main_lib/test.rs index b3907ce0..89c0fb64 100644 --- a/components/dada-lang/src/main_lib/test.rs +++ b/components/dada-lang/src/main_lib/test.rs @@ -6,7 +6,7 @@ use std::{ use dada_compiler::{Compiler, RealFs}; use dada_ir_ast::diagnostic::Diagnostic; use dada_util::{Fallible, bail}; -use expected::ExpectedDiagnostic; +use expected::{ExpectedDiagnostic, Probe}; use indicatif::ProgressBar; use panic_hook::CapturedPanic; use rayon::prelude::*; @@ -39,6 +39,15 @@ enum Failure { MissingDiagnostic(ExpectedDiagnostic), InternalCompilerError(Option), + /// The probe at the given location did not yield the expected result. + Probe { + /// Probe performed + probe: Probe, + + /// Actual result returned + actual: String, + }, + /// Auxiliary file at `path` did not have expected contents. /// /// See `diff`. @@ -320,6 +329,56 @@ impl FailedTest { writeln!(result, "No details available. :(")?; } } + Failure::Probe { probe, actual } => { + writeln!(result)?; + writeln!(result, "# Probe return unexpected result")?; + writeln!(result)?; + + let (probe_line, probe_start_col) = + probe.span.source_file.line_col(db, probe.span.start); + let (probe_end_line, probe_end_col) = + probe.span.source_file.line_col(db, probe.span.end); + assert_eq!( + probe_line, probe_end_line, + "multiline probe not currently possible" + ); + + writeln!( + result, + "Probe location: {u}:{l}:{c}:{l}:{e}", + u = probe.span.source_file.url_display(db), + l = probe_line.as_u32() + 1, + c = probe_start_col.as_u32() + 1, + e = probe_end_col.as_u32() + 1, + )?; + writeln!(result, "Probe expected: {e}", e = probe.message)?; + writeln!(result, "Probe got: {actual}")?; + + let file_text = probe.span.source_file.contents_if_ok(db); + let line_range = probe.span.source_file.line_range(db, probe_line); + if let Some(line_text) = + file_text.get(line_range.start.as_usize()..line_range.end.as_usize()) + { + writeln!(result)?; + writeln!(result, "```")?; + write!(result, "{line_text}")?; + writeln!( + result, + "{s}{c} probe `{k:?}` expected `{e}`, got `{a}`", + s = std::iter::repeat(' ') + .take(probe_start_col.as_usize()) + .collect::(), + c = std::iter::repeat('^') + .take((probe_end_col - probe_start_col).as_usize()) + .collect::(), + k = probe.kind, + e = probe.message, + a = actual, + )?; + writeln!(result, "```")?; + writeln!(result)?; + } + } } } diff --git a/components/dada-lang/src/main_lib/test/expected.rs b/components/dada-lang/src/main_lib/test/expected.rs index 2b794248..6cbe7c18 100644 --- a/components/dada-lang/src/main_lib/test/expected.rs +++ b/components/dada-lang/src/main_lib/test/expected.rs @@ -36,6 +36,40 @@ pub struct TestExpectations { expected_diagnostics: Vec, fn_asts: bool, codegen: bool, + probes: Vec, +} + +/// A "probe" is a test where we inspect some piece of compiler state +/// at a particular location, for example, to find out the inferred +/// type of a variable or expression. +/// +/// Probes are denoted with `#? kind: expected` or `#? ^^^ kind: expected`. +/// +/// The first syntax indicates the probe occurs at the column of the `#`. +/// +/// The second syntax indicates a probe with a span. +/// +/// The "kind" is some string matching `[a-z_]+` indicating the sort of probe +/// to perform. +/// +/// The "expected" part is a string (or `/string` for a regular expression) +/// that the probe should return. +#[derive(Clone, Debug)] +pub struct Probe { + /// Location of the probe. + pub span: AbsoluteSpan, + + /// Kind of probe. + pub kind: ProbeKind, + + /// Message expected + pub message: Regex, +} + +#[derive(Copy, Clone, Debug)] +pub enum ProbeKind { + /// Tests the type of the variable declared here + VariableType, } enum Bless { @@ -49,7 +83,15 @@ lazy_static::lazy_static! { } lazy_static::lazy_static! { - static ref DIAGNOSTIC_RE: Regex = Regex::new(r"^(?P
[^#]*)#!(?P\s*)(?P\^+)?(?P /)?\s*(?P.*)").unwrap();
+    static ref DIAGNOSTIC_RE: Regex = Regex::new(r"^(?P
[^#]*)#!(?P\s*)(?P\^+)?\s*(?P/)?(?P.*)").unwrap();
+}
+
+lazy_static::lazy_static! {
+    static ref PROBE_RE: Regex = Regex::new(r"^(?P
[^#]*)#\?(?P\s*)(?P\^+)?\s+(?P[A-Za-z_]+):\s*(?P/)?(?P.*)").unwrap();
+}
+
+lazy_static::lazy_static! {
+    static ref ERROR_RE: Regex = Regex::new(r"^(?P
[^#]*)(?#[^ a-zA-Z0-9])").unwrap();
 }
 
 impl TestExpectations {
@@ -71,6 +113,7 @@ impl TestExpectations {
             expected_diagnostics: vec![],
             fn_asts: false,
             codegen: true,
+            probes: vec![],
         };
         expectations.initialize(db)?;
         Ok(expectations)
@@ -117,49 +160,112 @@ impl TestExpectations {
             }
 
             // Check if this line contains an expected diagnostic.
-            let Some(c) = DIAGNOSTIC_RE.captures(line) else {
-                continue;
-            };
-
-            // Find the line on which the diagnostic will be expected to occur.
-            let Some(last_interesting_line) = last_interesting_line else {
-                bail!("found diagnostic on line with no previous interesting line");
-            };
-
-            // Extract the expected span: if the comment contains `^^^` markers, it needs to be
-            // exactly as given, but otherwise it just has to start somewhere on the line.
-            let pre = c.name("pre").unwrap().as_str();
-            let pad = c.name("pad").unwrap().as_str();
-            let span = match c.name("col") {
-                Some(c) => {
-                    let carrot_start =
-                        line_starts[last_interesting_line] + pre.len() + 2 + pad.len();
-                    let carrot_end = carrot_start + c.as_str().len();
-
-                    ExpectedSpan::MustEqual(AbsoluteSpan {
+            if let Some(c) = DIAGNOSTIC_RE.captures(line) {
+                // Find the line on which the diagnostic will be expected to occur.
+                let Some(last_interesting_line) = last_interesting_line else {
+                    bail!("found diagnostic on line with no previous interesting line");
+                };
+
+                // Extract the expected span: if the comment contains `^^^` markers, it needs to be
+                // exactly as given, but otherwise it just has to start somewhere on the line.
+                let pre = c.name("pre").unwrap().as_str();
+                let pad = c.name("pad").unwrap().as_str();
+                let span = match c.name("col") {
+                    Some(c) => {
+                        let carrot_start =
+                            line_starts[last_interesting_line] + pre.len() + 2 + pad.len();
+                        let carrot_end = carrot_start + c.as_str().len();
+
+                        ExpectedSpan::MustEqual(AbsoluteSpan {
+                            source_file: self.source_file,
+                            start: AbsoluteOffset::from(carrot_start),
+                            end: AbsoluteOffset::from(carrot_end),
+                        })
+                    }
+                    None => ExpectedSpan::MustStartWithin(AbsoluteSpan {
                         source_file: self.source_file,
-                        start: AbsoluteOffset::from(carrot_start),
-                        end: AbsoluteOffset::from(carrot_end),
-                    })
-                }
-                None => ExpectedSpan::MustStartWithin(AbsoluteSpan {
-                    source_file: self.source_file,
-                    start: AbsoluteOffset::from(line_starts[last_interesting_line]),
-                    end: AbsoluteOffset::from(
-                        line_starts[last_interesting_line + 1].saturating_sub(1),
-                    ),
-                }),
-            };
-
-            // Find the expected message (which may be a regular expression).
-            let message = match c.name("re") {
-                Some(_) => Regex::new(c.name("msg").unwrap().as_str())?,
-                None => Regex::new(®ex::escape(c.name("msg").unwrap().as_str()))?,
-            };
-
-            // Push onto the list of expected diagnostics.
-            self.expected_diagnostics
-                .push(ExpectedDiagnostic { span, message });
+                        start: AbsoluteOffset::from(line_starts[last_interesting_line]),
+                        end: AbsoluteOffset::from(
+                            line_starts[last_interesting_line + 1].saturating_sub(1),
+                        ),
+                    }),
+                };
+
+                // Find the expected message (which may be a regular expression).
+                let message = match c.name("re") {
+                    Some(_) => Regex::new(c.name("msg").unwrap().as_str())?,
+                    None => Regex::new(®ex::escape(c.name("msg").unwrap().as_str()))?,
+                };
+
+                // Push onto the list of expected diagnostics.
+                self.expected_diagnostics
+                    .push(ExpectedDiagnostic { span, message });
+            } else if let Some(c) = PROBE_RE.captures(line) {
+                // Find the line on which the diagnostic will be expected to occur.
+                let Some(last_interesting_line) = last_interesting_line else {
+                    bail!("found probe on line with no previous interesting line");
+                };
+
+                // Extract the expected span: if the probe contains `^^^` markers, use the span
+                // of the `^^^` markers, but otherwise use the single `#` character.
+                let pre = c.name("pre").unwrap().as_str();
+                let pad = c.name("pad").unwrap().as_str();
+                let span = match c.name("col") {
+                    Some(c) => {
+                        let carrot_start =
+                            line_starts[last_interesting_line] + pre.len() + 2 + pad.len();
+                        let carrot_end = carrot_start + c.as_str().len();
+
+                        AbsoluteSpan {
+                            source_file: self.source_file,
+                            start: AbsoluteOffset::from(carrot_start),
+                            end: AbsoluteOffset::from(carrot_end),
+                        }
+                    }
+                    None => {
+                        let hash_start = line_starts[last_interesting_line] + pre.len();
+                        AbsoluteSpan {
+                            source_file: self.source_file,
+                            start: AbsoluteOffset::from(hash_start),
+                            end: AbsoluteOffset::from(hash_start + 1),
+                        }
+                    }
+                };
+
+                let valid_probe_kinds = &[("VariableType", ProbeKind::VariableType)];
+                let user_probe_kind = c.name("kind").unwrap().as_str();
+                let Some(&(_, kind)) = valid_probe_kinds
+                    .iter()
+                    .find(|pair| pair.0 == user_probe_kind)
+                else {
+                    bail!(
+                        "unknown probe kind: `{user_probe_kind}`, valid probes are: {}",
+                        valid_probe_kinds
+                            .iter()
+                            .map(|pair| pair.0)
+                            .collect::>()
+                            .join(", ")
+                    )
+                };
+
+                // Find the expected message (which may be a regular expression).
+                let message = match c.name("re") {
+                    Some(_) => Regex::new(c.name("msg").unwrap().as_str())?,
+                    None => Regex::new(®ex::escape(c.name("msg").unwrap().as_str()))?,
+                };
+
+                // Push onto the list of expected diagnostics.
+                self.probes.push(Probe {
+                    span,
+                    kind,
+                    message,
+                });
+            } else if let Some(c) = ERROR_RE.captures(line) {
+                bail!(
+                    "comment starting with `{p}` looks suspiciously like an annotation but we didn't recognize it",
+                    p = c.name("suspicious").unwrap().as_str()
+                );
+            }
         }
 
         self.expected_diagnostics.sort_by_key(|e| *e.span());
@@ -217,6 +323,8 @@ impl TestExpectations {
             let _wasm_bytes = compiler.codegen_main_fn(self.source_file);
         }
 
+        test.failures.extend(self.perform_probes(compiler));
+
         for diagnostic in &actual_diagnostics {
             writeln!(
                 test.full_compiler_output,
@@ -235,6 +343,28 @@ impl TestExpectations {
         }
     }
 
+    fn perform_probes(&self, compiler: &Compiler) -> Vec {
+        self.probes
+            .iter()
+            .filter_map(|probe| {
+                let actual = match probe.kind {
+                    ProbeKind::VariableType => compiler
+                        .probe_variable_type(probe.span)
+                        .unwrap_or_else(|| "".to_string()),
+                };
+
+                if probe.message.is_match(&actual) {
+                    None
+                } else {
+                    Some(Failure::Probe {
+                        probe: probe.clone(),
+                        actual,
+                    })
+                }
+            })
+            .collect()
+    }
+
     fn generate_fn_asts(&self, compiler: &mut Compiler) -> String {
         compiler.fn_asts(self.source_file)
     }
diff --git a/components/dada-probe/Cargo.toml b/components/dada-probe/Cargo.toml
new file mode 100644
index 00000000..372b9516
--- /dev/null
+++ b/components/dada-probe/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "dada-probe"
+version.workspace = true
+repository.workspace = true
+edition.workspace = true
+
+[dependencies]
+dada-ir-ast = { version = "0.1.0", path = "../dada-ir-ast" }
+dada-ir-sym = { version = "0.1.0", path = "../dada-ir-sym" }
+dada-parser = { version = "0.1.0", path = "../dada-parser" }
+dada-util = { version = "0.1.0", path = "../dada-util" }
+salsa = { workspace = true }
diff --git a/components/dada-probe/src/lib.rs b/components/dada-probe/src/lib.rs
new file mode 100644
index 00000000..6c682758
--- /dev/null
+++ b/components/dada-probe/src/lib.rs
@@ -0,0 +1,136 @@
+use std::ops::ControlFlow;
+
+use dada_ir_ast::span::{AbsoluteSpan, SourceSpanned};
+pub use dada_ir_sym::Db;
+use dada_ir_sym::{
+    ir::{
+        exprs::{SymExpr, SymExprKind},
+        functions::SymFunction,
+        module::SymItem,
+    },
+    prelude::{CheckedBody, Symbol},
+};
+
+/// Probe for the type of a variable found in a given file at a given span.
+pub fn probe_variable_type<'db>(db: &'db dyn crate::Db, span: AbsoluteSpan) -> Option {
+    // We expect `span` to be located in
+    visit_exprs(db, span, &|expr| {
+        if let SymExprKind::LetIn {
+            lv,
+            ty,
+            initializer: _,
+            body: _,
+        } = expr.kind(db)
+            && lv.span(db).absolute_span(db).contains(span)
+        {
+            ControlFlow::Break(ty.to_string())
+        } else {
+            ControlFlow::Continue(())
+        }
+    })
+}
+
+/// Find the module item containing `span`
+fn find_item<'db>(db: &'db dyn crate::Db, span: AbsoluteSpan) -> Option> {
+    let module = span.source_file.symbol(db);
+    module
+        .items(db)
+        .find(|item| item.source_span(db).absolute_span(db).contains(span))
+}
+
+/// Find the fn or method containing `span`
+fn find_func<'db>(db: &'db dyn crate::Db, span: AbsoluteSpan) -> Option> {
+    match find_item(db, span)? {
+        SymItem::SymClass(aggr) => aggr
+            .methods(db)
+            .find(|m| m.source_span(db).absolute_span(db).contains(span)),
+        SymItem::SymFunction(func) => return Some(func),
+        SymItem::SymPrimitive(_) => return None,
+    }
+}
+
+/// Walk all expressions containing the given `span` and invoke `op`.
+/// Stops if `op` returns `ControlFlow::Break`.
+fn visit_exprs<'db, B>(
+    db: &'db dyn crate::Db,
+    span: AbsoluteSpan,
+    op: &dyn Fn(SymExpr<'db>) -> ControlFlow,
+) -> Option {
+    let func = find_func(db, span)?;
+    let expr = func.checked_body(db)?;
+    walk_expr_and_visit(db, expr, span, op)
+}
+
+fn walk_expr_and_visit<'db, B>(
+    db: &'db dyn crate::Db,
+    expr: SymExpr<'db>,
+    span: AbsoluteSpan,
+    op: &dyn Fn(SymExpr<'db>) -> ControlFlow,
+) -> Option {
+    if !expr.source_span(db).absolute_span(db).contains(span) {
+        return None;
+    }
+
+    match op(expr) {
+        ControlFlow::Continue(()) => {}
+        ControlFlow::Break(b) => return Some(b),
+    }
+
+    match expr.kind(db) {
+        SymExprKind::Semi(e1, e2) => walk_expr_and_visit(db, *e1, span, op)
+            .or_else(|| walk_expr_and_visit(db, *e2, span, op)),
+        SymExprKind::Tuple(exprs) => {
+            for &expr in exprs {
+                if let Some(b) = walk_expr_and_visit(db, expr, span, op) {
+                    return Some(b);
+                }
+            }
+            None
+        }
+        SymExprKind::Primitive(_) => None,
+        SymExprKind::ByteLiteral(_) => None,
+        SymExprKind::LetIn {
+            lv: _,
+            ty: _,
+            initializer,
+            body,
+        } => initializer
+            .and_then(|initializer| walk_expr_and_visit(db, initializer, span, op))
+            .or_else(|| walk_expr_and_visit(db, *body, span, op)),
+        SymExprKind::Await {
+            future,
+            await_keyword: _,
+        } => walk_expr_and_visit(db, *future, span, op),
+        SymExprKind::Assign { place: _, value } => walk_expr_and_visit(db, *value, span, op),
+        SymExprKind::PermissionOp(_, _) => None,
+        SymExprKind::Call {
+            function: _,
+            substitution: _,
+            arg_temps: _,
+        } => None,
+        SymExprKind::Return(sym_expr) => walk_expr_and_visit(db, *sym_expr, span, op),
+        SymExprKind::Not {
+            operand,
+            op_span: _,
+        } => walk_expr_and_visit(db, *operand, span, op),
+        SymExprKind::BinaryOp(_, lhs, rhs) => walk_expr_and_visit(db, *lhs, span, op)
+            .or_else(|| walk_expr_and_visit(db, *rhs, span, op)),
+        SymExprKind::Aggregate { ty: _, fields } => {
+            for &field in fields {
+                if let Some(b) = walk_expr_and_visit(db, field, span, op) {
+                    return Some(b);
+                }
+            }
+            None
+        }
+        SymExprKind::Match { arms } => {
+            for arm in arms {
+                if let Some(b) = walk_expr_and_visit(db, arm.body, span, op) {
+                    return Some(b);
+                }
+            }
+            None
+        }
+        SymExprKind::Error(_) => None,
+    }
+}
diff --git a/rust-toolchain b/rust-toolchain
index fb1bd865..07ade694 100644
--- a/rust-toolchain
+++ b/rust-toolchain
@@ -1 +1 @@
-nightly-2025-03-01
+nightly
\ No newline at end of file
diff --git a/tests/type_check/infer_shared_string.dada b/tests/type_check/infer_shared_string.dada
new file mode 100644
index 00000000..ce030b51
--- /dev/null
+++ b/tests/type_check/infer_shared_string.dada
@@ -0,0 +1,8 @@
+class Contents {
+    s: my String
+}
+
+fn test(c: my Contents) {
+    let x = c.s
+    #?  ^ VariableType: shared[c.s] String
+}
\ No newline at end of file
diff --git a/tests/type_check/infer_var_u32.dada b/tests/type_check/infer_var_u32.dada
new file mode 100644
index 00000000..987b8d17
--- /dev/null
+++ b/tests/type_check/infer_var_u32.dada
@@ -0,0 +1,8 @@
+fn main() {
+    let x = get()
+    #?  ^ VariableType: u32
+}
+
+fn get() -> u32 {
+    22
+}
\ No newline at end of file