From 2a90757608bd0dad570e79d119d4ef2b50a23fd8 Mon Sep 17 00:00:00 2001 From: Taus Date: Thu, 5 Nov 2026 21:18:16 +0000 Subject: [PATCH 1/4] yeast: Make translation failures panic We catch any panics in `Runner::run_once`, and turn them into `Err` results. This prevents them from affecting the extraction of other files. --- shared/yeast-macros/src/lower.rs | 8 +-- shared/yeast/src/build.rs | 47 +++++------------ shared/yeast/src/captures.rs | 10 ++-- shared/yeast/src/lib.rs | 91 +++++++++++++++++++------------- shared/yeast/src/query.rs | 50 +++++++++--------- 5 files changed, 100 insertions(+), 106 deletions(-) diff --git a/shared/yeast-macros/src/lower.rs b/shared/yeast-macros/src/lower.rs index dcb232a38052..6cf8767e2f1b 100644 --- a/shared/yeast-macros/src/lower.rs +++ b/shared/yeast-macros/src/lower.rs @@ -85,18 +85,18 @@ impl Rule { #(#guard_bindings)* let ast = __ast; let #ctx = __user_ctx; - Ok(#guard) + let __guard: bool = #guard; + __guard }), Box::new(|__ast: &yeast::Ast, __output: &mut yeast::OutputAst, __captures: yeast::captures::Captures, __source_range: Option, __user_ctx: &mut _, __translator: yeast::TranslatorHandle<'_, _>| { let __skip: &[&str] = &[#(#raw_capture_names),*]; let __translated_captures = - __translator.translate_captures(&__captures, __ast, __output, __user_ctx, __skip)?; + __translator.translate_captures(&__captures, __ast, __output, __user_ctx, __skip); #(#raw_bindings)* #(#translated_bindings)* let mut #ctx = yeast::build::BuildCtx::with_translator(__ast, __output, __source_range, __user_ctx, __translator); let __result: Vec = { #replacement }; - let __result = #ctx.finish_rule(__result)?; - Ok(__result) + #ctx.finish_rule(__result) }), ) } diff --git a/shared/yeast/src/build.rs b/shared/yeast/src/build.rs index 0f659d59d285..e34c4fe78f9e 100644 --- a/shared/yeast/src/build.rs +++ b/shared/yeast/src/build.rs @@ -28,7 +28,7 @@ use crate::{ /// When constructed by the framework (via the rule! macro), `BuildCtx` also /// carries a [`TranslatorHandle`] that the [`translate`] method delegates /// to. When constructed by hand (e.g. in tests), the translator is `None` -/// and [`translate`] returns an error. +/// and [`translate`] panics. pub struct BuildCtx<'a, C: 'a = ()> { ast: &'a Ast, pub output: &'a mut OutputAst, @@ -44,10 +44,6 @@ pub struct BuildCtx<'a, C: 'a = ()> { /// Optional translator handle, populated when the context is built by /// the framework's rule driver. None when the context is built by hand. pub(crate) translator: Option>, - /// First failure encountered while auto-translating an input `Id` during - /// template construction. Templates remain infallible expressions, so - /// [`finish_rule`](Self::finish_rule) returns this error at the rule boundary. - deferred_translation_error: Option, /// Nodes built directly through this context without an explicit source /// range in either their content or constructor argument. Recursive /// translations use their own context and therefore do not contribute to @@ -64,7 +60,6 @@ impl<'a, C> BuildCtx<'a, C> { source_range: None, user_ctx, translator: None, - deferred_translation_error: None, created_nodes_without_source_range: BTreeSet::new(), } } @@ -82,7 +77,6 @@ impl<'a, C> BuildCtx<'a, C> { source_range, user_ctx, translator: None, - deferred_translation_error: None, created_nodes_without_source_range: BTreeSet::new(), } } @@ -102,7 +96,6 @@ impl<'a, C> BuildCtx<'a, C> { source_range, user_ctx, translator: Some(translator), - deferred_translation_error: None, created_nodes_without_source_range: BTreeSet::new(), } } @@ -159,10 +152,7 @@ impl<'a, C> BuildCtx<'a, C> { /// Finish the current rule invocation by applying the matched source range /// to locally-created result roots. #[doc(hidden)] - pub fn finish_rule(self, results: Vec) -> Result, String> { - if let Some(error) = self.deferred_translation_error { - return Err(error); - } + pub fn finish_rule(self, results: Vec) -> Vec { if let Some(source_range) = self.source_range { for &id in &results { if self.created_nodes_without_source_range.contains(&id) { @@ -170,7 +160,7 @@ impl<'a, C> BuildCtx<'a, C> { } } } - Ok(results) + results } /// Assign an explicit source range to a newly-built result root. @@ -300,13 +290,7 @@ impl BuildCtx<'_, C> { #[doc(hidden)] pub fn translate_into(&mut self, id: Id, out: &mut Vec) { - if self.deferred_translation_error.is_some() { - return; - } - match self.translate(id) { - Ok(ids) => out.extend(ids), - Err(error) => self.deferred_translation_error = Some(error), - } + out.extend(self.translate(id)); } /// Recursively translate every id in the given iterable via the @@ -314,26 +298,23 @@ impl BuildCtx<'_, C> { /// ids in order. /// /// The single-`Id` case works too, because `Id: IntoIterator` is a singleton iterator — so `ctx.translate(some_id)?` + /// = Id>` is a singleton iterator — so `ctx.translate(some_id)` /// returns a `Vec` containing whatever `some_id` translated to. /// - /// Errors if this `BuildCtx` was constructed by hand (without a + /// Panics if this `BuildCtx` was constructed by hand (without a /// translator handle) — for example, in unit tests that don't go /// through the rule driver. - pub fn translate( - &mut self, - ids: impl IntoIterator, - ) -> Result, String> { + pub fn translate(&mut self, ids: impl IntoIterator) -> Vec { let translator = self .translator .as_ref() - .ok_or("translate() called on a BuildCtx without a translator handle")?; + .expect("translate() called on a BuildCtx without a translator handle"); let mut out = Vec::new(); for id in ids { - let translated = translator.translate(self.ast, self.output, self.user_ctx, id)?; + let translated = translator.translate(self.ast, self.output, self.user_ctx, id); out.extend(translated); } - Ok(out) + out } /// Run `f` with a temporary child [`BuildCtx`] whose `user_ctx` is @@ -358,9 +339,9 @@ impl BuildCtx<'_, C> { /// let val = ctx.scoped(|ctx| { /// ctx.reset(); /// ctx.translate(val) - /// })?; + /// }); /// // `ctx` here is untouched by the reset inside the closure. - /// let other = ctx.translate(other_id)?; + /// let other = ctx.translate(other_id); /// ``` pub fn scoped(&mut self, f: F) -> R where @@ -373,15 +354,11 @@ impl BuildCtx<'_, C> { source_range: self.source_range, user_ctx: &mut child_user_ctx, translator: self.translator, - deferred_translation_error: None, created_nodes_without_source_range: BTreeSet::new(), }; let result = f(&mut child); let created_nodes_without_source_range = std::mem::take(&mut child.created_nodes_without_source_range); - if self.deferred_translation_error.is_none() { - self.deferred_translation_error = child.deferred_translation_error.take(); - } drop(child); self.created_nodes_without_source_range .extend(created_nodes_without_source_range); diff --git a/shared/yeast/src/captures.rs b/shared/yeast/src/captures.rs index abe56c5a1ee5..25f1e2561586 100644 --- a/shared/yeast/src/captures.rs +++ b/shared/yeast/src/captures.rs @@ -54,23 +54,23 @@ impl Captures { self.captures.entry(key).or_default().push(id); } - pub(crate) fn try_translate_except( + pub(crate) fn translate_except( &self, skip: &[&str], - mut f: impl FnMut(Id) -> Result, E>, - ) -> Result { + mut f: impl FnMut(Id) -> Vec, + ) -> TranslatedCaptures { let mut translated = TranslatedCaptures::new(); for (name, ids) in &self.captures { if skip.contains(name) { continue; } for &id in ids { - for translated_id in f(id)? { + for translated_id in f(id) { translated.insert(name, translated_id); } } } - Ok(translated) + translated } pub fn merge(&mut self, other: &Captures) { diff --git a/shared/yeast/src/lib.rs b/shared/yeast/src/lib.rs index 917a39e81f5d..771d18a9267d 100644 --- a/shared/yeast/src/lib.rs +++ b/shared/yeast/src/lib.rs @@ -1145,7 +1145,7 @@ impl<'a, C: Clone> TranslatorHandle<'a, C> { output: &mut OutputAst, user_ctx: &mut C, id: Id, - ) -> Result, String> { + ) -> Vec { apply_rules_inner( self.index, ast, @@ -1163,10 +1163,10 @@ impl<'a, C: Clone> TranslatorHandle<'a, C> { output: &mut OutputAst, user_ctx: &mut C, skip: &[&str], - ) -> Result { - captures.try_translate_except(skip, |id| { + ) -> TranslatedCaptures { + captures.translate_except(skip, |id| { if id == self.matched_root { - Err("cannot eagerly translate a capture of the matched rule root".to_string()) + panic!("cannot eagerly translate a capture of the matched rule root") } else { self.translate(ast, output, user_ctx, id) } @@ -1178,7 +1178,7 @@ impl<'a, C: Clone> TranslatorHandle<'a, C> { /// /// Takes the immutable input AST, mutable output AST, raw captured variables, /// source range of the matched node, mutable user context, and a -/// [`TranslatorHandle`]. Returns output IDs or an error. +/// [`TranslatorHandle`]. Returns output IDs. /// /// Transforms receive raw input captures. Macro-generated transforms eagerly /// translate `@` captures and translate raw ids when they are returned or @@ -1191,7 +1191,7 @@ pub type Transform = Box< Option, &mut C, TranslatorHandle<'_, C>, - ) -> Result, String> + ) -> Vec + Send + Sync, >; @@ -1200,7 +1200,7 @@ pub type Transform = Box< /// runs. Returning `false` makes the rule behave as if its query did not /// match, so the driver continues to the next rule. Guards have read-only /// access to the shared user context. -pub type Guard = Box Result + Send + Sync>; +pub type Guard = Box bool + Send + Sync>; pub struct Rule { query: QueryNode, @@ -1238,21 +1238,21 @@ impl Rule { /// Attempt to match this rule's query against `node`, returning the raw /// captures on success. Does not evaluate the guard or invoke the /// transform. - fn match_query(&self, ast: &Ast, node: Id) -> Result, String> { + fn match_query(&self, ast: &Ast, node: Id) -> Option { let mut captures = Captures::new(); - if !self.query.do_match(ast, node, &mut captures)? { - return Ok(None); + if !self.query.do_match(ast, node, &mut captures) { + return None; } - Ok(Some(captures)) + Some(captures) } /// Evaluate this rule's guard against a successful query match. An /// unguarded rule always succeeds. - fn guard_matches(&self, ast: &Ast, captures: &Captures, user_ctx: &C) -> Result { + fn guard_matches(&self, ast: &Ast, captures: &Captures, user_ctx: &C) -> bool { if let Some(guard) = &self.guard { guard(ast, captures, user_ctx) } else { - Ok(true) + true } } @@ -1266,7 +1266,7 @@ impl Rule { node: Id, user_ctx: &mut C, translator: TranslatorHandle<'_, C>, - ) -> Result, String> { + ) -> Vec { let source_range = ast.source_range_ignoring_fields(node, &self.ignored_location_fields); (self.transform)(ast, output, captures, source_range, user_ctx, translator) } @@ -1306,14 +1306,14 @@ impl<'a, C> RuleIndex<'a, C> { /// Apply the first matching rule to each visited node. Recursion proceeds /// only through captured nodes (not through the input node's children -/// directly), and an error is returned if no rule matches a visited node. +/// directly), and panics if no rule matches a visited node. fn apply_rules( rules: &[Rule], ast: &Ast, output: &mut OutputAst, user_ctx: &mut C, id: Id, -) -> Result, String> { +) -> Vec { let index = RuleIndex::new(rules); apply_rules_inner(&index, ast, output, user_ctx, id, 0) } @@ -1325,22 +1325,22 @@ fn apply_rules_inner( user_ctx: &mut C, id: Id, rewrite_depth: usize, -) -> Result, String> { +) -> Vec { if rewrite_depth > MAX_REWRITE_DEPTH { - return Err(format!( + panic!( "Desugaring exceeded maximum rewrite depth ({MAX_REWRITE_DEPTH}). \ This likely indicates a non-terminating rule cycle." - )); + ); } let node_kind = ast.get_node(id).map(|n| n.kind_name()).unwrap_or(""); for rule in index.rules_for_kind(node_kind) { - let Some(captures) = rule.match_query(ast, id)? else { + let Some(captures) = rule.match_query(ast, id) else { continue; }; - if !rule.guard_matches(ast, &captures, user_ctx)? { + if !rule.guard_matches(ast, &captures, user_ctx) { continue; } @@ -1355,11 +1355,11 @@ fn apply_rules_inner( rewrite_depth, matched_root: id, }; - let result = rule.run_transform(ast, output, captures, id, &mut local, translator)?; - return Ok(result); + let result = rule.run_transform(ast, output, captures, id, &mut local, translator); + return result; } - Err(format!("no rule matched node of kind '{node_kind}'")) + panic!("no rule matched node of kind '{node_kind}'") } /// A named bundle of exhaustive rules for the one-shot translation pass. @@ -1617,23 +1617,40 @@ impl<'a, C: Clone> Runner<'a, C> { } fn run_once(&self, ast: Ast, user_ctx: &mut C) -> Result { - if self.phases.len() != 1 { - return Err("YEAST requires exactly one translation phase".to_string()); - } + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + self.run_once_inner(ast, user_ctx) + })) + .map(Ok) + .unwrap_or_else(|payload| { + let message = if let Some(message) = payload.downcast_ref::<&str>() { + (*message).to_owned() + } else if let Some(message) = payload.downcast_ref::() { + message.clone() + } else { + "unknown panic payload".to_owned() + }; + Err(format!("YEAST translation panicked: {message}")) + }) + } + + fn run_once_inner(&self, ast: Ast, user_ctx: &mut C) -> OutputAst { + assert_eq!( + self.phases.len(), + 1, + "YEAST requires exactly one translation phase" + ); let phase = &self.phases[0]; let mut output = OutputAst::with_schema(self.schema.clone()); - let res = apply_rules(&phase.rules, &ast, &mut output, user_ctx, ast.get_root()) - .map_err(|e| format!("Phase `{}`: {e}", phase.name))?; - if res.len() != 1 { - return Err(format!( - "Phase `{}`: expected exactly one result node, got {}", - phase.name, - res.len() - )); - } + let res = apply_rules(&phase.rules, &ast, &mut output, user_ctx, ast.get_root()); + assert_eq!( + res.len(), + 1, + "expected exactly one result node, got {}", + res.len() + ); output.set_root(res[0]); output.set_source(ast.into_source()); - Ok(output) + output } } diff --git a/shared/yeast/src/query.rs b/shared/yeast/src/query.rs index c8785ab00b27..ada93086fead 100644 --- a/shared/yeast/src/query.rs +++ b/shared/yeast/src/query.rs @@ -64,56 +64,56 @@ impl QueryNode { } } - pub fn do_match(&self, ast: &Ast, node: Id, matches: &mut Captures) -> Result { + pub fn do_match(&self, ast: &Ast, node: Id, matches: &mut Captures) -> bool { match self { QueryNode::Any { match_unnamed } => { if *match_unnamed { - Ok(true) + true } else { // `(_)` only matches named nodes, matching tree-sitter // semantics. Bare `_` (with `match_unnamed = true`) // matches any node. let n = ast.get_node(node).unwrap(); - Ok(n.is_named()) + n.is_named() } } QueryNode::Node { kind, children } => { let node = ast.get_node(node).unwrap(); let target_kind = ast .id_for_node_kind(kind) - .ok_or_else(|| format!("Node kind {kind} not found in language"))?; + .unwrap_or_else(|| panic!("Node kind {kind} not found in language")); if node.kind != target_kind { - return Ok(false); + return false; } for (field, field_children) in children { let field_id = ast .field_id_for_name(field) - .ok_or_else(|| format!("Field {field} not found in language"))?; + .unwrap_or_else(|| panic!("Field {field} not found in language")); let empty = Vec::new(); let mut child_iter = node.fields.get(&field_id).unwrap_or(&empty).iter().cloned(); - if !match_children(field_children.iter(), ast, &mut child_iter, matches)? { - return Ok(false); + if !match_children(field_children.iter(), ast, &mut child_iter, matches) { + return false; } } - Ok(true) + true } QueryNode::UnnamedNode { kind } => { let node = ast.get_node(node).unwrap(); let target_kind = ast .id_for_unnamed_node_kind(kind) - .ok_or_else(|| format!("unnamed Node kind {kind} not found in language"))?; - Ok(node.kind == target_kind) + .unwrap_or_else(|| panic!("unnamed Node kind {kind} not found in language")); + node.kind == target_kind } QueryNode::Capture { capture, node: sub_query, } => { - let matched = sub_query.do_match(ast, node, matches)?; + let matched = sub_query.do_match(ast, node, matches); if matched { matches.insert(capture, node); } - Ok(matched) + matched } } } @@ -124,13 +124,13 @@ fn match_children<'a>( ast: &Ast, remaining_children: &mut (impl Iterator + Clone), matches: &mut Captures, -) -> Result { +) -> bool { for child in child_matchers { - if !child.do_match(ast, remaining_children, matches)? { - return Ok(false); + if !child.do_match(ast, remaining_children, matches) { + return false; } } - Ok(true) + true } impl QueryListElem { @@ -139,13 +139,13 @@ impl QueryListElem { ast: &Ast, remaining_children: &mut (impl Iterator + Clone), matches: &mut Captures, - ) -> Result { + ) -> bool { match self { QueryListElem::Repeated { children, rep } => { if children.is_empty() { // Macro parsing rejects empty groups. Retain safe // zero-width behavior for manually constructed queries. - return Ok(*rep != Rep::OneOrMore); + return *rep != Rep::OneOrMore; } let mut iters = 0; @@ -154,7 +154,7 @@ impl QueryListElem { let matches_initial = matches.clone(); let start = remaining_children.clone(); let start_next = start.clone().next(); - if !match_children(children.iter(), ast, remaining_children, matches)? { + if !match_children(children.iter(), ast, remaining_children, matches) { *remaining_children = start; *matches = matches_initial; break; @@ -172,9 +172,9 @@ impl QueryListElem { } if *rep == Rep::OneOrMore && iters == 0 { // We didn't match any children but we were supposed to - Ok(false) + false } else { - Ok(true) + true } } QueryListElem::SingleNode(sub_query) => { @@ -187,7 +187,7 @@ impl QueryListElem { let skip_unnamed = sub_query.matches_named_only(); loop { let Some(child) = remaining_children.next() else { - return Ok(false); + return false; }; let node = ast.get_node(child).unwrap(); // Skip tree-sitter `extras` (e.g. comments) during @@ -200,8 +200,8 @@ impl QueryListElem { continue; } let snapshot = matches.clone(); - if sub_query.do_match(ast, child, matches)? { - return Ok(true); + if sub_query.do_match(ast, child, matches) { + return true; } *matches = snapshot; } From 07877db5ec7e678db4e28fe25549f64fd67d7447 Mon Sep 17 00:00:00 2001 From: Taus Date: Thu, 5 Nov 2026 21:18:50 +0000 Subject: [PATCH 2/4] yeast: Test panicking translation Also updates the remaining tests to use the new infallible interface. --- shared/yeast/tests/test.rs | 76 +++++++++++++++++++++++--------------- 1 file changed, 47 insertions(+), 29 deletions(-) diff --git a/shared/yeast/tests/test.rs b/shared/yeast/tests/test.rs index 11c1b1996ee6..c6e0343d84fb 100644 --- a/shared/yeast/tests/test.rs +++ b/shared/yeast/tests/test.rs @@ -42,11 +42,11 @@ fn with_passthrough_rules(mut rules: Vec>) -> Vec(mut rules: Vec>) -> Vec Vec { + panic!("deliberate translation panic") + } + + let schema = yeast::node_types_yaml::schema_from_yaml(OUTPUT_SCHEMA_YAML).unwrap(); + let rules: Vec = vec![yeast::rule!( + (integer) + => + integer { + panic_during_translation() + } + )]; + let phases = [Phase::new("translate", rules)]; + let runner: Runner = Runner::with_schema_no_language(&schema, &phases); + + let err = runner + .run_from_ast(synthetic_integer_ast("1")) + .expect_err("expected translation panic to become an error"); + assert_eq!( + err, + "YEAST translation panicked: deliberate translation panic" + ); +} + #[test] fn test_guard_runs_before_capture_translation() { let lang: tree_sitter::Language = tree_sitter_ruby::LANGUAGE.into(); @@ -1448,7 +1466,7 @@ fn test_transform_context_propagates_to_nested_guard_without_mutating_caller() { => program { ctx.enabled = true; - let stmt = ctx.translate(stmt)?; + let stmt = ctx.translate(stmt); tree!((program stmt: {stmt})) } ), @@ -1611,7 +1629,7 @@ fn test_raw_capture_marker() { // string "x" (source_text inherits the input range), so the dump // wouldn't change here. The companion test // `test_raw_capture_marker_explicit_translate` exercises the - // stronger property that `ctx.translate(raw_lhs)?` succeeds and + // stronger property that `ctx.translate(raw_lhs)` succeeds and // produces the translated `(identifier "ID")`. assert_dump_eq( &dump, @@ -1646,7 +1664,7 @@ fn test_raw_capture_marker_explicit_translate() { (assignment left: (_) @@raw_lhs right: (_) @rhs) => call { - let translated_lhs = ctx.translate(raw_lhs)?; + let translated_lhs = ctx.translate(raw_lhs); tree!((call method: {translated_lhs} receiver: {rhs})) @@ -2144,10 +2162,10 @@ fn test_explicit_recursive_translation_keeps_nested_rule_location() { (call method: (identifier) @@name) => identifier { - ctx.translate(name)? + ctx.translate(name) .into_iter() .next() - .ok_or("identifier translation produced no result")? + .expect("identifier translation produced no result") } ); let translate_identifier: Rule = rule!((identifier) @@identifier => (identifier #{identifier})); From 0af482494909c751836773adb3dbfb1756bc573e Mon Sep 17 00:00:00 2001 From: Taus Date: Thu, 5 Nov 2026 21:19:13 +0000 Subject: [PATCH 3/4] Unified: Use infallible yeast translation --- .../extractor/src/languages/swift/swift.rs | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/unified/extractor/src/languages/swift/swift.rs b/unified/extractor/src/languages/swift/swift.rs index af05d93ad172..7897cd203037 100644 --- a/unified/extractor/src/languages/swift/swift.rs +++ b/unified/extractor/src/languages/swift/swift.rs @@ -375,7 +375,7 @@ fn translation_rules() -> Vec> { let val = ctx.scoped(|ctx| { ctx.reset(); ctx.translate(val) - })?; + }); result.push(tree!( (variable_declaration modifier: {ctx.outer_modifiers.clone()} @@ -386,7 +386,7 @@ fn translation_rules() -> Vec> { } for acc in accessors.into_iter() { ctx.is_chained = !result.is_empty(); - result.extend(ctx.translate(acc)?); + result.extend(ctx.translate(acc)); } result } @@ -421,7 +421,7 @@ fn translation_rules() -> Vec> { let modifiers = ctx.outer_modifiers.clone(); let name = ctx .property_name_node - .ok_or("accessor outside property context")?; + .expect("accessor outside property context"); let return_ty = if spec_text == "get" { ctx.property_type } else { @@ -431,7 +431,7 @@ fn translation_rules() -> Vec> { Some(block) => ctx.scoped(|ctx| { ctx.reset(); ctx.translate(block) - })?.into_iter().next(), + }).into_iter().next(), None => None, }; tree!((accessor_declaration @@ -496,7 +496,7 @@ fn translation_rules() -> Vec> { let mut result = Vec::new(); for (i, b) in bindings.into_iter().enumerate() { ctx.is_chained = i > 0; - result.extend(ctx.translate(b)?); + result.extend(ctx.translate(b)); } result } @@ -569,7 +569,7 @@ fn translation_rules() -> Vec> { let mut result = Vec::new(); for (i, case) in cases.into_iter().enumerate() { ctx.is_chained = i > 0; - result.extend(ctx.translate(case)?); + result.extend(ctx.translate(case)); } result } @@ -741,7 +741,7 @@ fn translation_rules() -> Vec> { let callee = ctx.scoped(|ctx| { ctx.in_pattern = false; ctx.translate(rawCallee) - })?; + }); tree!((call_expr callee: {callee} argument: {args} @@ -1063,7 +1063,11 @@ fn translation_rules() -> Vec> { // ---- Optionals and errors ---- // Optional chaining — unwrap the marker rule!((optionalChainingExpr expression: @@inner) => expr { - let inner = ctx.translate(inner)?.into_iter().next().ok_or("optional chaining expression has no child")?; + let inner = ctx + .translate(inner) + .into_iter() + .next() + .expect("optional chaining expression has no child"); if ctx.in_pattern { tree!((call_expr callee: (member_access_expr @@ -1160,7 +1164,7 @@ fn translation_rules() -> Vec> { path: (importPathComponent name: @@parts)*) @@decl => import_declaration { - let last = *parts.last().ok_or("import has no path")?; + let last = *parts.last().expect("import has no path"); tree!((import_declaration modifier: [(modifier #{kind})] modifier: {attrs} From b29609e01e45ea28cba1466f9238a83a11544f2b Mon Sep 17 00:00:00 2001 From: Taus Date: Fri, 6 Nov 2026 12:49:43 +0000 Subject: [PATCH 4/4] yeast: Catch translation panics before they get printed When `panic!` gets called, the default Rust panic handler prints out the error (and possibly a backtrace) to stderr. This means even if we handle the panics in `run_once`, we still get the printed output. To avoid this, we use `resume_unwind` (which foregoes the default behaviour) instead, and add a helper function called `translation_failure` that takes care of the places where translation could previously panic. --- shared/yeast/src/lib.rs | 65 ++++++++++++++++++++++---------------- shared/yeast/src/query.rs | 20 ++++++------ shared/yeast/tests/test.rs | 5 +-- 3 files changed, 49 insertions(+), 41 deletions(-) diff --git a/shared/yeast/src/lib.rs b/shared/yeast/src/lib.rs index 771d18a9267d..2066fe880ebb 100644 --- a/shared/yeast/src/lib.rs +++ b/shared/yeast/src/lib.rs @@ -20,6 +20,13 @@ pub use yeast_macros::{query, rule, rules, tree, trees}; use captures::{Captures, TranslatedCaptures}; use query::QueryNode; +#[derive(Debug)] +struct TranslationFailure(String); + +pub(crate) fn translation_failure(message: impl Into) -> ! { + std::panic::resume_unwind(Box::new(TranslationFailure(message.into()))) +} + impl From for Point { fn from(p: tree_sitter::Point) -> Self { Point { @@ -1166,7 +1173,7 @@ impl<'a, C: Clone> TranslatorHandle<'a, C> { ) -> TranslatedCaptures { captures.translate_except(skip, |id| { if id == self.matched_root { - panic!("cannot eagerly translate a capture of the matched rule root") + translation_failure("cannot eagerly translate a capture of the matched rule root") } else { self.translate(ast, output, user_ctx, id) } @@ -1327,10 +1334,10 @@ fn apply_rules_inner( rewrite_depth: usize, ) -> Vec { if rewrite_depth > MAX_REWRITE_DEPTH { - panic!( + translation_failure(format!( "Desugaring exceeded maximum rewrite depth ({MAX_REWRITE_DEPTH}). \ This likely indicates a non-terminating rule cycle." - ); + )); } let node_kind = ast.get_node(id).map(|n| n.kind_name()).unwrap_or(""); @@ -1359,7 +1366,7 @@ fn apply_rules_inner( return result; } - panic!("no rule matched node of kind '{node_kind}'") + translation_failure(format!("no rule matched node of kind '{node_kind}'")) } /// A named bundle of exhaustive rules for the one-shot translation pass. @@ -1617,37 +1624,39 @@ impl<'a, C: Clone> Runner<'a, C> { } fn run_once(&self, ast: Ast, user_ctx: &mut C) -> Result { - std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { self.run_once_inner(ast, user_ctx) - })) - .map(Ok) - .unwrap_or_else(|payload| { - let message = if let Some(message) = payload.downcast_ref::<&str>() { - (*message).to_owned() - } else if let Some(message) = payload.downcast_ref::() { - message.clone() - } else { - "unknown panic payload".to_owned() - }; - Err(format!("YEAST translation panicked: {message}")) - }) + })) { + Ok(output) => Ok(output), + Err(payload) => match payload.downcast::() { + Ok(failure) => Err(failure.0), + Err(payload) => { + let message = if let Some(message) = payload.downcast_ref::<&str>() { + (*message).to_owned() + } else if let Some(message) = payload.downcast_ref::() { + message.clone() + } else { + "unknown panic payload".to_owned() + }; + Err(format!("YEAST translation panicked: {message}")) + } + }, + } } fn run_once_inner(&self, ast: Ast, user_ctx: &mut C) -> OutputAst { - assert_eq!( - self.phases.len(), - 1, - "YEAST requires exactly one translation phase" - ); + if self.phases.len() != 1 { + translation_failure("YEAST requires exactly one translation phase"); + } let phase = &self.phases[0]; let mut output = OutputAst::with_schema(self.schema.clone()); let res = apply_rules(&phase.rules, &ast, &mut output, user_ctx, ast.get_root()); - assert_eq!( - res.len(), - 1, - "expected exactly one result node, got {}", - res.len() - ); + if res.len() != 1 { + translation_failure(format!( + "expected exactly one result node, got {}", + res.len() + )); + } output.set_root(res[0]); output.set_source(ast.into_source()); output diff --git a/shared/yeast/src/query.rs b/shared/yeast/src/query.rs index ada93086fead..9f117bd16988 100644 --- a/shared/yeast/src/query.rs +++ b/shared/yeast/src/query.rs @@ -79,16 +79,16 @@ impl QueryNode { } QueryNode::Node { kind, children } => { let node = ast.get_node(node).unwrap(); - let target_kind = ast - .id_for_node_kind(kind) - .unwrap_or_else(|| panic!("Node kind {kind} not found in language")); + let target_kind = ast.id_for_node_kind(kind).unwrap_or_else(|| { + crate::translation_failure(format!("Node kind {kind} not found in language")) + }); if node.kind != target_kind { return false; } for (field, field_children) in children { - let field_id = ast - .field_id_for_name(field) - .unwrap_or_else(|| panic!("Field {field} not found in language")); + let field_id = ast.field_id_for_name(field).unwrap_or_else(|| { + crate::translation_failure(format!("Field {field} not found in language")) + }); let empty = Vec::new(); let mut child_iter = node.fields.get(&field_id).unwrap_or(&empty).iter().cloned(); @@ -100,9 +100,11 @@ impl QueryNode { } QueryNode::UnnamedNode { kind } => { let node = ast.get_node(node).unwrap(); - let target_kind = ast - .id_for_unnamed_node_kind(kind) - .unwrap_or_else(|| panic!("unnamed Node kind {kind} not found in language")); + let target_kind = ast.id_for_unnamed_node_kind(kind).unwrap_or_else(|| { + crate::translation_failure(format!( + "unnamed Node kind {kind} not found in language" + )) + }); node.kind == target_kind } QueryNode::Capture { diff --git a/shared/yeast/tests/test.rs b/shared/yeast/tests/test.rs index c6e0343d84fb..f0e705c0f139 100644 --- a/shared/yeast/tests/test.rs +++ b/shared/yeast/tests/test.rs @@ -1385,10 +1385,7 @@ fn test_translation_phase_errors_when_no_rule_matches() { let err = runner .run("x = 1") .expect_err("expected translation to error on unmatched node"); - assert!( - err.contains("no rule matched") && err.contains("integer"), - "error should describe the unmatched node kind, got: {err}" - ); + assert_eq!(err, "no rule matched node of kind 'integer'"); } #[test]