Skip to content

Commit 267f786

Browse files
authored
Merge pull request #2047 from harehare/feat/try-catch-error-binder
✨ feat(mq-lang): add error binder to try/catch (`catch(e):`)
2 parents a76e3dc + c13b670 commit 267f786

17 files changed

Lines changed: 432 additions & 30 deletions

File tree

crates/mq-check/src/constraint.rs

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -211,8 +211,24 @@ pub(super) fn generate_symbol_constraints(
211211

212212
// Parameters and pattern variables get fresh type variables
213213
SymbolKind::Parameter | SymbolKind::PatternVariable { .. } => {
214-
let ty_var = ctx.fresh_var();
215-
ctx.set_symbol_type(symbol_id, Type::Var(ty_var));
214+
// `catch(e):` always binds `e` to `{message: string}` (see `Evaluator::eval_try`).
215+
let is_catch_error_binder = matches!(kind, SymbolKind::Parameter)
216+
&& hir
217+
.symbol(symbol_id)
218+
.and_then(|s| s.parent)
219+
.and_then(|parent_id| hir.symbol(parent_id))
220+
.is_some_and(|parent| matches!(parent.kind, SymbolKind::Catch));
221+
222+
if is_catch_error_binder {
223+
let ty = Type::record(
224+
std::collections::BTreeMap::from([("message".to_string(), Type::String)]),
225+
Type::RowEmpty,
226+
);
227+
ctx.set_symbol_type(symbol_id, ty);
228+
} else {
229+
let ty_var = ctx.fresh_var();
230+
ctx.set_symbol_type(symbol_id, Type::Var(ty_var));
231+
}
216232
}
217233

218234
// Variables get fresh type variables, constrained to their initializer
@@ -1856,15 +1872,25 @@ pub(super) fn generate_symbol_constraints(
18561872
// Different concrete types: use Union type to represent both possibilities
18571873
let union_ty = Type::union(vec![resolved_try, resolved_catch]);
18581874
ctx.set_symbol_type(symbol_id, union_ty);
1859-
} else {
1860-
// Same type or at least one is a type variable: unify them
1875+
} else if both_concrete {
1876+
// Same concrete type: unify them directly.
18611877
ctx.add_constraint(Constraint::Equal(
18621878
try_ty.clone(),
18631879
catch_ty,
18641880
range,
18651881
ConstraintOrigin::General,
18661882
));
18671883
ctx.set_symbol_type(symbol_id, try_ty);
1884+
} else {
1885+
// A branch type is still pending deferred resolution — decide later.
1886+
let ty_var = ctx.fresh_var();
1887+
ctx.set_symbol_type(symbol_id, Type::Var(ty_var));
1888+
ctx.add_deferred_try_catch(infer::DeferredTryCatch {
1889+
symbol_id,
1890+
try_ty,
1891+
catch_ty,
1892+
range,
1893+
});
18681894
}
18691895
} else if !children.is_empty() {
18701896
let try_ty = ctx.get_or_create_symbol_type(children[0]);

crates/mq-check/src/deferred.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,50 @@ fn check_union_members(
395395
}
396396
}
397397

398+
/// Resolves deferred try/catch branch-type merges, after other deferred passes
399+
/// (record/tuple access, overloads, ...) have settled the branch types.
400+
pub(crate) fn resolve_deferred_try_catches(ctx: &mut InferenceContext) -> bool {
401+
let entries = ctx.take_deferred_try_catches();
402+
if entries.is_empty() {
403+
return false;
404+
}
405+
406+
for entry in entries {
407+
let Some(result_ty) = ctx.get_symbol_type(entry.symbol_id).cloned() else {
408+
continue;
409+
};
410+
let resolved_try = ctx.resolve_type(&entry.try_ty);
411+
let resolved_catch = ctx.resolve_type(&entry.catch_ty);
412+
let both_concrete = !resolved_try.is_var() && !resolved_catch.is_var();
413+
let same_discriminant =
414+
both_concrete && std::mem::discriminant(&resolved_try) == std::mem::discriminant(&resolved_catch);
415+
416+
let merged_ty = if both_concrete && !same_discriminant {
417+
types::Type::union(vec![resolved_try, resolved_catch])
418+
} else {
419+
ctx.add_constraint(Constraint::Equal(
420+
entry.try_ty.clone(),
421+
entry.catch_ty,
422+
entry.range,
423+
ConstraintOrigin::General,
424+
));
425+
entry.try_ty
426+
};
427+
ctx.add_constraint(Constraint::Equal(
428+
result_ty,
429+
merged_ty,
430+
entry.range,
431+
ConstraintOrigin::General,
432+
));
433+
434+
// Solve immediately so an outer try/catch that depends on this result
435+
// (nested `try: (try: ... catch: ...) catch: ...`) sees it resolved.
436+
unify::solve_constraints(ctx);
437+
}
438+
439+
true
440+
}
441+
398442
/// Resolves deferred overloads after the first round of unification.
399443
///
400444
/// Binary/unary operators whose operands were type variables during constraint

crates/mq-check/src/infer.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,22 @@ pub struct DeferredTupleAccess {
138138
pub range: Option<mq_lang::Range>,
139139
}
140140

141+
/// A deferred try/catch branch-type merge for post-unification resolution.
142+
///
143+
/// A branch's type may depend on another deferred pass (e.g. record field
144+
/// access), so deciding Union-vs-Equal must wait until that pass has run.
145+
#[derive(Debug, Clone)]
146+
pub struct DeferredTryCatch {
147+
/// The `Try` symbol whose type is being resolved
148+
pub symbol_id: SymbolId,
149+
/// The try body's type
150+
pub try_ty: Type,
151+
/// The catch body's type
152+
pub catch_ty: Type,
153+
/// Source range for error reporting
154+
pub range: Option<mq_lang::Range>,
155+
}
156+
141157
/// A single variable type narrowing entry.
142158
///
143159
/// Represents a narrowing of a variable's type within a specific branch,
@@ -211,6 +227,8 @@ pub struct InferenceContext {
211227
deferred_tuple_accesses: Vec<DeferredTupleAccess>,
212228
/// Deferred bracket accesses on function call return values (e.g. `f(x)["key"]`)
213229
deferred_call_return_accesses: Vec<DeferredCallReturnAccess>,
230+
/// Deferred try/catch branch-type merges for post-unification resolution
231+
deferred_try_catches: Vec<DeferredTryCatch>,
214232
/// Type narrowings collected from type predicate conditions in if/elif expressions
215233
type_narrowings: Vec<TypeNarrowing>,
216234
/// Cross-arm narrowings collected from match expressions
@@ -242,6 +260,7 @@ impl InferenceContext {
242260
deferred_selector_accesses: Vec::new(),
243261
deferred_tuple_accesses: Vec::new(),
244262
deferred_call_return_accesses: Vec::new(),
263+
deferred_try_catches: Vec::new(),
245264
type_narrowings: Vec::new(),
246265
cross_arm_narrowings: Vec::new(),
247266
strict_array,
@@ -361,6 +380,16 @@ impl InferenceContext {
361380
std::mem::take(&mut self.deferred_call_return_accesses)
362381
}
363382

383+
/// Adds a deferred try/catch branch-type merge
384+
pub fn add_deferred_try_catch(&mut self, entry: DeferredTryCatch) {
385+
self.deferred_try_catches.push(entry);
386+
}
387+
388+
/// Takes all deferred try/catch entries (consumes them)
389+
pub fn take_deferred_try_catches(&mut self) -> Vec<DeferredTryCatch> {
390+
std::mem::take(&mut self.deferred_try_catches)
391+
}
392+
364393
/// Adds a type narrowing collected from a type predicate condition
365394
pub fn add_type_narrowing(&mut self, narrowing: TypeNarrowing) {
366395
self.type_narrowings.push(narrowing);

crates/mq-check/src/lib.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,14 @@ impl TypeChecker {
352352
unify::solve_constraints(&mut ctx);
353353
}
354354

355+
// Resolve deferred try/catch branch-type merges, then retry accesses/operators
356+
// that depend on a `let v = try: ... catch: ...` variable's now-final type.
357+
deferred::resolve_deferred_try_catches(&mut ctx);
358+
if deferred::resolve_deferred_tuple_accesses(&mut ctx) {
359+
unify::solve_constraints(&mut ctx);
360+
}
361+
deferred::resolve_deferred_overloads(&mut ctx);
362+
355363
// Check operators inside user-defined function bodies against call-site types.
356364
// Uses local substitution (original params → call-site args) without modifying
357365
// global state, so multiple call sites don't interfere.

crates/mq-check/tests/integration_test.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -527,6 +527,19 @@ fn test_try_catch() {
527527
);
528528
}
529529

530+
#[test]
531+
fn test_try_catch_error_binder() {
532+
assert!(check_types(r#"try: 42 / 0 catch(e): e["message"];"#).is_empty());
533+
}
534+
535+
#[test]
536+
fn test_try_catch_error_binder_undefined_field() {
537+
assert!(
538+
!check_types(r#"try: 42 / 0 catch(e): e["not_a_field"];"#).is_empty(),
539+
"accessing a field the error binder doesn't have should be a type error"
540+
);
541+
}
542+
530543
// Polymorphic Function Type Checking
531544

532545
#[rstest]

crates/mq-formatter/src/formatter.rs

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1061,12 +1061,24 @@ impl Formatter {
10611061
self.append_indent(indent_level);
10621062
self.output.push_str(&node.to_string());
10631063

1064-
if let Some(colon) = node.children.first() {
1064+
// Optional error binder: `catch(e):`. Render the `(`, ident, `)` as-is
1065+
// before the colon-or-do separator handled below.
1066+
let colon_index = match Self::find_token_position(node, |kind| matches!(kind, mq_lang::TokenKind::RParen)) {
1067+
Some(rparen_index) => {
1068+
node.children.iter().take(rparen_index + 1).for_each(|child| {
1069+
self.format_node(mq_lang::Shared::clone(child), 0);
1070+
});
1071+
rparen_index + 1
1072+
}
1073+
None => 0,
1074+
};
1075+
1076+
if let Some(colon) = node.children.get(colon_index) {
10651077
self.output.push_str(&colon.to_string());
10661078
self.append_space();
10671079
}
10681080

1069-
for child in node.children.iter().skip(1) {
1081+
for child in node.children.iter().skip(colon_index + 1) {
10701082
let child_indent = if child.has_new_line() {
10711083
indent_level + 1
10721084
} else {
@@ -2275,6 +2287,25 @@ catch:
22752287
"
22762288
)]
22772289
#[case::try_catch_oneline("try: process() catch: handle_error()", "try: process() catch: handle_error()")]
2290+
#[case::try_catch_with_binder(
2291+
"try: process() catch(e): handle_error(e)",
2292+
"try: process() catch(e): handle_error(e)"
2293+
)]
2294+
#[case::try_catch_with_binder_extra_spaces(
2295+
"try: process() catch( e ): handle_error(e)",
2296+
"try: process() catch(e): handle_error(e)"
2297+
)]
2298+
#[case::try_catch_with_binder_multiline(
2299+
r#"try:
2300+
process()
2301+
catch(e):
2302+
handle_error(e)"#,
2303+
"try:
2304+
process()
2305+
catch(e):
2306+
handle_error(e)
2307+
"
2308+
)]
22782309
#[case::try_catch_with_finally(
22792310
r#"try:
22802311
process()

crates/mq-hir/src/hir.rs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,8 @@ def foo(): 1", vec![" test".to_owned(), " test".to_owned(), "".to_owned()], vec!
400400
#[case::block("do \"hello\" end", "hello", SymbolKind::String)]
401401
#[case::try_("try: 1 catch: 2", "try", SymbolKind::Try)]
402402
#[case::catch_("try: 1 catch: 2", "catch", SymbolKind::Catch)]
403+
#[case::catch_with_binder("try: 1 catch(e): e", "catch", SymbolKind::Catch)]
404+
#[case::catch_error_binder_param("try: 1 catch(e): e", "e", SymbolKind::Parameter)]
403405
#[case::symbol_ident(":foo", "foo", SymbolKind::Symbol)]
404406
#[case::symbol_string(":\"hello\"", "hello", SymbolKind::Symbol)]
405407
#[case::pattern_match("match (v): | [1,2,3]: 1 end", "match", SymbolKind::Match)]
@@ -597,6 +599,68 @@ end"#;
597599
assert!(hir.errors().is_empty(), "Should have no unresolved symbols");
598600
}
599601

602+
#[test]
603+
fn test_catch_error_binder_resolution() {
604+
let mut hir = Hir::default();
605+
hir.builtin.disabled = true;
606+
607+
let code = "try: 1 catch(e): e";
608+
hir.add_code(None, code);
609+
610+
let binder_symbol = hir
611+
.symbols()
612+
.find(|(_, s)| s.kind == SymbolKind::Parameter && s.value.as_deref() == Some("e"));
613+
assert!(binder_symbol.is_some(), "catch(e) should declare e as a Parameter");
614+
615+
let ref_symbol = hir
616+
.symbols()
617+
.find(|(_, s)| s.kind == SymbolKind::Ref && s.value.as_deref() == Some("e"));
618+
assert!(ref_symbol.is_some(), "Should have a Ref symbol for e in the catch body");
619+
620+
let (ref_id, _) = ref_symbol.unwrap();
621+
let resolved = hir.resolve_reference_symbol(ref_id);
622+
623+
assert_eq!(
624+
resolved,
625+
Some(binder_symbol.unwrap().0),
626+
"e in the catch body should resolve to the catch(e) binder"
627+
);
628+
assert!(hir.errors().is_empty(), "Should have no unresolved symbols");
629+
}
630+
631+
#[test]
632+
fn test_catch_error_binder_shadows_outer_variable() {
633+
let mut hir = Hir::default();
634+
hir.builtin.disabled = true;
635+
636+
let code = "let e = \"hello\" | try: 1 catch(e): e";
637+
hir.add_code(None, code);
638+
639+
let outer_e = hir
640+
.symbols()
641+
.find(|(_, s)| s.kind == SymbolKind::Variable && s.value.as_deref() == Some("e"))
642+
.unwrap()
643+
.0;
644+
let binder_e = hir
645+
.symbols()
646+
.find(|(_, s)| s.kind == SymbolKind::Parameter && s.value.as_deref() == Some("e"))
647+
.unwrap()
648+
.0;
649+
650+
let ref_symbol = hir
651+
.symbols()
652+
.find(|(_, s)| s.kind == SymbolKind::Ref && s.value.as_deref() == Some("e"));
653+
let (ref_id, _) = ref_symbol.unwrap();
654+
let resolved = hir.resolve_reference_symbol(ref_id);
655+
656+
assert_eq!(
657+
resolved,
658+
Some(binder_e),
659+
"e inside the catch body should resolve to the catch(e) binder, not the outer `let e`"
660+
);
661+
assert_ne!(resolved, Some(outer_e));
662+
}
663+
600664
#[test]
601665
fn test_match_expression_basic() {
602666
let mut hir = Hir::default();

0 commit comments

Comments
 (0)