From f69988c5ca94f83b35b8f3f7e31ff7137847db3a Mon Sep 17 00:00:00 2001 From: Konstantin Vyatkin Date: Sun, 23 Aug 2026 02:15:59 +0200 Subject: [PATCH 1/5] feat(codegen): make catch/finally and named action sections accountable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Authored target-code sections without ATN coordinates — grammar-level and scoped named actions (@header, @definitions, @members, unknown names), rule-level named actions (@init/@after), and rule exception clauses (catch [...] / finally) — were silently dropped by both action modes, and --require-full-semantics could not see them because strict auditing only covered coordinate rows. This closes that hole with the fail-loud contract from issues #9/#35/#266: every section now executes, is translated, or fails generation with a source-positioned diagnostic. Inventory and enforcement: semantics.json gains a per-grammar `sections` array (kind, name, scope, owning rule, source file name, line/column, body, disposition: embedded/translated/unsupported), collected after import resolution and grammar transforms. Scope routing follows ANTLR: unscoped sections of a split combined grammar belong to the parser half, authored lexer grammars own their unscoped sections, and @members covered by [[member]] slots reports `translated`. Unsupported sections warn on every run and fail under --require-full-semantics with path:line:column, the section label, the body, and remediation guidance, aggregated across recognizers so one strict run reports every violation. Embedded @header/@definitions: bodies are emitted exactly once at documented positions — @header at the top of the generated module before generated imports, @definitions at module scope after the @members module items — and are translated through the same token-alias machinery as @members items, not textual special cases. Rule exception lifecycle: the generated-rule macro gains two optional sections, `exception (...)` (either `none` or an authored handler that replaces default report-and-recover, with the recognition error bound to the identifier derived from the catch argument, matching ANTLR's generated catch replacement) and `propagate { ... }` (runs the authored finally when a fatal propagated error abandons the entry rule). The generator weaves finally bodies into the success, recovery, authored-catch, and propagate slots, so finally runs exactly once on every completed or propagated path, @after stays success-only, neither section runs on adaptive-retry unwinds (the retried execution re-enters the rule from the top), and ordinary and left-recursive rules share one contract. Multiple catch clauses and non-identifier catch arguments are rejected as unsupported sections rather than mistranslated. This adds macro arms newly generated source can require, so the generated-code API revision increments to 16; revisions 12-15 remain accepted (rules without exception clauses still emit the four-section form, which the runtime normalizes to the same defaults). Checked-in recognizers are regenerated (diffs are the revision line plus manifest sections arrays), and the compatibility docs in README.md and docs/migration.md describe the new revision and section dispositions. Verified with new CLI integration tests that build and run generated parsers (success/recovered/caught ordering, java-style catch binding, left-recursive finally counts, header/definitions placement and exactly-once emission, combined and imported grammar attribution, strict and templates-mode rejection), the full workspace suite, the exact CI clippy invocation, a 357/357 upstream conformance sweep, and the Kotlin parity smoke. Closes #355 --- README.md | 57 +- crates/antlr-rust-codegen/src/driver.rs | 62 +- crates/antlr-rust-codegen/src/embedded/mod.rs | 2 +- .../antlr-rust-codegen/src/embedded/model.rs | 13 + ...__tests__generated_module_file_header.snap | 2 +- ..._manifest_with_untranslated_predicate.snap | 3 +- .../antlr-rust-codegen/src/generator/tests.rs | 11 +- .../antlr-rust-codegen/src/parser/ir/mod.rs | 15 +- .../src/parser/render/mod.rs | 33 +- .../src/parser/render/rules.rs | 47 ++ .../src/parser/render_model.rs | 50 +- .../antlr-rust-codegen/src/parser/routing.rs | 7 +- .../src/parser/surface/model.rs | 9 + .../src/parser/surface/support_abi.rs | 183 ++++-- crates/antlr-rust-codegen/src/pipeline.rs | 6 +- .../src/semantics/manifest.rs | 41 +- .../antlr-rust-codegen/src/semantics/mod.rs | 1 + .../antlr-rust-codegen/src/semantics/model.rs | 8 + .../src/semantics/sections.rs | 439 +++++++++++++ .../src/semantics/stack_member.rs | 2 +- .../antlr-rust-codegen/src/structural/mod.rs | 59 +- .../tests/antlr4_rust_gen_cli.rs | 2 + .../tests/antlr4_rust_gen_cli/cli.rs | 4 +- .../tests/antlr4_rust_gen_cli/sections.rs | 599 ++++++++++++++++++ ...li__cli__generated_codegen_api_checks.snap | 4 +- ...rated_codegen_api_mismatch_diagnostic.snap | 2 +- ..._antlr4rust_compat_semantics_manifest.snap | 46 +- ..._imported_sections_semantics_manifest.snap | 49 ++ ...ons__section_audit_semantics_manifest.snap | 110 ++++ ..._section_lifecycle_semantics_manifest.snap | 85 +++ ...med_parser_actions_semantics_manifest.snap | 6 +- ...cs__recog_receiver_semantics_manifest.snap | 6 +- .../src/generated/antlr_v4_lexer.rs | 2 +- .../src/generated/antlr_v4_parser.rs | 2 +- .../src/generated/rust_lexer.rs | 2 +- .../src/generated/rust_parser.rs | 2 +- .../src/generated/semantics.json | 6 +- crates/antlr-rust-runtime/src/lib.rs | 5 +- crates/antlr-rust-runtime/src/parser.rs | 174 ++++- .../src/generated/toml_lexer.rs | 2 +- .../src/generated/toml_parser.rs | 2 +- docs/migration.md | 29 +- .../antlr-v4-grammar/self-hosted.sha256 | 4 +- 43 files changed, 1992 insertions(+), 201 deletions(-) create mode 100644 crates/antlr-rust-codegen/src/semantics/sections.rs create mode 100644 crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/sections.rs create mode 100644 crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__sections__imported_sections_semantics_manifest.snap create mode 100644 crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__sections__section_audit_semantics_manifest.snap create mode 100644 crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__sections__section_lifecycle_semantics_manifest.snap diff --git a/README.md b/README.md index af06a339..948531a3 100644 --- a/README.md +++ b/README.md @@ -170,7 +170,21 @@ Using the same package release for `antlr4-rust-gen` and `antlr-rust-runtime` remains the recommended workflow, but matching the generated-code API is the compile-time requirement. -The bundled generator currently emits revision 15. Generated recognizers now +The bundled generator currently emits revision 16. Rules with an authored +`catch [...]` or `finally { ... }` clause now expand through two additional +generated-rule lifecycle sections: an `exception` slot that either keeps the +default report-and-recover handler or replaces it with the authored handler +(bound to the recognition error, matching ANTLR's generated catch +replacement), and a `propagate` slot that still runs the authored `finally` +body when a fatal error abandons the generated attempt. Rules without +exception clauses emit the unchanged four-section form. Alongside the +lifecycle change, every authored target-code section (named actions and +exception clauses) now receives a deterministic disposition in the +`semantics.json` `sections` rows, `--require-full-semantics` rejects +unsupported sections, and embedded generation emits `@header` bodies at the +top of the generated module and `@definitions` bodies at module scope. + +Revision 15 generated recognizers embed their static data tables — the ahead-of-time compiled lexer DFA, the packed parser ATN, and the serialized lexer ATN inside `GrammarMetadata` — as versioned encoded blobs defined by the runtime's `encoded` module: LEB128 @@ -192,11 +206,13 @@ also omits the same redundant frames. SLL accuracy is preserved by default, and the reduced-accuracy parser mode is available only through an explicit simulator constructor. -Revision 12 to 14 generated recognizers remain accepted because the runtime -still provides their source API — integer-array `GrammarMetadata` ATN data, +Revision 12 to 15 generated recognizers remain accepted because the runtime +still provides their source API — the four-section generated-rule form, +integer-array `GrammarMetadata` ATN data, `CompiledLexerDfa::from_serialized`, and `ParserAtn::from_static` — and reads -packed parser ATN formats 1 through 3. Regenerate them with revision 15 to -emit the compact encoded representation. +packed parser ATN formats 1 through 3. Regenerate them with revision 16 to +execute authored `catch`/`finally` clauses and emit the compact encoded +representation. Revision 13 moved the iterative generated listener tree-walk engine into `antlr4_runtime::generated::walk_generated`. Generated parsers retain their @@ -696,6 +712,37 @@ Acknowledged options have the `hooked` disposition. Unacknowledged target options have the `unsupported` disposition and make `--require-full-semantics` fail. +The manifest also inventories every source-owned target-code *section* under +each grammar's `sections` array: grammar-level named actions (`@header`, +`@definitions`, `@members`, scoped variants), rule-level named actions +(`@init`, `@after`), and rule exception clauses (`catch [...] { ... }`, +`finally { ... }`). Sections have no ATN coordinate, so these rows are what +makes them auditable. Each row carries the section kind, scope, owning rule, +source position, body, and a disposition: `embedded` (the body is spliced +into generated Rust), `hooked` (`@members` state owned by `[[member]]` +declarations in `--sem-patterns`), or `unsupported`. Unsupported sections +warn on every run and fail generation under `--require-full-semantics` with a +source-positioned diagnostic. + +Under `--actions embedded`, supported sections execute: + +- `@header` bodies are emitted once at the top of the generated module, + before the generated imports; `@definitions` bodies are emitted once at + module scope after the `@members` module items. Both are translated with + the same token-alias machinery as `@members`. In a combined grammar the + unscoped sections belong to the parser module; `@lexer::`-scoped sections + are currently unsupported. +- A single `catch [name] { ... }` clause per rule replaces the default + report-and-recover handler, with the recognition error bound to `name` + (a Java-style `catch [Type name]` argument binds the last identifier). The + rule then completes normally, like ANTLR's generated catch replacement. + Multiple catch clauses are unsupported. +- `finally { ... }` runs exactly once on every completed path — after + `@after` on success, after default recovery or an authored catch handler, + and before a fatal propagated error abandons the rule — and never during + speculative adaptive-retry unwinds, whose re-entry runs the rule from the + top. `@after` remains success-only. + Unknown coordinates are governed by `--sem-unknown`: ```bash diff --git a/crates/antlr-rust-codegen/src/driver.rs b/crates/antlr-rust-codegen/src/driver.rs index 6b1b0107..da926b68 100644 --- a/crates/antlr-rust-codegen/src/driver.rs +++ b/crates/antlr-rust-codegen/src/driver.rs @@ -7,10 +7,12 @@ use crate::optimization::OptimizationPlan; use crate::parser::{ParserRenderOptions, render_parser_with_decision_report}; use crate::rust_support::{self, PreparedRustSupport}; use crate::semantics::{ - DecisionReportGrammar, GrammarOptionEntry, SemUnknownPolicy, SemanticsEntry, - collect_lexer_semantics, collect_parser_semantics_for_mode, collect_structural_grammar_options, - enforce_require_full_options, enforce_require_full_semantics, enforce_sem_unknown, + DecisionReportGrammar, GrammarOptionEntry, SectionEntry, SectionInventoryOptions, + SemUnknownPolicy, SemanticsEntry, collect_lexer_semantics, collect_parser_semantics_for_mode, + collect_recognizer_sections, collect_structural_grammar_options, enforce_require_full_options, + enforce_require_full_sections, enforce_require_full_semantics, enforce_sem_unknown, grammar_option_warning_messages, render_decisions_manifest, render_semantics_manifest, + section_warning_messages, }; use crate::test_rig::{ MAIN_PATH as TEST_RIG_MAIN_PATH, TestRigLexer, TestRigParser, render_test_rig, @@ -77,7 +79,8 @@ pub(crate) fn generate( } let mut grammar_options = Vec::new(); - let mut manifest_grammars: Vec<(&'static str, String, Vec)> = Vec::new(); + let mut manifest_grammars: Vec<(&'static str, String, Vec, Vec)> = + Vec::new(); let mut decision_report_grammars: Vec = Vec::new(); let mut rendered_modules = BTreeMap::::new(); let mut emitted_lexers = BTreeSet::new(); @@ -117,6 +120,30 @@ pub(crate) fn generate( )?; enforce_sem_unknown(sem_unknown, &entries)?; enforce_require_full_semantics(require_full_semantics, &entries)?; + // Unscoped grammar actions of a split combined grammar belong to + // the parser half (ANTLR's default scope); an authored lexer + // grammar owns its own unscoped actions. + let owns_unscoped_actions = root.parser.is_none_or(|parser_grammar| { + compilation.parser(parser_grammar).is_none_or(|parser| { + parser.semantic.unit.source != compiled.semantic.unit.source + }) + }); + let sections = collect_recognizer_sections( + &data, + SectionInventoryOptions { + embedded: embedded_actions, + patterns: &args.sem_patterns, + owns_unscoped_actions, + }, + )?; + let section_warnings = section_warning_messages(§ions); + for warning in §ion_warnings { + report(warning).map_err(Error::generation)?; + } + warnings.extend(section_warnings); + if support_enabled { + enforce_require_full_sections(true, §ions)?; + } let grammar_name = compiled.semantic.recognizer.name.clone(); let render_model = LexerRenderModel::new( &grammar_name, @@ -132,7 +159,7 @@ pub(crate) fn generate( if args.test_rig.is_some() { test_rig_lexers.push(TestRigLexer::new(grammar_name.clone())); } - manifest_grammars.push(("lexer", grammar_name, entries)); + manifest_grammars.push(("lexer", grammar_name, entries, sections)); } if let Some(grammar) = root.parser @@ -165,6 +192,22 @@ pub(crate) fn generate( )?; enforce_sem_unknown(sem_unknown, &entries)?; enforce_require_full_semantics(require_full_semantics, &entries)?; + let sections = collect_recognizer_sections( + &data, + SectionInventoryOptions { + embedded: embedded_actions, + patterns: &args.sem_patterns, + owns_unscoped_actions: true, + }, + )?; + let section_warnings = section_warning_messages(§ions); + for warning in §ion_warnings { + report(warning).map_err(Error::generation)?; + } + warnings.extend(section_warnings); + if support_enabled { + enforce_require_full_sections(true, §ions)?; + } let grammar_name = compiled.semantic.recognizer.name.clone(); let (mut module, decision_report_rows) = render_parser_with_decision_report( &grammar_name, @@ -192,7 +235,7 @@ pub(crate) fn generate( rule_names: data.rule_names.clone(), rows: decision_report_rows, }); - manifest_grammars.push(("parser", grammar_name, entries)); + manifest_grammars.push(("parser", grammar_name, entries, sections)); } } @@ -203,6 +246,13 @@ pub(crate) fn generate( } warnings.extend(option_warnings); enforce_require_full_options(args.require_full_semantics, &grammar_options)?; + // Aggregated across recognizers so one strict run reports every + // unsupported section, not just the first failing recognizer's. + let all_sections = manifest_grammars + .iter() + .flat_map(|(_, _, _, sections)| sections.iter().cloned()) + .collect::>(); + enforce_require_full_sections(args.require_full_semantics, &all_sections)?; let manifest_policy = if prepared_support.all_roots_supported() { SemUnknownPolicy::Error } else { diff --git a/crates/antlr-rust-codegen/src/embedded/mod.rs b/crates/antlr-rust-codegen/src/embedded/mod.rs index 7d6bba6a..8042b97b 100644 --- a/crates/antlr-rust-codegen/src/embedded/mod.rs +++ b/crates/antlr-rust-codegen/src/embedded/mod.rs @@ -48,7 +48,7 @@ mod tests { fn model(rules: Vec) -> EmbeddedModel { EmbeddedModel { rules, - parser_members: MembersModel::default(), + ..EmbeddedModel::default() } } diff --git a/crates/antlr-rust-codegen/src/embedded/model.rs b/crates/antlr-rust-codegen/src/embedded/model.rs index 4f8a1f57..241ccf20 100644 --- a/crates/antlr-rust-codegen/src/embedded/model.rs +++ b/crates/antlr-rust-codegen/src/embedded/model.rs @@ -222,6 +222,13 @@ pub(crate) struct RuleModel { pub(crate) arg_names: Vec, pub(crate) init_body: Option, pub(crate) after_body: Option, + /// Supported authored `catch [...] { ... }` clause: the Rust binding name + /// derived from the argument, plus the handler body. `None` when the rule + /// has no catch clause or the clause is unsupported (the section + /// inventory reports the latter). + pub(crate) catch_clause: Option<(String, String)>, + /// Authored `finally { ... }` body (non-empty). + pub(crate) finally_body: Option, pub(crate) alts: Vec, } @@ -289,6 +296,12 @@ pub(crate) struct EmbeddedModel { /// Parser rules keyed by parser rule index (grammar order). pub(crate) rules: Vec, pub(crate) parser_members: MembersModel, + /// `@header` bodies emitted verbatim (after token-alias translation) at + /// the top of the generated module, before generated imports. + pub(crate) header_items: Vec, + /// `@definitions` bodies emitted at module scope after the embedded + /// `@members` module items. + pub(crate) definitions_items: Vec, } /// Where an action body executes, which changes how `$text` translates. diff --git a/crates/antlr-rust-codegen/src/generator/snapshots/antlr_rust_codegen__generator__tests__generated_module_file_header.snap b/crates/antlr-rust-codegen/src/generator/snapshots/antlr_rust_codegen__generator__tests__generated_module_file_header.snap index f412ac02..586a7f92 100644 --- a/crates/antlr-rust-codegen/src/generator/snapshots/antlr_rust_codegen__generator__tests__generated_module_file_header.snap +++ b/crates/antlr-rust-codegen/src/generator/snapshots/antlr_rust_codegen__generator__tests__generated_module_file_header.snap @@ -4,7 +4,7 @@ expression: "generated_module_header.replace(env!(\"CARGO_PKG_VERSION\"),\n\" - do not edit // project: https://github.com/ophi-dev/antlr-rust-runtime -antlr4_runtime::__antlr4_rust_require_codegen_api!(15, ""); +antlr4_runtime::__antlr4_rust_require_codegen_api!(16, ""); #[allow(warnings, missing_docs, clippy::all, clippy::pedantic, clippy::nursery)] #[rustfmt::skip] mod __antlr4_rust_generated { diff --git a/crates/antlr-rust-codegen/src/generator/snapshots/antlr_rust_codegen__generator__tests__semantics_manifest_with_untranslated_predicate.snap b/crates/antlr-rust-codegen/src/generator/snapshots/antlr_rust_codegen__generator__tests__semantics_manifest_with_untranslated_predicate.snap index 244d2023..20ebadad 100644 --- a/crates/antlr-rust-codegen/src/generator/snapshots/antlr_rust_codegen__generator__tests__semantics_manifest_with_untranslated_predicate.snap +++ b/crates/antlr-rust-codegen/src/generator/snapshots/antlr_rust_codegen__generator__tests__semantics_manifest_with_untranslated_predicate.snap @@ -24,7 +24,8 @@ expression: manifest "disposition": "assume-true", "template": null } - ] + ], + "sections": [] } ] } diff --git a/crates/antlr-rust-codegen/src/generator/tests.rs b/crates/antlr-rust-codegen/src/generator/tests.rs index 813cf5c5..57e016d1 100644 --- a/crates/antlr-rust-codegen/src/generator/tests.rs +++ b/crates/antlr-rust-codegen/src/generator/tests.rs @@ -1218,6 +1218,8 @@ fn embedded_rules_never_use_atn_preferred_fallback() { let rule_has_attrs = vec![false; rules.len()]; let init_entry = BTreeMap::new(); let after = BTreeMap::new(); + let catch_clauses = BTreeMap::new(); + let finally_bodies = BTreeMap::new(); let call_args = BTreeMap::new(); let rule_arg0 = vec![None; rules.len()]; @@ -1236,6 +1238,8 @@ fn embedded_rules_never_use_atn_preferred_fallback() { rule_has_attrs: &rule_has_attrs, init_entry: &init_entry, after: &after, + catch_clauses: &catch_clauses, + finally_bodies: &finally_bodies, call_args: &call_args, rule_arg0: &rule_arg0, }), @@ -1987,7 +1991,7 @@ fn antlr4rust_compat_accessors_reserve_legacy_method_names() { ..embedded::RuleModel::default() }, ], - parser_members: embedded::MembersModel::default(), + ..embedded::EmbeddedModel::default() }; let mut child_cardinalities = BTreeMap::from([ ( @@ -5839,7 +5843,7 @@ fn semantics_manifest_renders_coordinates_and_policy() { let manifest = render_semantics_manifest( SemUnknownPolicy::AssumeTrue, &[], - &[("parser", "SParser".to_owned(), entries)], + &[("parser", "SParser".to_owned(), entries, Vec::new())], ); insta::assert_snapshot!("semantics_manifest_with_untranslated_predicate", manifest); @@ -5909,10 +5913,11 @@ fn semantics_manifest_renders_empty_inventory() { let manifest = render_semantics_manifest( SemUnknownPolicy::AssumeTrue, &[], - &[("parser", "SParser".to_owned(), Vec::new())], + &[("parser", "SParser".to_owned(), Vec::new(), Vec::new())], ); assert!(manifest.contains("\"coordinates\": []")); + assert!(manifest.contains("\"sections\": []")); } #[test] diff --git a/crates/antlr-rust-codegen/src/parser/ir/mod.rs b/crates/antlr-rust-codegen/src/parser/ir/mod.rs index 2aa2f852..a8e9a1d1 100644 --- a/crates/antlr-rust-codegen/src/parser/ir/mod.rs +++ b/crates/antlr-rust-codegen/src/parser/ir/mod.rs @@ -162,6 +162,10 @@ pub(crate) struct EmbeddedStepRender<'a> { pub(crate) rule_has_attrs: &'a [bool], pub(crate) init_entry: &'a BTreeMap, pub(crate) after: &'a BTreeMap, + /// rule -> (binding name, translated authored `catch` handler body). + pub(crate) catch_clauses: &'a BTreeMap, + /// rule -> translated authored `finally` body. + pub(crate) finally_bodies: &'a BTreeMap, pub(crate) call_args: &'a BTreeMap, pub(crate) rule_arg0: &'a [Option], } @@ -217,10 +221,7 @@ impl<'a> DecisionRoutingRender<'a> { /// LOOK(1) arms (plain mode only; embedded mode renders its Java-parity /// switch through [`EmbeddedStepRender`]). Both are pre-restricted to /// sync-no-op lookahead and render through the same dispatch shape. - pub(crate) fn static_dispatch_table( - self, - decision: usize, - ) -> Option<&'a FixedLookaheadTable> { + pub(crate) fn static_dispatch_table(self, decision: usize) -> Option<&'a FixedLookaheadTable> { self.fixed_lookahead_tables .and_then(|tables| tables.get(&decision)) .or_else(|| { @@ -711,11 +712,7 @@ pub(crate) fn parser_rule_callers_reaching( return graph_nodes_reaching(&graph, target_rules); } let atn = data.parser_atn(); - atn_rule_callers_reaching( - atn, - target_rules, - data.rule_names.len(), - ) + atn_rule_callers_reaching(atn, target_rules, data.rule_names.len()) } pub(crate) fn atn_rule_callers_reaching( diff --git a/crates/antlr-rust-codegen/src/parser/render/mod.rs b/crates/antlr-rust-codegen/src/parser/render/mod.rs index ef8ca4f9..221530c3 100644 --- a/crates/antlr-rust-codegen/src/parser/render/mod.rs +++ b/crates/antlr-rust-codegen/src/parser/render/mod.rs @@ -25,10 +25,9 @@ pub(crate) fn render_parser_with_decision_report( let type_name = rust_type_name(grammar_name); let metadata = render_parser_metadata(grammar_name, data); let parser_atn = data.parser_atn(); - let parser_atn_data = - rust_encoded_blob_literal(&antlr4_runtime::encoded::encode_u32_values( - parser_atn.packed_words(), - )); + let parser_atn_data = rust_encoded_blob_literal(&antlr4_runtime::encoded::encode_u32_values( + parser_atn.packed_words(), + )); // Every constant in the generated module shares one Rust value // namespace; allocation order matches emission order (tokens first). let mut const_names = BTreeSet::new(); @@ -235,13 +234,7 @@ pub(crate) fn render_parser_with_decision_report( let entry_rule_indices = likely_parser_entry_rule_indices(data); let parser_rustdoc = render_parser_rustdoc(&public_rule_method_names, &entry_rule_indices); let rule_methods = render_public_rule_methods(&public_rule_method_names); - let ( - embedded_attrs_structs, - embedded_module_items, - embedded_struct_fields, - embedded_field_inits, - embedded_impl_items, - ) = embedded_render_slots(surface_model.bindings()); + let embedded_slots = embedded_render_slots(surface_model.bindings()); let support_bindings = GeneratedSupportBindings::current(); let embedded_imports = if embedded_data.is_some() || structural_surface.is_some() { @@ -257,20 +250,22 @@ pub(crate) fn render_parser_with_decision_report( metadata, parser_semantics_function, typed_hook_adapter, - embedded_attrs_structs, - embedded_module_items, + embedded_attrs_structs: embedded_slots.attrs_structs, + embedded_module_items: embedded_slots.module_items, + embedded_header_items: embedded_slots.header_items, + embedded_definitions_items: embedded_slots.definitions_items, parser_atn_data, parse_convenience, parser_rustdoc, type_name, adaptive_atn_preferred_rule_count, base_initialization, - embedded_struct_fields, - embedded_field_inits, + embedded_struct_fields: embedded_slots.struct_fields, + embedded_field_inits: embedded_slots.field_inits, adaptive_direct_allowed, parse_rule_fallback, generated_rule_dispatch, - embedded_impl_items, + embedded_impl_items: embedded_slots.impl_items, rule_methods, action_method, typed_parser_constructor, @@ -290,6 +285,8 @@ fn render_parser_module(model: &ParserRenderModel) -> String { typed_hook_adapter, embedded_attrs_structs, embedded_module_items, + embedded_header_items, + embedded_definitions_items, parser_atn_data, parse_convenience, parser_rustdoc, @@ -308,7 +305,7 @@ fn render_parser_module(model: &ParserRenderModel) -> String { .. } = model; format!( - r#"{generated_header}use antlr4_runtime::token::TokenSource; + r#"{generated_header}{embedded_header_items}use antlr4_runtime::token::TokenSource; use antlr4_runtime::token_stream::CommonTokenStream; use antlr4_runtime::atn::parser_atn::ParserAtn; use antlr4_runtime::generated::GeneratedRuleError; @@ -323,7 +320,7 @@ use std::sync::OnceLock; {typed_hook_adapter} {embedded_attrs_structs} {embedded_module_items} - +{embedded_definitions_items} static PARSER_ATN_DATA: &str = {parser_atn_data}; static ATN_CELL: OnceLock = OnceLock::new(); diff --git a/crates/antlr-rust-codegen/src/parser/render/rules.rs b/crates/antlr-rust-codegen/src/parser/render/rules.rs index fd7c8562..1d6f4f97 100644 --- a/crates/antlr-rust-codegen/src/parser/render/rules.rs +++ b/crates/antlr-rust-codegen/src/parser/render/rules.rs @@ -83,9 +83,56 @@ fn render_generated_rule_lifecycle( let mut recovery = String::new(); render_embedded_after_and_seal(&mut recovery, index, step_render_context, false, 4); render_generated_rule_section(out, "recovery", &recovery); + render_generated_rule_exception_sections(out, rule, step_render_context); writeln!(out, " }}").expect("writing to a string cannot fail"); } +/// Emits the optional `exception (...)` / `propagate { ... }` macro sections +/// carrying an authored `catch` handler and the propagated-failure `finally` +/// slot. Rules without a catch or finally clause keep the shorter macro form, +/// which the runtime normalizes to the same defaults. +fn render_generated_rule_exception_sections( + out: &mut String, + rule: &GeneratedParserRule, + step_render_context: GeneratedStepRenderContext<'_>, +) { + let index = rule.rule_index; + let Some(embedded) = step_render_context.embedded else { + return; + }; + let catch_clause = embedded.catch_clauses.get(&index); + let finally_body = embedded.finally_bodies.get(&index); + if catch_clause.is_none() && finally_body.is_none() { + return; + } + if let Some((binding, body)) = catch_clause { + if body.trim().is_empty() { + writeln!(out, " exception (|{binding}| {{}});") + .expect("writing to a string cannot fail"); + } else { + writeln!(out, " exception (|{binding}| {{") + .expect("writing to a string cannot fail"); + writeln!(out, " {body}").expect("writing to a string cannot fail"); + writeln!(out, " }});").expect("writing to a string cannot fail"); + } + } else { + writeln!(out, " exception (none);").expect("writing to a string cannot fail"); + } + // `finally` runs on the propagated-failure path too: the recovery/success + // sections cover completed parses, and this slot covers the fatal unwind. + match finally_body { + Some(finally_body) => { + writeln!(out, " propagate {{").expect("writing to a string cannot fail"); + writeln!(out, " {finally_body}") + .expect("writing to a string cannot fail"); + writeln!(out, " }};").expect("writing to a string cannot fail"); + } + None => { + writeln!(out, " propagate {{}};").expect("writing to a string cannot fail"); + } + } +} + fn render_generated_rule_section(out: &mut String, name: &str, body: &str) { if body.is_empty() { writeln!(out, " {name} {{}}").expect("writing to a string cannot fail"); diff --git a/crates/antlr-rust-codegen/src/parser/render_model.rs b/crates/antlr-rust-codegen/src/parser/render_model.rs index 87f63ce7..e2af85c5 100644 --- a/crates/antlr-rust-codegen/src/parser/render_model.rs +++ b/crates/antlr-rust-codegen/src/parser/render_model.rs @@ -28,6 +28,8 @@ pub(crate) struct ParserRenderModel { pub(crate) typed_hook_adapter: String, pub(crate) embedded_attrs_structs: String, pub(crate) embedded_module_items: String, + pub(crate) embedded_header_items: String, + pub(crate) embedded_definitions_items: String, pub(crate) parser_atn_data: String, pub(crate) parse_convenience: String, pub(crate) parser_rustdoc: String, @@ -306,6 +308,8 @@ fn embedded_step_render<'a>( rule_has_attrs: &embedded.rule_has_attrs, init_entry: &embedded.init_entry, after: &embedded.after, + catch_clauses: &embedded.catch_clauses, + finally_bodies: &embedded.finally_bodies, call_args: &embedded.call_args, rule_arg0: &embedded.rule_arg0, } @@ -313,29 +317,29 @@ fn embedded_step_render<'a>( /// The module/struct/impl text [`ParserSurfaceBindings`] contributes to the /// rendered parser, empty in template mode. -fn embedded_render_slots( - embedded_data: Option<&ParserSurfaceBindings>, -) -> (String, String, String, String, String) { - embedded_data.map_or_else( - || { - ( - String::new(), - String::new(), - String::new(), - String::new(), - String::new(), - ) - }, - |embedded| { - ( - embedded.attrs_structs.clone(), - embedded.module_items.clone(), - embedded.struct_fields.clone(), - embedded.field_inits.clone(), - embedded.impl_items.clone(), - ) - }, - ) +#[derive(Default)] +struct EmbeddedRenderSlots { + attrs_structs: String, + module_items: String, + header_items: String, + definitions_items: String, + struct_fields: String, + field_inits: String, + impl_items: String, +} + +fn embedded_render_slots(embedded_data: Option<&ParserSurfaceBindings>) -> EmbeddedRenderSlots { + embedded_data.map_or_else(EmbeddedRenderSlots::default, |embedded| { + EmbeddedRenderSlots { + attrs_structs: embedded.attrs_structs.clone(), + module_items: embedded.module_items.clone(), + header_items: embedded.header_items.clone(), + definitions_items: embedded.definitions_items.clone(), + struct_fields: embedded.struct_fields.clone(), + field_inits: embedded.field_inits.clone(), + impl_items: embedded.impl_items.clone(), + } + }) } /// Step-render view over the opt-in `--fixed-lookahead` routing. Embedded diff --git a/crates/antlr-rust-codegen/src/parser/routing.rs b/crates/antlr-rust-codegen/src/parser/routing.rs index 356c8e1e..274cc20e 100644 --- a/crates/antlr-rust-codegen/src/parser/routing.rs +++ b/crates/antlr-rust-codegen/src/parser/routing.rs @@ -450,7 +450,9 @@ pub(crate) fn render_embedded_init_entry( } /// Runs the embedded `@after` body (committed path only — ANTLR's caught-error -/// path skips `@after`) and seals the attrs snapshot before `finish_rule`. +/// path skips `@after`), then the authored `finally` body (every completed +/// path, matching ANTLR's try/finally ordering), and seals the attrs snapshot +/// before `finish_rule`. pub(crate) fn render_embedded_after_and_seal( out: &mut String, rule_index: usize, @@ -467,6 +469,9 @@ pub(crate) fn render_embedded_after_and_seal( writeln!(out, "{pad}{after}").expect("writing to a string cannot fail"); } } + if let Some(finally_body) = embedded.finally_bodies.get(&rule_index) { + writeln!(out, "{pad}{finally_body}").expect("writing to a string cannot fail"); + } if embedded .rule_has_attrs .get(rule_index) diff --git a/crates/antlr-rust-codegen/src/parser/surface/model.rs b/crates/antlr-rust-codegen/src/parser/surface/model.rs index b2954b7c..cb6594ae 100644 --- a/crates/antlr-rust-codegen/src/parser/surface/model.rs +++ b/crates/antlr-rust-codegen/src/parser/surface/model.rs @@ -30,6 +30,15 @@ pub(crate) struct ParserSurfaceBindings { pub(crate) impl_items: String, /// `@members` structs/impls and generated support types. pub(crate) module_items: String, + /// `@header` items for the top of the module, before generated imports. + pub(crate) header_items: String, + /// `@definitions` items for module scope, after the `@members` module + /// items. + pub(crate) definitions_items: String, + /// rule -> (binding name, translated authored `catch` handler body). + pub(crate) catch_clauses: BTreeMap, + /// rule -> translated authored `finally` body. + pub(crate) finally_bodies: BTreeMap, } /// Mode-selected parser surface stage artifact. diff --git a/crates/antlr-rust-codegen/src/parser/surface/support_abi.rs b/crates/antlr-rust-codegen/src/parser/surface/support_abi.rs index 4927f8e4..e1790137 100644 --- a/crates/antlr-rust-codegen/src/parser/surface/support_abi.rs +++ b/crates/antlr-rust-codegen/src/parser/surface/support_abi.rs @@ -251,11 +251,18 @@ pub(crate) fn build_embedded_parser_data( ); } - // @init / @after bodies from the rule headers. - for (rule_index, rule) in model.rules.iter().enumerate() { - let semantic = data - .semantic - .expect("embedded parser data has semantic grammar"); + // Rule-header sections: `@init`, `@after`, and the rule exception + // clauses (`catch` / `finally`) all translate identically; only the + // action site and the diagnostic label differ. + let semantic = data + .semantic + .expect("embedded parser data has semantic grammar"); + let mut translate_rule_section = |rule_index: usize, + rule_name: &str, + kind: &'static str, + site: embedded::ActionSite, + body: &str| + -> io::Result { let semantic_rule = semantic.unit.rules.iter().find(|semantic_rule| { semantic.recognizer.rule_numbers.get(&semantic_rule.id) == Some(&rule_index) }); @@ -265,79 +272,82 @@ pub(crate) fn build_embedded_parser_data( let aliases = antlr4rust_alias_inventory_cache .entry(rule_source) .or_insert_with(|| antlr4rust_token_alias_inventory(data, type_name, rule_source)); - if let Some(body) = &rule.init_body { - let ctx = embedded::TranslationCtx { - model: &model, + let ctx = embedded::TranslationCtx { + model: &model, + rule_index, + body_offset: None, + site, + token_types: &token_types, + }; + let translated = embedded::translate_parser_body_with_alias_module( + body, + &ctx, + &context_names.rules[rule_index].context_type, + &aliases.names, + antlr4rust_names, + embedded::ParserBodyKind::Action, + ) + .map_err(|error| { + embedded_rule_action_translation_error( + data, + semantic_rule, + kind, rule_index, - body_offset: None, - site: embedded::ActionSite::Init, - token_types: &token_types, - }; - let translated = embedded::translate_parser_body_with_alias_module( - body, - &ctx, - &context_names.rules[rule_index].context_type, - &aliases.names, - antlr4rust_names, - embedded::ParserBodyKind::Action, + rule_name, + &error, ) - .map_err(|error| { - embedded_rule_action_translation_error( - data, - semantic_rule, - "init", - rule_index, - &rule.name, - &error, - ) - })?; - record_antlr4rust_translation( - &translated, - aliases, + })?; + record_antlr4rust_translation( + &translated, + aliases, + rule_index, + &mut uses_antlr4rust_input, + &mut antlr4rust_context_roots, + &mut antlr4rust_token_aliases, + ); + Ok(post_process_embedded(body, &translated.source, type_name)) + }; + for (rule_index, rule) in model.rules.iter().enumerate() { + if let Some(body) = &rule.init_body { + let translated = translate_rule_section( rule_index, - &mut uses_antlr4rust_input, - &mut antlr4rust_context_roots, - &mut antlr4rust_token_aliases, - ); - out.init_entry - .insert(rule_index, finish_body(body, &translated.source)); + &rule.name, + "init", + embedded::ActionSite::Init, + body, + )?; + out.init_entry.insert(rule_index, translated); } if let Some(body) = &rule.after_body { - let ctx = embedded::TranslationCtx { - model: &model, + let translated = translate_rule_section( rule_index, - body_offset: None, - site: embedded::ActionSite::After, - token_types: &token_types, - }; - let translated = embedded::translate_parser_body_with_alias_module( + &rule.name, + "after", + embedded::ActionSite::After, body, - &ctx, - &context_names.rules[rule_index].context_type, - &aliases.names, - antlr4rust_names, - embedded::ParserBodyKind::Action, - ) - .map_err(|error| { - embedded_rule_action_translation_error( - data, - semantic_rule, - "after", - rule_index, - &rule.name, - &error, - ) - })?; - record_antlr4rust_translation( - &translated, - aliases, + )?; + out.after.insert(rule_index, translated); + } + if let Some((binding, body)) = &rule.catch_clause { + let translated = translate_rule_section( rule_index, - &mut uses_antlr4rust_input, - &mut antlr4rust_context_roots, - &mut antlr4rust_token_aliases, - ); - out.after - .insert(rule_index, finish_body(body, &translated.source)); + &rule.name, + "catch", + embedded::ActionSite::After, + body, + )?; + out.catch_clauses + .insert(rule_index, (binding.clone(), translated)); + } + if let Some(body) = &rule.finally_body { + let translated = translate_rule_section( + rule_index, + &rule.name, + "finally", + embedded::ActionSite::After, + body, + )?; + out.finally_bodies.insert(rule_index, translated); } } @@ -477,6 +487,39 @@ pub(crate) fn build_embedded_parser_data( let _ = writeln!(out.module_items, "{item}\n"); } + for (items, out_slot, kind) in [ + ( + &model.header_items, + &mut out.header_items, + "parser @header item", + ), + ( + &model.definitions_items, + &mut out.definitions_items, + "parser @definitions item", + ), + ] { + for item in items { + let aliases = antlr4rust_alias_inventory_cache + .entry(item.source) + .or_insert_with(|| antlr4rust_token_alias_inventory(data, type_name, item.source)); + let translated = embedded::translate_member_token_aliases( + &item.body, + &aliases.names, + &antlr4rust_token_alias_module, + ) + .map_err(|error| embedded_member_translation_error(data, item.source, kind, &error))?; + antlr4rust_direct_alias_imports.extend(translated.direct_alias_imports.iter().cloned()); + antlr4rust_token_aliases.extend( + translated.token_aliases.iter().filter_map(|name| { + aliases.values.get(name).map(|value| (name.clone(), *value)) + }), + ); + let item = post_process_embedded(&item.body, &translated.source, type_name); + let _ = writeln!(out_slot, "{item}\n"); + } + } + // Rule-call argument expressions attach to the exact finalized transition // produced from each structural call element. out.call_args = structural_embedded_rule_call_args(data)?; diff --git a/crates/antlr-rust-codegen/src/pipeline.rs b/crates/antlr-rust-codegen/src/pipeline.rs index b8e0b2cc..20aacced 100644 --- a/crates/antlr-rust-codegen/src/pipeline.rs +++ b/crates/antlr-rust-codegen/src/pipeline.rs @@ -40,11 +40,9 @@ pub(crate) mod prelude { }; pub(crate) use crate::grammar::provenance::{Origin, ProvenanceIndex}; pub(crate) use crate::grammar::source::SourceSet; - #[cfg(test)] - pub(crate) use crate::rust_output::is_rust_keyword; pub(crate) use crate::rust_output::{ - module_name, replace_all, rust_encoded_blob_literal, rust_function_name, rust_identifier, - rust_string, rust_type_name, sanitize_identifier, split_identifier_words, + is_rust_keyword, module_name, replace_all, rust_encoded_blob_literal, rust_function_name, + rust_identifier, rust_string, rust_type_name, sanitize_identifier, split_identifier_words, }; } diff --git a/crates/antlr-rust-codegen/src/semantics/manifest.rs b/crates/antlr-rust-codegen/src/semantics/manifest.rs index 0d6202cd..0509cc64 100644 --- a/crates/antlr-rust-codegen/src/semantics/manifest.rs +++ b/crates/antlr-rust-codegen/src/semantics/manifest.rs @@ -39,6 +39,7 @@ struct SemanticsGrammarManifest<'a> { kind: &'static str, name: &'a str, coordinates: Vec>, + sections: Vec>, } #[derive(Serialize)] @@ -72,23 +73,59 @@ impl<'a> From<&'a SemanticsEntry> for SemanticsCoordinateManifest<'a> { } } +/// One source-owned target-code section (named action or rule exception +/// clause). Sections have no ATN coordinate, so they get their own rows; +/// `source` is a file name (never an absolute path) to keep the manifest +/// deterministic across checkouts. +#[derive(Serialize)] +struct SectionManifest<'a> { + kind: &'static str, + name: Option<&'a str>, + scope: Option<&'a str>, + rule: Option<&'a str>, + rule_index: Option, + source: Option<&'a str>, + line: usize, + column: usize, + body: &'a str, + disposition: &'static str, +} + +impl<'a> From<&'a SectionEntry> for SectionManifest<'a> { + fn from(entry: &'a SectionEntry) -> Self { + Self { + kind: entry.kind.manifest_name(), + name: entry.name.as_deref(), + scope: entry.scope.as_deref(), + rule: entry.rule_name.as_deref(), + rule_index: entry.rule_index, + source: entry.source.as_deref(), + line: entry.line, + column: entry.column, + body: &entry.body, + disposition: entry.disposition.manifest_name(), + } + } +} + pub(crate) fn render_semantics_manifest( policy: SemUnknownPolicy, options: &[GrammarOptionEntry], - grammars: &[(&'static str, String, Vec)], + grammars: &[(&'static str, String, Vec, Vec)], ) -> String { const DEPRECATION_NOTE: &str = "unknown coordinates currently default to assume-true; \ a future minor release changes the default to error"; let options = options.iter().map(GrammarOptionManifest::from).collect(); let grammars = grammars .iter() - .map(|(kind, name, entries)| SemanticsGrammarManifest { + .map(|(kind, name, entries, sections)| SemanticsGrammarManifest { kind, name, coordinates: entries .iter() .map(SemanticsCoordinateManifest::from) .collect(), + sections: sections.iter().map(SectionManifest::from).collect(), }) .collect(); to_pretty_json(&SemanticsManifest { diff --git a/crates/antlr-rust-codegen/src/semantics/mod.rs b/crates/antlr-rust-codegen/src/semantics/mod.rs index 95cc9aa5..c5635953 100644 --- a/crates/antlr-rust-codegen/src/semantics/mod.rs +++ b/crates/antlr-rust-codegen/src/semantics/mod.rs @@ -22,6 +22,7 @@ use templates::{ include!("model.rs"); include!("patterns.rs"); include!("inventory.rs"); +include!("sections.rs"); include!("manifest.rs"); include!("templates.rs"); include!("hooks.rs"); diff --git a/crates/antlr-rust-codegen/src/semantics/model.rs b/crates/antlr-rust-codegen/src/semantics/model.rs index 198a823e..5a7ed75e 100644 --- a/crates/antlr-rust-codegen/src/semantics/model.rs +++ b/crates/antlr-rust-codegen/src/semantics/model.rs @@ -237,6 +237,14 @@ impl SemPatternFile { ) -> io::Result { stack_member::MemberSlots::assign_scoped(&self.members, recognizer) } + + /// Whether any `[[member]]` slot is declared for `recognizer`, i.e. the + /// pattern file explicitly owns that recognizer's `@members` state. + pub(crate) fn has_member_declarations(&self, recognizer: stack_member::MemberScope) -> bool { + self.members + .iter() + .any(|declaration| declaration.scope.covers(recognizer)) + } } #[derive(Clone, Debug, Eq, PartialEq)] diff --git a/crates/antlr-rust-codegen/src/semantics/sections.rs b/crates/antlr-rust-codegen/src/semantics/sections.rs new file mode 100644 index 00000000..7e6ef896 --- /dev/null +++ b/crates/antlr-rust-codegen/src/semantics/sections.rs @@ -0,0 +1,439 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026 Konstantin Vyatkin +// Inventory of source-owned target-code *sections*: grammar-level and +// rule-level named actions (`@header`, `@definitions`, `@members`, `@init`, +// `@after`, …) plus rule exception clauses (`catch [...] { ... }` and +// `finally { ... }`). +// +// Unlike `SemanticsEntry` coordinates, sections own no ATN action/predicate +// coordinate, so the coordinate inventory alone can never account for them +// (issue #355). Every section is inventoried after import resolution and +// grammar transforms, receives a deterministic disposition in +// `semantics.json`, produces a warning when unsupported, and fails +// generation under `--require-full-semantics`. + +/// Section kinds tracked by the `sections` manifest rows. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum SectionKind { + /// A grammar-level or rule-level `@name { ... }` action. + NamedAction, + /// A rule `catch [...] { ... }` exception handler. + Catch, + /// A rule `finally { ... }` clause. + Finally, +} + +impl SectionKind { + const fn manifest_name(self) -> &'static str { + match self { + Self::NamedAction => "named-action", + Self::Catch => "catch", + Self::Finally => "finally", + } + } +} + +/// How generation disposed of one target-code section. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum SectionDisposition { + /// The authored body is spliced into the generated Rust module (or the + /// section is empty and there is nothing to splice). + Embedded, + /// The section's behavior is translated into generated metadata without + /// splicing the authored body (e.g. `@members` state declared through + /// `[[member]]` slots lowers into the semantic IR table). + Translated, + /// The section is legal ANTLR syntax but its target code is not executed + /// by the generated module. Reported as a warning by default and rejected + /// by `--require-full-semantics`. + Unsupported, +} + +impl SectionDisposition { + const fn manifest_name(self) -> &'static str { + match self { + Self::Embedded => "embedded", + Self::Translated => "translated", + Self::Unsupported => "unsupported", + } + } +} + +/// One source-owned target-code section inventoried for the manifest. +#[derive(Clone, Debug)] +pub(crate) struct SectionEntry { + pub(crate) kind: SectionKind, + /// Named-action name (`header`, `members`, `init`, …); `None` for + /// exception clauses. + pub(crate) name: Option, + /// Authored scope qualifier (`parser`, `lexer`, …) exactly as written. + pub(crate) scope: Option, + pub(crate) rule_index: Option, + pub(crate) rule_name: Option, + /// Logical source path for diagnostics (may be absolute). + pub(crate) path: String, + /// Source file name for the deterministic manifest row. + pub(crate) source: Option, + pub(crate) line: usize, + pub(crate) column: usize, + pub(crate) body: String, + pub(crate) disposition: SectionDisposition, + /// Remediation guidance rendered with unsupported-section diagnostics. + pub(crate) note: Option<&'static str>, +} + +impl SectionEntry { + fn label(&self) -> String { + let mut label = String::new(); + if let (Some(rule_name), Some(rule_index)) = (&self.rule_name, self.rule_index) { + let _ = write!(label, "rule {rule_name}({rule_index}) "); + } + match self.kind { + SectionKind::NamedAction => match (&self.scope, &self.name) { + (Some(scope), Some(name)) => { + let _ = write!(label, "@{scope}::{name}"); + } + (None, Some(name)) => { + let _ = write!(label, "@{name}"); + } + _ => label.push_str("@"), + }, + SectionKind::Catch => label.push_str("catch[...]"), + SectionKind::Finally => label.push_str("finally"), + } + label + } + + /// Renders the fail-loud diagnostic line for this section: source path, + /// line, column, section kind, body, and remediation guidance. + fn describe_unsupported(&self) -> String { + let mut message = format!( + "unsupported target-code section: {} at {}:{}:{}", + self.label(), + self.path, + self.line, + self.column + ); + if !self.body.is_empty() { + let _ = write!(message, ": {{{}}}", self.body); + } + if let Some(note) = self.note { + let _ = write!(message, "; {note}"); + } + message + } +} + +pub(crate) fn section_warning_messages(entries: &[SectionEntry]) -> Vec { + entries + .iter() + .filter(|entry| entry.disposition == SectionDisposition::Unsupported) + .map(|entry| format!("warning: {}", entry.describe_unsupported())) + .collect() +} + +/// Fails generation when `--require-full-semantics` is active and any +/// authored target-code section has no executed implementation. +pub(crate) fn enforce_require_full_sections( + require: bool, + entries: &[SectionEntry], +) -> io::Result<()> { + if !require { + return Ok(()); + } + let unsupported = entries + .iter() + .filter(|entry| entry.disposition == SectionDisposition::Unsupported) + .collect::>(); + if unsupported.is_empty() { + return Ok(()); + } + let mut message = String::new(); + for entry in &unsupported { + message.push_str(&entry.describe_unsupported()); + message.push('\n'); + } + let _ = write!( + message, + "--require-full-semantics: {} target-code section(s) would be silently dropped; \ + implement them under --actions embedded, route them through a documented hook, or \ + remove them from the grammar", + unsupported.len() + ); + Err(io::Error::new(io::ErrorKind::InvalidData, message)) +} + +/// Per-recognizer configuration for section inventory. +#[derive(Clone, Copy)] +pub(crate) struct SectionInventoryOptions<'a> { + /// `--actions embedded` (or a Rust-support bundle) is active. + pub(crate) embedded: bool, + pub(crate) patterns: &'a SemPatternFile, + /// Whether unscoped (and unknown-scoped) grammar-level actions belong to + /// this recognizer. True for every parser recognizer and for lexer + /// recognizers compiled from an authored lexer grammar; false for the + /// lexer half of a split combined grammar, whose unscoped actions belong + /// to the parser (ANTLR's default scope for combined grammars). + pub(crate) owns_unscoped_actions: bool, +} + +/// Extracts the Rust binding name from an authored `catch [...]` argument. +/// +/// Accepts a bare identifier (`catch [error]`) or a Java-style +/// `Type name` pair (`catch [RecognitionException re]`), binding the last +/// identifier-shaped token. Anything else is unsupported. +pub(crate) fn exception_catch_binding(argument: &str) -> Option { + let mut tokens = argument.split_whitespace().rev(); + let binding = tokens.next()?; + let mut chars = binding.chars(); + let valid = chars + .next() + .is_some_and(|ch| ch == '_' || ch.is_ascii_alphabetic()) + && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()); + // At most one leading type token; longer argument lists are not a + // binding form this backend understands. + (valid && tokens.count() <= 1 && !is_rust_keyword(binding)).then(|| binding.to_owned()) +} + +/// Inventories every source-owned target-code section visible to one +/// recognizer: grammar-level named actions (scope-routed), rule-level named +/// actions, and rule exception clauses. +/// +/// Deterministic: grammar-level actions in declaration order, then rule-level +/// sections in rule-index order with declaration order within a rule. +pub(crate) fn collect_recognizer_sections( + data: &RecognizerCodegenData<'_>, + options: SectionInventoryOptions<'_>, +) -> io::Result> { + let semantic = data.semantic.ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "structural grammar model is unavailable", + ) + })?; + let recognizer_scope = match semantic.unit.kind { + GrammarKind::Lexer => "lexer", + GrammarKind::Parser | GrammarKind::Combined => "parser", + }; + let mut entries = Vec::new(); + for action in &semantic.unit.actions { + let owned = match action.scope.as_deref() { + Some(scope) if scope == recognizer_scope => true, + // The other recognizer of this grammar owns the section. + Some("lexer" | "parser") => false, + // Unknown scopes follow the unit's default scope, like unscoped + // sections. + Some(_) | None => options.owns_unscoped_actions, + }; + if !owned { + continue; + } + let (disposition, note) = grammar_action_disposition(action, recognizer_scope, options); + entries.push(section_entry_for_action( + data, + action, + None, + disposition, + note, + )); + } + + if recognizer_scope == "lexer" { + // Lexer rules carry no rule-level actions or exception clauses + // (enforced by the grammar frontend). + return Ok(entries); + } + + let mut rule_entries = Vec::new(); + for rule in &semantic.unit.rules { + let Some(&rule_index) = semantic.recognizer.rule_numbers.get(&rule.id) else { + continue; + }; + let rule_ref = Some((rule_index, rule.name.as_str())); + let mut seen_names = BTreeSet::new(); + for action in &rule.actions { + let (disposition, note) = + rule_action_disposition(action, options.embedded, &mut seen_names); + rule_entries.push(( + rule_index, + section_entry_for_action(data, action, rule_ref, disposition, note), + )); + } + for handler in &rule.catches { + let (disposition, note) = catch_disposition(handler, rule, options.embedded); + let (line, column) = structural_line_column(data, &handler.span); + rule_entries.push(( + rule_index, + SectionEntry { + kind: SectionKind::Catch, + name: None, + scope: None, + rule_index: Some(rule_index), + rule_name: Some(rule.name.clone()), + path: section_path(data, &handler.span), + source: section_source(data, &handler.span), + line, + column, + body: one_line_action_body(&handler.body), + disposition, + note, + }, + )); + } + if let Some(action) = &rule.finally_action { + let disposition = if options.embedded { + SectionDisposition::Embedded + } else { + SectionDisposition::Unsupported + }; + let note = + (disposition == SectionDisposition::Unsupported).then_some(TEMPLATES_MODE_NOTE); + let (line, column) = structural_line_column(data, &action.span); + rule_entries.push(( + rule_index, + SectionEntry { + kind: SectionKind::Finally, + name: None, + scope: None, + rule_index: Some(rule_index), + rule_name: Some(rule.name.clone()), + path: section_path(data, &action.span), + source: section_source(data, &action.span), + line, + column, + body: one_line_action_body(&action.body), + disposition, + note, + }, + )); + } + } + rule_entries.sort_by_key(|(rule_index, _)| *rule_index); + entries.extend(rule_entries.into_iter().map(|(_, entry)| entry)); + Ok(entries) +} + +const TEMPLATES_MODE_NOTE: &str = "templates mode does not execute authored target-code \ + sections; generate with --actions embedded from a Rust-authored grammar, or remove the \ + section"; +const LEXER_SECTION_NOTE: &str = "lexer-scoped sections are not implemented in embedded mode; \ + move shared code into @parser::definitions or a hand-maintained module"; +const UNKNOWN_SECTION_NOTE: &str = "unknown named action; embedded Rust generation implements \ + @header, @definitions, and @members at grammar scope"; +const DUPLICATE_RULE_SECTION_NOTE: &str = "duplicate rule section; only the first occurrence \ + is executed"; +const RULE_SECTION_NOTE: &str = "unknown rule action; embedded Rust generation implements \ + @init and @after at rule scope"; +const MULTIPLE_CATCH_NOTE: &str = "multiple catch clauses are not supported; merge the \ + handlers into one clause and match on the bound error value"; +const CATCH_ARGUMENT_NOTE: &str = "catch argument must be a Rust identifier (optionally \ + preceded by one type token); the handler receives the recognition error under that name"; + +fn grammar_action_disposition( + action: &grammar::model::NamedAction, + recognizer_scope: &str, + options: SectionInventoryOptions<'_>, +) -> (SectionDisposition, Option<&'static str>) { + if action.body.trim().is_empty() { + // An empty section has no target code to lose. + return (SectionDisposition::Embedded, None); + } + let member_scope = if recognizer_scope == "lexer" { + stack_member::MemberScope::Lexer + } else { + stack_member::MemberScope::Parser + }; + if !options.embedded { + if action.name == "members" && options.patterns.has_member_declarations(member_scope) { + // `[[member]]` slots lower the declared state into the semantic IR + // table; the authored body is replaced, not hook-routed. + return (SectionDisposition::Translated, None); + } + return (SectionDisposition::Unsupported, Some(TEMPLATES_MODE_NOTE)); + } + if recognizer_scope == "lexer" { + return (SectionDisposition::Unsupported, Some(LEXER_SECTION_NOTE)); + } + match action.name.as_str() { + "header" | "definitions" | "members" => (SectionDisposition::Embedded, None), + _ => (SectionDisposition::Unsupported, Some(UNKNOWN_SECTION_NOTE)), + } +} + +fn rule_action_disposition( + action: &grammar::model::NamedAction, + embedded: bool, + seen_names: &mut BTreeSet, +) -> (SectionDisposition, Option<&'static str>) { + if action.body.trim().is_empty() { + return (SectionDisposition::Embedded, None); + } + let first = seen_names.insert(action.name.clone()); + if !embedded { + return (SectionDisposition::Unsupported, Some(TEMPLATES_MODE_NOTE)); + } + match action.name.as_str() { + "init" | "after" if first => (SectionDisposition::Embedded, None), + "init" | "after" => ( + SectionDisposition::Unsupported, + Some(DUPLICATE_RULE_SECTION_NOTE), + ), + _ => (SectionDisposition::Unsupported, Some(RULE_SECTION_NOTE)), + } +} + +fn catch_disposition( + handler: &grammar::model::ExceptionHandler, + rule: &Rule, + embedded: bool, +) -> (SectionDisposition, Option<&'static str>) { + if !embedded { + return (SectionDisposition::Unsupported, Some(TEMPLATES_MODE_NOTE)); + } + if rule.catches.len() > 1 { + return (SectionDisposition::Unsupported, Some(MULTIPLE_CATCH_NOTE)); + } + if exception_catch_binding(&handler.argument).is_none() { + return (SectionDisposition::Unsupported, Some(CATCH_ARGUMENT_NOTE)); + } + (SectionDisposition::Embedded, None) +} + +fn section_entry_for_action( + data: &RecognizerCodegenData<'_>, + action: &grammar::model::NamedAction, + rule: Option<(usize, &str)>, + disposition: SectionDisposition, + note: Option<&'static str>, +) -> SectionEntry { + let (line, column) = structural_line_column(data, &action.span); + SectionEntry { + kind: SectionKind::NamedAction, + name: Some(action.name.clone()), + scope: action.scope.clone(), + rule_index: rule.map(|(index, _)| index), + rule_name: rule.map(|(_, name)| name.to_owned()), + path: section_path(data, &action.span), + source: section_source(data, &action.span), + line, + column, + body: one_line_action_body(&action.body), + disposition, + note, + } +} + +fn section_path(data: &RecognizerCodegenData<'_>, span: &SourceSpan) -> String { + data.sources + .and_then(|sources| sources.logical_path(span.source)) + .map_or_else(|| "".to_owned(), |path| path.display().to_string()) +} + +/// File name only: manifests must stay deterministic across checkouts, so +/// absolute logical paths never appear in `semantics.json`. +fn section_source(data: &RecognizerCodegenData<'_>, span: &SourceSpan) -> Option { + data.sources + .and_then(|sources| sources.logical_path(span.source)) + .and_then(|path| path.file_name()) + .map(|name| name.to_string_lossy().into_owned()) +} diff --git a/crates/antlr-rust-codegen/src/semantics/stack_member.rs b/crates/antlr-rust-codegen/src/semantics/stack_member.rs index ee11ea41..9b0f1e1f 100644 --- a/crates/antlr-rust-codegen/src/semantics/stack_member.rs +++ b/crates/antlr-rust-codegen/src/semantics/stack_member.rs @@ -108,7 +108,7 @@ impl MemberScope { } /// Whether a declaration with this scope is visible to `recognizer`. - const fn covers(self, recognizer: Self) -> bool { + pub(crate) const fn covers(self, recognizer: Self) -> bool { matches!( (self, recognizer), (Self::Both, _) | (Self::Lexer, Self::Lexer) | (Self::Parser, Self::Parser) diff --git a/crates/antlr-rust-codegen/src/structural/mod.rs b/crates/antlr-rust-codegen/src/structural/mod.rs index c72feb24..f1b6ab0a 100644 --- a/crates/antlr-rust-codegen/src/structural/mod.rs +++ b/crates/antlr-rust-codegen/src/structural/mod.rs @@ -302,6 +302,18 @@ pub(crate) fn structural_embedded_model( .iter() .find(|action| action.name == "after") .map(|action| action.body.clone()); + // Only the supported single-handler form lowers; anything else stays + // `None` and the section inventory reports it as unsupported. + let catch_clause = match rule.catches.as_slice() { + [handler] => crate::semantics::exception_catch_binding(&handler.argument) + .map(|binding| (binding, handler.body.clone())), + _ => None, + }; + let finally_body = rule + .finally_action + .as_ref() + .map(|action| action.body.clone()) + .filter(|body| !body.trim().is_empty()); rules[rule_index] = embedded::RuleModel { name: rule.name.clone(), attrs, @@ -317,24 +329,49 @@ pub(crate) fn structural_embedded_model( .collect(), init_body, after_body, + catch_clause, + finally_body, alts: structural_rule_alternatives(rule, &semantic.recognizer.vocabulary), }; } let mut parser_members = embedded::MembersModel::default(); + let mut header_items = Vec::new(); + let mut definitions_items = Vec::new(); if include_members { for action in &semantic.unit.actions { - if action.name == "members" - && action - .scope - .as_deref() - .is_none_or(|scope| scope == "parser") + if action + .scope + .as_deref() + .is_some_and(|scope| scope != "parser") { - embedded::classify_members( - &action.body, - action.body_span.source, - &mut parser_members, - )?; + continue; + } + match action.name.as_str() { + "members" => { + embedded::classify_members( + &action.body, + action.body_span.source, + &mut parser_members, + )?; + } + // Supported non-ATN sections (issue #355): bodies are emitted + // at documented module positions after the same token-alias + // translation `@members` items receive. Empty bodies have + // nothing to emit. + "header" if !action.body.trim().is_empty() => { + header_items.push(embedded::MemberItem { + source: action.body_span.source, + body: action.body.trim().to_owned(), + }); + } + "definitions" if !action.body.trim().is_empty() => { + definitions_items.push(embedded::MemberItem { + source: action.body_span.source, + body: action.body.trim().to_owned(), + }); + } + _ => {} } } } @@ -342,5 +379,7 @@ pub(crate) fn structural_embedded_model( Ok(embedded::EmbeddedModel { rules, parser_members, + header_items, + definitions_items, }) } diff --git a/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli.rs b/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli.rs index 6ec5e416..715ea242 100644 --- a/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli.rs +++ b/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli.rs @@ -16,6 +16,8 @@ mod optimizations; mod parser; #[path = "antlr4_rust_gen_cli/rust_support.rs"] mod rust_support; +#[path = "antlr4_rust_gen_cli/sections.rs"] +mod sections; #[path = "antlr4_rust_gen_cli/semantics.rs"] mod semantics; #[path = "antlr4_rust_gen_cli/support.rs"] diff --git a/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/cli.rs b/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/cli.rs index 4c4dc279..f2495cb0 100644 --- a/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/cli.rs +++ b/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/cli.rs @@ -99,7 +99,7 @@ fn generated_modules_enforce_codegen_api_compatibility() { "__antlr4_rust_require_codegen_api!({},", antlr4_runtime::__ANTLR4_RUST_CODEGEN_API ); - let previous = "__antlr4_rust_require_codegen_api!(14,"; + let previous = "__antlr4_rust_require_codegen_api!(15,"; let oldest_supported = "__antlr4_rust_require_codegen_api!(12,"; let unsupported = "__antlr4_rust_require_codegen_api!(11,"; let mut previous_parser = parser; @@ -141,7 +141,7 @@ fn generated_modules_enforce_codegen_api_compatibility() { .collect::>() .join("\n"); assert!( - diagnostic.contains("supports revisions 12, 13, 14, and 15"), + diagnostic.contains("supports revisions 12, 13, 14, 15, and 16"), "diagnostic should name the supported revisions: {diagnostic}" ); insta::assert_snapshot!( diff --git a/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/sections.rs b/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/sections.rs new file mode 100644 index 00000000..00f8d5f1 --- /dev/null +++ b/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/sections.rs @@ -0,0 +1,599 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026 Konstantin Vyatkin +//! Issue #355: authored target-code sections (named actions and rule +//! exception clauses) must execute, be routed through a documented +//! disposition, or fail generation — never disappear silently. +#![allow(clippy::disallowed_methods)] // insta assertion macros unwrap internal I/O. +#[allow(clippy::wildcard_imports)] +use super::support::*; + +fn generate_embedded_strict(grammar: &Path, out: &Path) -> Output { + run_antlr4_rust_gen(&[ + grammar.as_os_str(), + OsStr::new("--actions"), + OsStr::new("embedded"), + OsStr::new("--require-generated-parser"), + OsStr::new("--require-full-semantics"), + OsStr::new("--out-dir"), + out.as_os_str(), + ]) +} + +/// The full ANTLR rule lifecycle for ordinary rules: `@after` runs on the +/// committed path only, an authored `catch` replaces the default +/// report-and-recover handler, and `finally` runs exactly once on the +/// success, recovered-error, and caught-error paths. +#[test] +fn catch_finally_and_after_follow_antlr_lifecycle_ordering() { + let temp = temporary_directory("section-lifecycle"); + let grammar = temp.path().join("SectionLifecycle.g4"); + let out = temp.path().join("generated"); + fs::write( + &grammar, + r#"grammar SectionLifecycle; + +start +@init { crate::record_event("init"); } +@after { crate::record_event("after"); } + : A B EOF + ; +finally { + crate::record_event("finally"); +} + +caught + : A B EOF + ; +catch [error] { + let _ = &error; + crate::record_event("catch"); +} + +javaStyle + : A B EOF + ; +catch [RecognitionException e] { + let _ = &e; + crate::record_event("java-style-catch"); +} + +A: 'a'; +B: 'b'; +WS: [ \t\r\n]+ -> skip; +"#, + ) + .expect("grammar should be writable"); + + let output = generate_embedded_strict(&grammar, &out); + assert!( + output.status.success(), + "stdout: {}\nstderr: {}", + utf8(&output.stdout), + utf8(&output.stderr) + ); + + let manifest = + fs::read_to_string(out.join("semantics.json")).expect("manifest should be emitted"); + insta::assert_snapshot!("section_lifecycle_semantics_manifest", manifest); + + let parser = fs::read_to_string(out.join("section_lifecycle_parser.rs")) + .expect("parser should be emitted"); + // The propagated-failure slot carries the authored `finally`, and the + // authored catch replaces the default handler. + assert!( + parser.contains("propagate {"), + "missing propagate section:\n{parser}" + ); + assert!( + parser.contains("exception (|error| {"), + "missing authored catch handler:\n{parser}" + ); + assert!( + parser.contains("exception (|e| {"), + "Java-style catch argument should bind its last identifier:\n{parser}" + ); + + let test_source = r####" +use std::cell::RefCell; + +thread_local! { + static EVENTS: RefCell> = RefCell::new(Vec::new()); +} + +pub fn record_event(event: &str) { + EVENTS.with(|events| events.borrow_mut().push(event.to_owned())); +} + +#[cfg(test)] +fn take_events() -> Vec { + EVENTS.with(|events| events.borrow_mut().drain(..).collect()) +} + +#[cfg(test)] +mod section_lifecycle_tests { + use super::section_lifecycle_lexer::SectionLifecycleLexer; + use super::section_lifecycle_parser::SectionLifecycleParser; + use super::take_events; + use antlr4_runtime::{CommonTokenStream, InputStream, Parser as _}; + + macro_rules! parser { + ($input:expr) => { + SectionLifecycleParser::new(CommonTokenStream::new(SectionLifecycleLexer::new( + InputStream::new($input), + ))) + }; + } + + #[test] + fn success_runs_init_after_then_finally() { + let mut parser = parser!("ab"); + parser.start().expect("clean input should parse"); + assert_eq!(parser.number_of_syntax_errors(), 0); + assert_eq!(take_events(), ["init", "after", "finally"]); + } + + #[test] + fn recovered_error_skips_after_but_runs_finally_once() { + let mut parser = parser!("aa"); + parser + .start() + .expect("default recovery should still produce a tree"); + assert!(parser.number_of_syntax_errors() > 0); + assert_eq!(take_events(), ["init", "finally"]); + } + + #[test] + fn authored_catch_replaces_default_recovery() { + let mut parser = parser!("aa"); + parser.caught().expect("caught rule completes normally"); + // ANTLR emits the authored clause instead of the default + // report-and-recover handler, so no syntax error is recorded. + assert_eq!(parser.number_of_syntax_errors(), 0); + assert_eq!(take_events(), ["catch"]); + } + + #[test] + fn authored_catch_does_not_run_on_success() { + let mut parser = parser!("ab"); + parser.caught().expect("clean input should parse"); + assert_eq!(parser.number_of_syntax_errors(), 0); + assert!(take_events().is_empty()); + } + + #[test] + fn java_style_catch_argument_binds_last_identifier() { + let mut parser = parser!("aa"); + parser.java_style().expect("caught rule completes normally"); + assert_eq!(parser.number_of_syntax_errors(), 0); + assert_eq!(take_events(), ["java-style-catch"]); + } +} +"####; + assert_generated_project( + temp.path(), + &["section_lifecycle_lexer.rs", "section_lifecycle_parser.rs"], + test_source, + ); +} + +/// Ordinary and left-recursive rules share one lifecycle contract: a +/// left-recursive rule's operator loop is one rule invocation, so its +/// `finally` runs exactly once however many expansions the loop takes. +#[test] +fn finally_runs_once_per_left_recursive_rule_invocation() { + let temp = temporary_directory("section-left-recursion"); + let grammar = temp.path().join("SectionExpr.g4"); + let out = temp.path().join("generated"); + fs::write( + &grammar, + r#"grammar SectionExpr; + +start + : expr EOF + ; + +expr + : expr PLUS expr + | ID + ; +finally { + crate::record_event("expr-finally"); +} + +ID: [a-z]+; +PLUS: '+'; +WS: [ \t\r\n]+ -> skip; +"#, + ) + .expect("grammar should be writable"); + + let output = generate_embedded_strict(&grammar, &out); + assert!( + output.status.success(), + "stdout: {}\nstderr: {}", + utf8(&output.stdout), + utf8(&output.stderr) + ); + + let test_source = r####" +use std::cell::RefCell; + +thread_local! { + static EVENTS: RefCell> = RefCell::new(Vec::new()); +} + +pub fn record_event(event: &str) { + EVENTS.with(|events| events.borrow_mut().push(event.to_owned())); +} + +#[cfg(test)] +mod left_recursion_tests { + use super::EVENTS; + use super::section_expr_lexer::SectionExprLexer; + use super::section_expr_parser::SectionExprParser; + use antlr4_runtime::{CommonTokenStream, InputStream, Parser as _}; + + fn finally_count(input: &str) -> usize { + let mut parser = SectionExprParser::new(CommonTokenStream::new(SectionExprLexer::new( + InputStream::new(input), + ))); + parser.start().expect("input should parse"); + assert_eq!(parser.number_of_syntax_errors(), 0); + EVENTS.with(|events| events.borrow_mut().drain(..).count()) + } + + #[test] + fn operator_chain_is_one_rule_invocation() { + // `a` enters `expr` once. `a+a+a` enters the rule function three + // times: once for the top-level invocation whose operator loop + // expands twice, plus once per right operand — never once per loop + // pass beyond those entries. + assert_eq!(finally_count("a"), 1); + assert_eq!(finally_count("a+a+a"), 3); + } +} +"####; + assert_generated_project( + temp.path(), + &["section_expr_lexer.rs", "section_expr_parser.rs"], + test_source, + ); +} + +/// Supported `@header` / `@definitions` bodies appear exactly once at their +/// documented module positions: `@header` before the generated imports, +/// `@definitions` at module scope after the `@members` module items. +#[test] +fn header_and_definitions_emit_once_at_documented_positions() { + let temp = temporary_directory("section-header"); + let grammar = temp.path().join("Header.g4"); + let out = temp.path().join("generated"); + fs::write( + &grammar, + r#"grammar Header; + +@header { + const HEADER_SENTINEL: i32 = 7; +} + +@parser::definitions { + fn definition_sentinel() -> i32 { HEADER_SENTINEL } +} + +@parser::members { + fn use_definitions(&mut self) -> i32 { definition_sentinel() } +} + +start: A EOF; +A: 'a'; +"#, + ) + .expect("grammar should be writable"); + + let output = generate_embedded_strict(&grammar, &out); + assert!( + output.status.success(), + "stdout: {}\nstderr: {}", + utf8(&output.stdout), + utf8(&output.stderr) + ); + + let parser = + fs::read_to_string(out.join("header_parser.rs")).expect("parser should be emitted"); + assert_eq!( + parser.matches("const HEADER_SENTINEL: i32 = 7;").count(), + 1, + "@header must be emitted exactly once:\n{parser}" + ); + assert_eq!( + parser.matches("fn definition_sentinel").count(), + 1, + "@definitions must be emitted exactly once:\n{parser}" + ); + let header_position = parser + .find("const HEADER_SENTINEL") + .expect("header body is present"); + let first_import = parser.find("use antlr4_runtime").expect("imports exist"); + assert!( + header_position < first_import, + "@header must precede the generated imports:\n{parser}" + ); + // The unscoped @header belongs to the parser module of a combined + // grammar; the lexer module must not duplicate it. + let lexer = fs::read_to_string(out.join("header_lexer.rs")).expect("lexer should be emitted"); + assert!( + !lexer.contains("HEADER_SENTINEL"), + "@header must not be duplicated into the lexer module:\n{lexer}" + ); + + let manifest = + fs::read_to_string(out.join("semantics.json")).expect("manifest should be emitted"); + for expected in [ + r#""name": "header""#, + r#""name": "definitions""#, + r#""disposition": "embedded""#, + ] { + assert!( + manifest.contains(expected), + "missing {expected} in manifest:\n{manifest}" + ); + } + + assert_generated_modules_compile(temp.path(), &["header_lexer.rs", "header_parser.rs"]); +} + +fn write_section_audit_grammar(path: &Path) { + fs::write( + path, + r#"grammar SectionAudit; + +@header { + const AUDIT: i32 = 1; +} + +@lexer::members { + fn lexer_helper(&mut self) {} +} + +@parser::tokenfactory { + unknown_section(); +} + +start +@init { let _ = AUDIT; } + : A EOF + ; +catch [not a valid binding list] { + let _ = (); +} + +multi: A EOF; +catch [first] { let _ = &first; } +catch [second] { let _ = &second; } + +A: 'a'; +"#, + ) + .expect("grammar should be writable"); +} + +/// Every authored section receives a manifest row: supported ones are +/// `embedded`, everything else is `unsupported` and warns without failing a +/// default (non-strict) run. +#[test] +fn unsupported_sections_warn_and_stay_manifest_visible() { + let temp = temporary_directory("section-audit"); + let grammar = temp.path().join("SectionAudit.g4"); + let out = temp.path().join("generated"); + write_section_audit_grammar(&grammar); + + let output = run_antlr4_rust_gen(&[ + grammar.as_os_str(), + OsStr::new("--actions"), + OsStr::new("embedded"), + OsStr::new("--out-dir"), + out.as_os_str(), + ]); + assert!( + output.status.success(), + "stdout: {}\nstderr: {}", + utf8(&output.stdout), + utf8(&output.stderr) + ); + let stderr = utf8(&output.stderr); + for expected in [ + "warning: unsupported target-code section: @lexer::members", + "warning: unsupported target-code section: @parser::tokenfactory", + "warning: unsupported target-code section: rule start(0) catch[...]", + "warning: unsupported target-code section: rule multi(1) catch[...]", + ] { + assert!(expected.is_ascii()); + assert!( + stderr.contains(expected), + "missing {expected:?} in {stderr}" + ); + } + + let manifest = + fs::read_to_string(out.join("semantics.json")).expect("manifest should be emitted"); + insta::assert_snapshot!("section_audit_semantics_manifest", manifest); +} + +/// `--require-full-semantics` rejects every unsupported authored section with +/// a source-positioned diagnostic and remediation guidance. +#[test] +fn require_full_semantics_rejects_unsupported_sections() { + let temp = temporary_directory("section-audit-strict"); + let grammar = temp.path().join("SectionAudit.g4"); + let out = temp.path().join("generated"); + write_section_audit_grammar(&grammar); + + let output = generate_embedded_strict(&grammar, &out); + assert!( + !output.status.success(), + "unsupported sections must fail strict generation\nstdout: {}", + utf8(&output.stdout) + ); + let stderr = utf8(&output.stderr); + // Source path, line, column, section kind, body, and remediation — for + // every unsupported section across both recognizers in one run. + for expected in [ + "unsupported target-code section: @lexer::members at ", + "SectionAudit.g4:7:", + "lexer-scoped sections are not implemented in embedded mode", + "unsupported target-code section: @parser::tokenfactory at ", + "unsupported target-code section: rule start(0) catch[...] at ", + "catch argument must be a Rust identifier", + "unsupported target-code section: rule multi(1) catch[...] at ", + "multiple catch clauses are not supported", + "--require-full-semantics: 5 target-code section(s) would be silently dropped", + ] { + assert!( + stderr.contains(expected), + "missing {expected:?} in {stderr}" + ); + } +} + +/// Templates mode executes no authored target code, so the strict flag must +/// reject the sections it would silently drop; the default run keeps +/// succeeding with warnings. +#[test] +fn templates_mode_rejects_authored_sections_under_strict_flag() { + let temp = temporary_directory("section-templates"); + let grammar = temp.path().join("TemplateSections.g4"); + fs::write( + &grammar, + r#"grammar TemplateSections; + +@header { + package com.example; +} + +start +@init { setup(); } + : A EOF + ; +finally { + cleanup(); +} + +A: 'a'; +"#, + ) + .expect("grammar should be writable"); + + let lenient = temp.path().join("generated-lenient"); + let output = run_antlr4_rust_gen(&[ + grammar.as_os_str(), + OsStr::new("--out-dir"), + lenient.as_os_str(), + ]); + assert!( + output.status.success(), + "stdout: {}\nstderr: {}", + utf8(&output.stdout), + utf8(&output.stderr) + ); + let stderr = utf8(&output.stderr); + for expected in [ + "warning: unsupported target-code section: @header", + "warning: unsupported target-code section: rule start(0) @init", + "warning: unsupported target-code section: rule start(0) finally", + "templates mode does not execute authored target-code sections", + ] { + assert!( + stderr.contains(expected), + "missing {expected:?} in {stderr}" + ); + } + + let strict = temp.path().join("generated-strict"); + let output = run_antlr4_rust_gen(&[ + grammar.as_os_str(), + OsStr::new("--require-full-semantics"), + OsStr::new("--out-dir"), + strict.as_os_str(), + ]); + assert!( + !output.status.success(), + "templates mode must reject dropped sections under the strict flag" + ); + let stderr = utf8(&output.stderr); + for expected in [ + "unsupported target-code section: @header at ", + "TemplateSections.g4:3:", + "--require-full-semantics: 3 target-code section(s) would be silently dropped", + ] { + assert!( + stderr.contains(expected), + "missing {expected:?} in {stderr}" + ); + } +} + +/// Sections survive import resolution: an imported grammar's `@members` and a +/// cloned rule's `finally` clause stay inventoried (and executed) in the +/// importing recognizer, attributed to the imported source file. +#[test] +fn imported_grammar_sections_are_inventoried() { + let temp = temporary_directory("section-imports"); + let root = temp.path().join("Root.g4"); + let sub = temp.path().join("Sub.g4"); + let out = temp.path().join("generated"); + fs::write( + &root, + r#"grammar Root; +import Sub; + +start: item EOF; + +A: 'a'; +"#, + ) + .expect("root grammar should be writable"); + fs::write( + &sub, + r#"parser grammar Sub; + +@members { + fn imported_helper(&mut self) {} +} + +item: A; +finally { + self.imported_helper(); +} +"#, + ) + .expect("imported grammar should be writable"); + + let output = run_antlr4_rust_gen(&[ + root.as_os_str(), + OsStr::new("--lib"), + temp.path().as_os_str(), + OsStr::new("--actions"), + OsStr::new("embedded"), + OsStr::new("--require-generated-parser"), + OsStr::new("--require-full-semantics"), + OsStr::new("--out-dir"), + out.as_os_str(), + ]); + assert!( + output.status.success(), + "stdout: {}\nstderr: {}", + utf8(&output.stdout), + utf8(&output.stderr) + ); + + let manifest = + fs::read_to_string(out.join("semantics.json")).expect("manifest should be emitted"); + insta::assert_snapshot!("imported_sections_semantics_manifest", manifest); + + let parser = fs::read_to_string(out.join("root_parser.rs")).expect("parser should be emitted"); + assert!( + parser.contains("self.imported_helper();"), + "imported finally body must execute:\n{parser}" + ); + assert_generated_modules_compile(temp.path(), &["root_lexer.rs", "root_parser.rs"]); +} diff --git a/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__cli__generated_codegen_api_checks.snap b/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__cli__generated_codegen_api_checks.snap index 374a5e33..ec368a1d 100644 --- a/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__cli__generated_codegen_api_checks.snap +++ b/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__cli__generated_codegen_api_checks.snap @@ -2,5 +2,5 @@ source: crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/cli.rs expression: normalize_current_package_version(&checks) --- -lexer: antlr4_runtime::__antlr4_rust_require_codegen_api!(15, ""); -parser: antlr4_runtime::__antlr4_rust_require_codegen_api!(15, ""); +lexer: antlr4_runtime::__antlr4_rust_require_codegen_api!(16, ""); +parser: antlr4_runtime::__antlr4_rust_require_codegen_api!(16, ""); diff --git a/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__cli__generated_codegen_api_mismatch_diagnostic.snap b/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__cli__generated_codegen_api_mismatch_diagnostic.snap index 3fc815bc..019ba5fb 100644 --- a/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__cli__generated_codegen_api_mismatch_diagnostic.snap +++ b/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__cli__generated_codegen_api_mismatch_diagnostic.snap @@ -2,5 +2,5 @@ source: crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/cli.rs expression: normalize_current_package_version(&diagnostic) --- -error: antlr4-rust generated-code API mismatch: antlr4-rust-gen v emitted generated-code API revision 11, but the selected antlr-rust-runtime supports revisions 12, 13, 14, and 15; regenerate this recognizer with a compatible antlr4-rust-gen or select a compatible antlr-rust-runtime dependency +error: antlr4-rust generated-code API mismatch: antlr4-rust-gen v emitted generated-code API revision 11, but the selected antlr-rust-runtime supports revisions 12, 13, 14, 15, and 16; regenerate this recognizer with a compatible antlr4-rust-gen or select a compatible antlr-rust-runtime dependency --> src/codegen_api_parser.rs:3:1 diff --git a/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__compatibility__antlr4rust_compat_semantics_manifest.snap b/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__compatibility__antlr4rust_compat_semantics_manifest.snap index 61743f78..070264fe 100644 --- a/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__compatibility__antlr4rust_compat_semantics_manifest.snap +++ b/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__compatibility__antlr4rust_compat_semantics_manifest.snap @@ -11,7 +11,8 @@ expression: manifest { "kind": "lexer", "name": "CCompatLexer", - "coordinates": [] + "coordinates": [], + "sections": [] }, { "kind": "parser", @@ -89,12 +90,14 @@ expression: manifest "disposition": "translated", "template": "Embedded" } - ] + ], + "sections": [] }, { "kind": "lexer", "name": "JavaCompatLexer", - "coordinates": [] + "coordinates": [], + "sections": [] }, { "kind": "parser", @@ -232,12 +235,27 @@ expression: manifest "disposition": "translated", "template": "Embedded" } + ], + "sections": [ + { + "kind": "named-action", + "name": "init", + "scope": null, + "rule": "liveAttributes", + "rule_index": 6, + "source": "JavaCompat.g4", + "line": 70, + "column": 0, + "body": "$value = 1;", + "disposition": "embedded" + } ] }, { "kind": "lexer", "name": "AliasOnlyLexer", - "coordinates": [] + "coordinates": [], + "sections": [] }, { "kind": "parser", @@ -255,12 +273,14 @@ expression: manifest "disposition": "translated", "template": "Embedded" } - ] + ], + "sections": [] }, { "kind": "lexer", "name": "AliasCollisionLexer", - "coordinates": [] + "coordinates": [], + "sections": [] }, { "kind": "parser", @@ -290,6 +310,20 @@ expression: manifest "disposition": "translated", "template": "Embedded" } + ], + "sections": [ + { + "kind": "named-action", + "name": "members", + "scope": "parser", + "rule": null, + "rule_index": null, + "source": "AliasCollision.g4", + "line": 4, + "column": 0, + "body": "marker: i32 = AliasCollisionParser_FIELD_INIT; field_type: [u8; AliasCollisionParser_FIELD_TYPE ...", + "disposition": "embedded" + } ] } ] diff --git a/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__sections__imported_sections_semantics_manifest.snap b/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__sections__imported_sections_semantics_manifest.snap new file mode 100644 index 00000000..e71dfd10 --- /dev/null +++ b/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__sections__imported_sections_semantics_manifest.snap @@ -0,0 +1,49 @@ +--- +source: crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/sections.rs +expression: manifest +--- +{ + "version": 2, + "policy": "assume-true", + "note": "unknown coordinates currently default to assume-true; a future minor release changes the default to error", + "options": [], + "grammars": [ + { + "kind": "lexer", + "name": "RootLexer", + "coordinates": [], + "sections": [] + }, + { + "kind": "parser", + "name": "RootParser", + "coordinates": [], + "sections": [ + { + "kind": "named-action", + "name": "members", + "scope": null, + "rule": null, + "rule_index": null, + "source": "Sub.g4", + "line": 3, + "column": 0, + "body": "fn imported_helper(&mut self) {}", + "disposition": "embedded" + }, + { + "kind": "finally", + "name": null, + "scope": null, + "rule": "item", + "rule_index": 1, + "source": "Sub.g4", + "line": 8, + "column": 0, + "body": "self.imported_helper();", + "disposition": "embedded" + } + ] + } + ] +} diff --git a/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__sections__section_audit_semantics_manifest.snap b/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__sections__section_audit_semantics_manifest.snap new file mode 100644 index 00000000..0c4ab48f --- /dev/null +++ b/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__sections__section_audit_semantics_manifest.snap @@ -0,0 +1,110 @@ +--- +source: crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/sections.rs +expression: manifest +--- +{ + "version": 2, + "policy": "assume-true", + "note": "unknown coordinates currently default to assume-true; a future minor release changes the default to error", + "options": [], + "grammars": [ + { + "kind": "lexer", + "name": "SectionAuditLexer", + "coordinates": [], + "sections": [ + { + "kind": "named-action", + "name": "members", + "scope": "lexer", + "rule": null, + "rule_index": null, + "source": "SectionAudit.g4", + "line": 7, + "column": 0, + "body": "fn lexer_helper(&mut self) {}", + "disposition": "unsupported" + } + ] + }, + { + "kind": "parser", + "name": "SectionAuditParser", + "coordinates": [], + "sections": [ + { + "kind": "named-action", + "name": "header", + "scope": null, + "rule": null, + "rule_index": null, + "source": "SectionAudit.g4", + "line": 3, + "column": 0, + "body": "const AUDIT: i32 = 1;", + "disposition": "embedded" + }, + { + "kind": "named-action", + "name": "tokenfactory", + "scope": "parser", + "rule": null, + "rule_index": null, + "source": "SectionAudit.g4", + "line": 11, + "column": 0, + "body": "unknown_section();", + "disposition": "unsupported" + }, + { + "kind": "named-action", + "name": "init", + "scope": null, + "rule": "start", + "rule_index": 0, + "source": "SectionAudit.g4", + "line": 16, + "column": 0, + "body": "let _ = AUDIT;", + "disposition": "embedded" + }, + { + "kind": "catch", + "name": null, + "scope": null, + "rule": "start", + "rule_index": 0, + "source": "SectionAudit.g4", + "line": 19, + "column": 0, + "body": "let _ = ();", + "disposition": "unsupported" + }, + { + "kind": "catch", + "name": null, + "scope": null, + "rule": "multi", + "rule_index": 1, + "source": "SectionAudit.g4", + "line": 24, + "column": 0, + "body": "let _ = &first;", + "disposition": "unsupported" + }, + { + "kind": "catch", + "name": null, + "scope": null, + "rule": "multi", + "rule_index": 1, + "source": "SectionAudit.g4", + "line": 25, + "column": 0, + "body": "let _ = &second;", + "disposition": "unsupported" + } + ] + } + ] +} diff --git a/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__sections__section_lifecycle_semantics_manifest.snap b/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__sections__section_lifecycle_semantics_manifest.snap new file mode 100644 index 00000000..3838f302 --- /dev/null +++ b/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__sections__section_lifecycle_semantics_manifest.snap @@ -0,0 +1,85 @@ +--- +source: crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/sections.rs +expression: manifest +--- +{ + "version": 2, + "policy": "assume-true", + "note": "unknown coordinates currently default to assume-true; a future minor release changes the default to error", + "options": [], + "grammars": [ + { + "kind": "lexer", + "name": "SectionLifecycleLexer", + "coordinates": [], + "sections": [] + }, + { + "kind": "parser", + "name": "SectionLifecycleParser", + "coordinates": [], + "sections": [ + { + "kind": "named-action", + "name": "init", + "scope": null, + "rule": "start", + "rule_index": 0, + "source": "SectionLifecycle.g4", + "line": 4, + "column": 0, + "body": "crate::record_event(\"init\");", + "disposition": "embedded" + }, + { + "kind": "named-action", + "name": "after", + "scope": null, + "rule": "start", + "rule_index": 0, + "source": "SectionLifecycle.g4", + "line": 5, + "column": 0, + "body": "crate::record_event(\"after\");", + "disposition": "embedded" + }, + { + "kind": "finally", + "name": null, + "scope": null, + "rule": "start", + "rule_index": 0, + "source": "SectionLifecycle.g4", + "line": 8, + "column": 0, + "body": "crate::record_event(\"finally\");", + "disposition": "embedded" + }, + { + "kind": "catch", + "name": null, + "scope": null, + "rule": "caught", + "rule_index": 1, + "source": "SectionLifecycle.g4", + "line": 15, + "column": 0, + "body": "let _ = &error; crate::record_event(\"catch\");", + "disposition": "embedded" + }, + { + "kind": "catch", + "name": null, + "scope": null, + "rule": "javaStyle", + "rule_index": 2, + "source": "SectionLifecycle.g4", + "line": 23, + "column": 0, + "body": "let _ = &e; crate::record_event(\"java-style-catch\");", + "disposition": "embedded" + } + ] + } + ] +} diff --git a/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__semantics__named_parser_actions_semantics_manifest.snap b/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__semantics__named_parser_actions_semantics_manifest.snap index 4c6dda75..3be4e6d9 100644 --- a/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__semantics__named_parser_actions_semantics_manifest.snap +++ b/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__semantics__named_parser_actions_semantics_manifest.snap @@ -11,7 +11,8 @@ expression: manifest { "kind": "lexer", "name": "ActionTimingLexer", - "coordinates": [] + "coordinates": [], + "sections": [] }, { "kind": "parser", @@ -185,7 +186,8 @@ expression: manifest "disposition": "hooked", "template": "Hook(middle)" } - ] + ], + "sections": [] } ] } diff --git a/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__semantics__recog_receiver_semantics_manifest.snap b/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__semantics__recog_receiver_semantics_manifest.snap index b2ef5999..a5fbe505 100644 --- a/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__semantics__recog_receiver_semantics_manifest.snap +++ b/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__semantics__recog_receiver_semantics_manifest.snap @@ -19,7 +19,8 @@ expression: manifest { "kind": "lexer", "name": "RecogPredicateLexer", - "coordinates": [] + "coordinates": [], + "sections": [] }, { "kind": "parser", @@ -37,7 +38,8 @@ expression: manifest "disposition": "hooked", "template": "Hook" } - ] + ], + "sections": [] } ] } diff --git a/crates/antlr-rust-g4-parser/src/generated/antlr_v4_lexer.rs b/crates/antlr-rust-g4-parser/src/generated/antlr_v4_lexer.rs index c0016769..69f7303a 100644 --- a/crates/antlr-rust-g4-parser/src/generated/antlr_v4_lexer.rs +++ b/crates/antlr-rust-g4-parser/src/generated/antlr_v4_lexer.rs @@ -1,6 +1,6 @@ // @generated by antlr-rust-codegen v0.34.0 - do not edit // project: https://github.com/ophi-dev/antlr-rust-runtime -antlr4_runtime::__antlr4_rust_require_codegen_api!(15, "0.34.0"); +antlr4_runtime::__antlr4_rust_require_codegen_api!(16, "0.34.0"); #[allow(warnings, missing_docs, clippy::all, clippy::pedantic, clippy::nursery)] #[rustfmt::skip] mod __antlr4_rust_generated { diff --git a/crates/antlr-rust-g4-parser/src/generated/antlr_v4_parser.rs b/crates/antlr-rust-g4-parser/src/generated/antlr_v4_parser.rs index 10a335e0..eefc8e09 100644 --- a/crates/antlr-rust-g4-parser/src/generated/antlr_v4_parser.rs +++ b/crates/antlr-rust-g4-parser/src/generated/antlr_v4_parser.rs @@ -1,6 +1,6 @@ // @generated by antlr-rust-codegen v0.34.0 - do not edit // project: https://github.com/ophi-dev/antlr-rust-runtime -antlr4_runtime::__antlr4_rust_require_codegen_api!(15, "0.34.0"); +antlr4_runtime::__antlr4_rust_require_codegen_api!(16, "0.34.0"); #[allow(warnings, missing_docs, clippy::all, clippy::pedantic, clippy::nursery)] #[rustfmt::skip] mod __antlr4_rust_generated { diff --git a/crates/antlr-rust-rs-parser/src/generated/rust_lexer.rs b/crates/antlr-rust-rs-parser/src/generated/rust_lexer.rs index 53218e09..c6c4f4a7 100644 --- a/crates/antlr-rust-rs-parser/src/generated/rust_lexer.rs +++ b/crates/antlr-rust-rs-parser/src/generated/rust_lexer.rs @@ -1,6 +1,6 @@ // @generated by antlr-rust-codegen v0.34.0 - do not edit // project: https://github.com/ophi-dev/antlr-rust-runtime -antlr4_runtime::__antlr4_rust_require_codegen_api!(15, "0.34.0"); +antlr4_runtime::__antlr4_rust_require_codegen_api!(16, "0.34.0"); #[allow(warnings, missing_docs, clippy::all, clippy::pedantic, clippy::nursery)] #[rustfmt::skip] mod __antlr4_rust_generated { diff --git a/crates/antlr-rust-rs-parser/src/generated/rust_parser.rs b/crates/antlr-rust-rs-parser/src/generated/rust_parser.rs index 7a5e1fcb..d46f162a 100644 --- a/crates/antlr-rust-rs-parser/src/generated/rust_parser.rs +++ b/crates/antlr-rust-rs-parser/src/generated/rust_parser.rs @@ -1,6 +1,6 @@ // @generated by antlr-rust-codegen v0.34.0 - do not edit // project: https://github.com/ophi-dev/antlr-rust-runtime -antlr4_runtime::__antlr4_rust_require_codegen_api!(15, "0.34.0"); +antlr4_runtime::__antlr4_rust_require_codegen_api!(16, "0.34.0"); #[allow(warnings, missing_docs, clippy::all, clippy::pedantic, clippy::nursery)] #[rustfmt::skip] mod __antlr4_rust_generated { diff --git a/crates/antlr-rust-rs-parser/src/generated/semantics.json b/crates/antlr-rust-rs-parser/src/generated/semantics.json index bf4fb0cf..923ff854 100644 --- a/crates/antlr-rust-rs-parser/src/generated/semantics.json +++ b/crates/antlr-rust-rs-parser/src/generated/semantics.json @@ -7,7 +7,8 @@ { "kind": "lexer", "name": "RustLexer", - "coordinates": [] + "coordinates": [], + "sections": [] }, { "kind": "parser", @@ -301,7 +302,8 @@ "disposition": "synthetic", "template": null } - ] + ], + "sections": [] } ] } diff --git a/crates/antlr-rust-runtime/src/lib.rs b/crates/antlr-rust-runtime/src/lib.rs index bb5e5cb4..6f8acd80 100644 --- a/crates/antlr-rust-runtime/src/lib.rs +++ b/crates/antlr-rust-runtime/src/lib.rs @@ -6,12 +6,13 @@ extern crate self as antlr4_runtime; /// Current generated-source/runtime contract revision emitted by the bundled generator. #[doc(hidden)] -pub const __ANTLR4_RUST_CODEGEN_API: u32 = 15; +pub const __ANTLR4_RUST_CODEGEN_API: u32 = 16; /// Verifies that generated source is compatible with the selected runtime. #[doc(hidden)] #[macro_export] macro_rules! __antlr4_rust_require_codegen_api { + (16, $generator_version:literal) => {}; (15, $generator_version:literal) => {}; (14, $generator_version:literal) => {}; (13, $generator_version:literal) => {}; @@ -22,7 +23,7 @@ macro_rules! __antlr4_rust_require_codegen_api { $generator_version, " emitted generated-code API revision ", stringify!($requested), - ", but the selected antlr-rust-runtime supports revisions 12, 13, 14, and 15; \ + ", but the selected antlr-rust-runtime supports revisions 12, 13, 14, 15, and 16; \ regenerate this recognizer with a compatible antlr4-rust-gen or \ select a compatible antlr-rust-runtime dependency" )); diff --git a/crates/antlr-rust-runtime/src/parser.rs b/crates/antlr-rust-runtime/src/parser.rs index 44a97f48..21b84aca 100644 --- a/crates/antlr-rust-runtime/src/parser.rs +++ b/crates/antlr-rust-runtime/src/parser.rs @@ -138,6 +138,23 @@ pub fn grow_generated_rule_stack(body: impl FnOnce() -> R) -> R { /// hand-written parser API. The binders supplied by generated code keep /// grammar-specific locals and steps inline while this macro owns the entry, /// recovery, and exit state machine. +/// +/// The optional trailing sections carry authored rule exception handling: +/// +/// - `exception (none);` keeps the default report-and-recover handler; +/// `exception (|name| { ... });` replaces it with an authored handler that +/// receives the recognition error bound to `name` (an authored grammar +/// `catch` clause). The handler runs instead of default recovery, after +/// which the rule finishes normally. +/// - `propagate { ... };` runs only when the generated attempt is abandoned +/// with a fatal propagated error (entry-rule sync failure), so an authored +/// `finally` still executes on that path. The generator weaves `finally` +/// bodies into the `success`, `recovery`, authored-exception, and +/// `propagate` slots; this macro has no `finally` concept of its own. +/// +/// Neither section runs on the adaptive-retry unwind: the retried execution +/// re-enters the rule from the top, so its sections run on the final attempt +/// only. #[doc(hidden)] #[macro_export] macro_rules! __antlr4_rust_generated_rule { @@ -150,6 +167,52 @@ macro_rules! __antlr4_rust_generated_rule { body { $($body:tt)* } success { $($success:tt)* } recovery { $($recovery:tt)* } + ) => { + $crate::__antlr4_rust_generated_rule! { + ordinary $parser, $state, $rule, $allow_fallback, $atn, $fatal; + retry [$($retry)*]; + bind ($ctx, $rule_start, $consumed_eof, $sync_error); + setup { $($setup)* } + body { $($body)* } + success { $($success)* } + recovery { $($recovery)* } + exception (none); + propagate { }; + } + }; + ( + recursive $parser:ident, $state:expr, $rule:expr, $precedence:expr, + $allow_fallback:expr, $atn:expr, $fatal:path; + retry [$($retry:tt)*]; + bind ($ctx:ident, $rule_start:ident, $consumed_eof:ident, $sync_error:ident); + setup { $($setup:tt)* } + body { $($body:tt)* } + success { $($success:tt)* } + recovery { $($recovery:tt)* } + ) => { + $crate::__antlr4_rust_generated_rule! { + recursive $parser, $state, $rule, $precedence, $allow_fallback, $atn, $fatal; + retry [$($retry)*]; + bind ($ctx, $rule_start, $consumed_eof, $sync_error); + setup { $($setup)* } + body { $($body)* } + success { $($success)* } + recovery { $($recovery)* } + exception (none); + propagate { }; + } + }; + ( + ordinary $parser:ident, $state:expr, $rule:expr, $allow_fallback:expr, + $atn:expr, $fatal:path; + retry [$($retry:tt)*]; + bind ($ctx:ident, $rule_start:ident, $consumed_eof:ident, $sync_error:ident); + setup { $($setup:tt)* } + body { $($body:tt)* } + success { $($success:tt)* } + recovery { $($recovery:tt)* } + exception ($($exception:tt)+); + propagate { $($propagate:tt)* }; ) => { $crate::__antlr4_rust_generated_rule! { @body @@ -166,6 +229,8 @@ macro_rules! __antlr4_rust_generated_rule { body { $($body)* } success { $($success)* } recovery { $($recovery)* } + exception ($($exception)+); + propagate { $($propagate)* }; } }; ( @@ -177,6 +242,8 @@ macro_rules! __antlr4_rust_generated_rule { body { $($body:tt)* } success { $($success:tt)* } recovery { $($recovery:tt)* } + exception ($($exception:tt)+); + propagate { $($propagate:tt)* }; ) => { $crate::__antlr4_rust_generated_rule! { @body @@ -193,6 +260,8 @@ macro_rules! __antlr4_rust_generated_rule { body { $($body)* } success { $($success)* } recovery { $($recovery)* } + exception ($($exception)+); + propagate { $($propagate)* }; } }; ( @@ -210,6 +279,8 @@ macro_rules! __antlr4_rust_generated_rule { body { $($body:tt)* } success { $($success:tt)* } recovery { $($recovery:tt)* } + exception ($($exception:tt)+); + propagate { $($propagate:tt)* }; ) => {{ let __generated_diagnostic_marker = $parser.base.generated_diagnostics_checkpoint(); @@ -238,27 +309,96 @@ macro_rules! __antlr4_rust_generated_rule { marker __generated_diagnostic_marker; abort $abort; } - let __error = if let Some(__sync_error) = $sync_error { - if $allow_fallback { - $parser.base.$abort(); - $parser - .base - .rollback_generated_tree(__generated_diagnostic_marker); - $parser.base.record_generated_syntax_error(); - return Err($fatal(__sync_error)); - } - __sync_error - } else { - __error - }; + $crate::__antlr4_rust_generated_rule! { + @handle_error ($($exception)+) + parser $parser; + finish $finish; + abort $abort; + allow_fallback $allow_fallback; + atn $atn; + fatal $fatal; + marker __generated_diagnostic_marker; + error __error; + bind ($ctx, $consumed_eof, $sync_error); + recovery { $($recovery)* } + propagate { $($propagate)* } + } + } + } + }}; + ( + @handle_error (none) + parser $parser:ident; + finish $finish:ident; + abort $abort:ident; + allow_fallback $allow_fallback:expr; + atn $atn:expr; + fatal $fatal:path; + marker $marker:ident; + error $error:ident; + bind ($ctx:ident, $consumed_eof:ident, $sync_error:ident); + recovery { $($recovery:tt)* } + propagate { $($propagate:tt)* } + ) => {{ + let $error = if let Some(__sync_error) = $sync_error { + if $allow_fallback { + // The generated attempt is abandoned with a propagated fatal + // error. An authored `finally` still runs first (the generator + // weaves it into this slot), matching ANTLR's try/finally + // ordering when an error escapes the rule. + $($propagate)* + $parser.base.$abort(); $parser .base - .recover_generated_rule(&mut $ctx, $atn, __error); - $($recovery)* - let __tree = $parser.base.$finish($ctx, $consumed_eof); - Ok(__tree) + .rollback_generated_tree($marker); + $parser.base.record_generated_syntax_error(); + return Err($fatal(__sync_error)); } + __sync_error + } else { + $error + }; + $parser + .base + .recover_generated_rule(&mut $ctx, $atn, $error); + $($recovery)* + let __tree = $parser.base.$finish($ctx, $consumed_eof); + Ok(__tree) + }}; + ( + @handle_error (|$catch_bind:ident| { $($catch_body:tt)* }) + parser $parser:ident; + finish $finish:ident; + abort $abort:ident; + allow_fallback $allow_fallback:expr; + atn $atn:expr; + fatal $fatal:path; + marker $marker:ident; + error $error:ident; + bind ($ctx:ident, $consumed_eof:ident, $sync_error:ident); + recovery { $($recovery:tt)* } + propagate { $($propagate:tt)* } + ) => {{ + // An authored `catch` replaces the default report-and-recover handler + // (ANTLR emits the authored clause instead of the generated one), so + // no diagnostic is pushed and no resynchronization happens here. A + // pending loop-sync error is the recognition error the author + // observes, and the fatal propagation path is unreachable because the + // authored handler owns every recognition error. + let _ = &$marker; + let __caught = if let Some(__sync_error) = $sync_error { + __sync_error + } else { + $error + }; + { + let $catch_bind = __caught; + let _ = &$catch_bind; + $($catch_body)* } + $($recovery)* + let __tree = $parser.base.$finish($ctx, $consumed_eof); + Ok(__tree) }}; ( @retry diff --git a/crates/antlr-rust-toml-parser/src/generated/toml_lexer.rs b/crates/antlr-rust-toml-parser/src/generated/toml_lexer.rs index 3c38ae69..a2804798 100644 --- a/crates/antlr-rust-toml-parser/src/generated/toml_lexer.rs +++ b/crates/antlr-rust-toml-parser/src/generated/toml_lexer.rs @@ -1,6 +1,6 @@ // @generated by antlr-rust-codegen v0.34.0 - do not edit // project: https://github.com/ophi-dev/antlr-rust-runtime -antlr4_runtime::__antlr4_rust_require_codegen_api!(15, "0.34.0"); +antlr4_runtime::__antlr4_rust_require_codegen_api!(16, "0.34.0"); #[allow(warnings, missing_docs, clippy::all, clippy::pedantic, clippy::nursery)] #[rustfmt::skip] mod __antlr4_rust_generated { diff --git a/crates/antlr-rust-toml-parser/src/generated/toml_parser.rs b/crates/antlr-rust-toml-parser/src/generated/toml_parser.rs index a159ac85..02a3973b 100644 --- a/crates/antlr-rust-toml-parser/src/generated/toml_parser.rs +++ b/crates/antlr-rust-toml-parser/src/generated/toml_parser.rs @@ -1,6 +1,6 @@ // @generated by antlr-rust-codegen v0.34.0 - do not edit // project: https://github.com/ophi-dev/antlr-rust-runtime -antlr4_runtime::__antlr4_rust_require_codegen_api!(15, "0.34.0"); +antlr4_runtime::__antlr4_rust_require_codegen_api!(16, "0.34.0"); #[allow(warnings, missing_docs, clippy::all, clippy::pedantic, clippy::nursery)] #[rustfmt::skip] mod __antlr4_rust_generated { diff --git a/docs/migration.md b/docs/migration.md index 0d259df7..baadd4d0 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -7,7 +7,32 @@ generated-code API revision that is checked against the selected runtime at compile time, so releases that deliberately preserve the source contract can remain compatible without exact SemVer equality. -The current generator emits revision 15. Generated recognizers embed their +The current generator emits revision 16. Rules with an authored `catch [...]` +or `finally { ... }` clause now expand through two additional +`__antlr4_rust_generated_rule!` lifecycle sections: `exception (...)` (either +`none` or an authored handler `|name| { ... }` that replaces the default +report-and-recover behavior, matching ANTLR's generated catch replacement) and +`propagate { ... }` (the authored `finally` body for the propagated-failure +unwind; the success and recovery paths carry the `finally` body inline). +Neither section runs on the adaptive-retry unwind, whose re-entry executes the +rule from the top. Rules without exception clauses emit the unchanged +four-section form, and the runtime normalizes it to the same defaults, so +revision 12 to 15 recognizers remain compatible with this runtime. Regenerate +with revision 16 to execute authored `catch`/`finally` clauses; earlier +revisions silently dropped them. + +Revision 16 also makes every authored target-code section accountable +(issue #355): `semantics.json` gains a per-grammar `sections` array covering +grammar-level and rule-level named actions plus `catch`/`finally` clauses, +each with a deterministic disposition (`embedded`, `hooked`, or +`unsupported`). Unsupported sections warn by default and fail generation under +`--require-full-semantics` with a source-positioned diagnostic. Embedded +generation now also emits `@header` bodies at the top of the generated module +(before generated imports) and `@definitions` bodies at module scope (after +the `@members` module items), translated with the same token-alias machinery +as `@members`. + +Revision 15 generated recognizers embed their static data tables — the ahead-of-time compiled lexer DFA, the packed parser ATN, and the serialized lexer ATN inside `GrammarMetadata` — as versioned encoded blobs: LEB128 varints (zigzag-mapped for signed values) armored as @@ -32,7 +57,7 @@ Revision 12 to 14 generated recognizers remain compatible because the runtime still implements their source APIs — `GrammarMetadata::new` with integer-array ATN data, `CompiledLexerDfa::from_serialized`, and `ParserAtn::from_static` — and reads packed parser ATN formats 1 through 3. Regenerate them with revision -15 to emit the compact encoded representation. +15 or later to emit the compact encoded representation. Revision 14 generated parsers embed packed parser ATN format 3 with validated tail-call markers on rule transitions. Prediction diff --git a/third_party/antlr-v4-grammar/self-hosted.sha256 b/third_party/antlr-v4-grammar/self-hosted.sha256 index 32705175..95970847 100644 --- a/third_party/antlr-v4-grammar/self-hosted.sha256 +++ b/third_party/antlr-v4-grammar/self-hosted.sha256 @@ -2,5 +2,5 @@ 1286e542499e4480b3ab5ff60e4a4a7faf21134ca4c4f8f1f468f30095fa25cb third_party/antlr-v4-grammar/ANTLRv4Parser.g4 c7114545a75ab294215819962e92e570383dc830fd5768463dab04e6733bcb80 third_party/antlr-v4-grammar/predefined.tokens 5803594bd2c8dd2d5180f1ca08fc70dfc80308479d18a7c4a1b743fa523b55ec third_party/antlr-v4-grammar/antlr-v4.toml -b95d6905f406fcc09ed3a2a06a51a07aa7f4875c953d5c96e43fdd7316183db4 crates/antlr-rust-g4-parser/src/generated/antlr_v4_lexer.rs -386bea362b857cd08b4807f39a8c7d55e42e7cc10aa8f74046a3216e98762286 crates/antlr-rust-g4-parser/src/generated/antlr_v4_parser.rs +4c2837fae70fddb3778656427fffe808bb7a2e0c2ed725a50bd1784be38fb45d crates/antlr-rust-g4-parser/src/generated/antlr_v4_lexer.rs +078c599119c7540ba13aff2935b05cf81a40633c3575d8359786a6fe18d46d5b crates/antlr-rust-g4-parser/src/generated/antlr_v4_parser.rs From 8307a79a1bbd3da0e38e020027415c3638eb04b6 Mon Sep 17 00:00:00 2001 From: Konstantin Vyatkin Date: Sun, 23 Aug 2026 02:52:12 +0200 Subject: [PATCH 2/5] fix(codegen): address review findings on section dispositions and lifecycle - Reject unknown section scopes (@custom::header) instead of inheriting the default scope's supported-name dispositions: no backend consumes them, so reporting them embedded would reintroduce a silent drop. - Restrict typed catch arguments to the catch-all RecognitionException form; a narrowed exception type (FailedPredicateException) would over-catch because the generated handler receives every recognition error. - Reserve rule action names before the empty-body check so a non-empty duplicate @init/@after after an empty first occurrence reports unsupported (only the first occurrence executes). - Treat an empty finally clause as trivially embedded in templates mode, matching empty named actions. - Run authored catch bodies in their own closure so an authored `return` exits only the handler and the finally/seal slot plus rule finalization still run, matching Java's try/catch/finally ordering. - Resolve catch/finally clause spans in embedded translation errors, which previously fell back to an unpositioned message because only named actions were searched. - Stop applying the impl-scope `TParser::` -> `Self::` rewrite to @header / @definitions items, which are emitted at module scope where Self does not exist. - Defer Rust-support section enforcement to the post-loop aggregate so one strict run reports every unsupported section across recognizers. - Document the `translated` section disposition (not `hooked`) in README. --- README.md | 16 +++-- crates/antlr-rust-codegen/src/driver.rs | 14 ++-- .../src/parser/surface/support_abi.rs | 71 +++++++++++-------- .../src/semantics/sections.rs | 50 ++++++++----- .../tests/antlr4_rust_gen_cli/sections.rs | 47 +++++++++++- ...ons__section_audit_semantics_manifest.snap | 12 ++++ ..._section_lifecycle_semantics_manifest.snap | 24 +++++++ crates/antlr-rust-runtime/src/parser.rs | 7 +- 8 files changed, 179 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index 948531a3..609b6e0d 100644 --- a/README.md +++ b/README.md @@ -719,10 +719,11 @@ each grammar's `sections` array: grammar-level named actions (`@header`, `finally { ... }`). Sections have no ATN coordinate, so these rows are what makes them auditable. Each row carries the section kind, scope, owning rule, source position, body, and a disposition: `embedded` (the body is spliced -into generated Rust), `hooked` (`@members` state owned by `[[member]]` -declarations in `--sem-patterns`), or `unsupported`. Unsupported sections -warn on every run and fail generation under `--require-full-semantics` with a -source-positioned diagnostic. +into generated Rust), `translated` (the section's behavior lowers into +generated metadata without splicing the body, e.g. `@members` state owned by +`[[member]]` declarations in `--sem-patterns`), or `unsupported`. Unsupported +sections warn on every run and fail generation under +`--require-full-semantics` with a source-positioned diagnostic. Under `--actions embedded`, supported sections execute: @@ -734,9 +735,10 @@ Under `--actions embedded`, supported sections execute: are currently unsupported. - A single `catch [name] { ... }` clause per rule replaces the default report-and-recover handler, with the recognition error bound to `name` - (a Java-style `catch [Type name]` argument binds the last identifier). The - rule then completes normally, like ANTLR's generated catch replacement. - Multiple catch clauses are unsupported. + (the Java catch-all form `catch [RecognitionException e]` binds `e`; + narrower exception types are rejected because the handler receives every + recognition error). The rule then completes normally, like ANTLR's + generated catch replacement. Multiple catch clauses are unsupported. - `finally { ... }` runs exactly once on every completed path — after `@after` on success, after default recovery or an authored catch handler, and before a fatal propagated error abandons the rule — and never during diff --git a/crates/antlr-rust-codegen/src/driver.rs b/crates/antlr-rust-codegen/src/driver.rs index da926b68..4333752f 100644 --- a/crates/antlr-rust-codegen/src/driver.rs +++ b/crates/antlr-rust-codegen/src/driver.rs @@ -81,6 +81,10 @@ pub(crate) fn generate( let mut grammar_options = Vec::new(); let mut manifest_grammars: Vec<(&'static str, String, Vec, Vec)> = Vec::new(); + // Sections are enforced once, after the loop, so one strict run reports + // every unsupported section; a Rust-support bundle forces the strict gate + // the same way it forces --require-full-semantics. + let mut require_full_sections = args.require_full_semantics; let mut decision_report_grammars: Vec = Vec::new(); let mut rendered_modules = BTreeMap::::new(); let mut emitted_lexers = BTreeSet::new(); @@ -141,9 +145,7 @@ pub(crate) fn generate( report(warning).map_err(Error::generation)?; } warnings.extend(section_warnings); - if support_enabled { - enforce_require_full_sections(true, §ions)?; - } + require_full_sections |= support_enabled; let grammar_name = compiled.semantic.recognizer.name.clone(); let render_model = LexerRenderModel::new( &grammar_name, @@ -205,9 +207,7 @@ pub(crate) fn generate( report(warning).map_err(Error::generation)?; } warnings.extend(section_warnings); - if support_enabled { - enforce_require_full_sections(true, §ions)?; - } + require_full_sections |= support_enabled; let grammar_name = compiled.semantic.recognizer.name.clone(); let (mut module, decision_report_rows) = render_parser_with_decision_report( &grammar_name, @@ -252,7 +252,7 @@ pub(crate) fn generate( .iter() .flat_map(|(_, _, _, sections)| sections.iter().cloned()) .collect::>(); - enforce_require_full_sections(args.require_full_semantics, &all_sections)?; + enforce_require_full_sections(require_full_sections, &all_sections)?; let manifest_policy = if prepared_support.all_roots_supported() { SemUnknownPolicy::Error } else { diff --git a/crates/antlr-rust-codegen/src/parser/surface/support_abi.rs b/crates/antlr-rust-codegen/src/parser/surface/support_abi.rs index e1790137..3025d660 100644 --- a/crates/antlr-rust-codegen/src/parser/surface/support_abi.rs +++ b/crates/antlr-rust-codegen/src/parser/surface/support_abi.rs @@ -515,8 +515,10 @@ pub(crate) fn build_embedded_parser_data( aliases.values.get(name).map(|value| (name.clone(), *value)) }), ); - let item = post_process_embedded(&item.body, &translated.source, type_name); - let _ = writeln!(out_slot, "{item}\n"); + // No `post_process_embedded`: its `TParser::` -> `Self::` rewrite + // targets bodies inside the generated impl, and `Self` does not + // exist at module scope where these items are emitted. + let _ = writeln!(out_slot, "{}\n", translated.source); } } @@ -622,33 +624,44 @@ fn embedded_rule_action_translation_error( rule_name: &str, error: &io::Error, ) -> io::Error { - semantic_rule - .and_then(|semantic_rule| { - semantic_rule - .actions - .iter() - .find(|action| action.name == action_name) - }) - .map_or_else( - || { - io::Error::new( - error.kind(), - format!( - "cannot lower embedded @{action_name} body for parser rule {rule_name} \ - ({rule_index}): {error}" - ), - ) - }, - |action| { - embedded_named_body_translation_error( - data, - &action.body_span, - &format!("parser @{action_name}"), - rule_index, - error, - ) - }, - ) + let label = match action_name { + "catch" => "parser catch clause".to_owned(), + "finally" => "parser finally clause".to_owned(), + other => format!("parser @{other}"), + }; + embedded_rule_section_span(semantic_rule, action_name).map_or_else( + || { + io::Error::new( + error.kind(), + format!( + "cannot lower embedded {label} body for parser rule {rule_name} \ + ({rule_index}): {error}" + ), + ) + }, + |span| embedded_named_body_translation_error(data, span, &label, rule_index, error), + ) +} + +/// The authored source span of one rule-header section: a named action +/// (`@init` / `@after`), the rule's `catch` clause, or its `finally` clause. +fn embedded_rule_section_span<'r>( + semantic_rule: Option<&'r Rule>, + action_name: &str, +) -> Option<&'r SourceSpan> { + let rule = semantic_rule?; + match action_name { + "catch" => rule.catches.first().map(|handler| &handler.body_span), + "finally" => rule + .finally_action + .as_ref() + .map(|action| &action.body_span), + _ => rule + .actions + .iter() + .find(|action| action.name == action_name) + .map(|action| &action.body_span), + } } fn embedded_context_accessor_translation_error( diff --git a/crates/antlr-rust-codegen/src/semantics/sections.rs b/crates/antlr-rust-codegen/src/semantics/sections.rs index 7e6ef896..98f907b6 100644 --- a/crates/antlr-rust-codegen/src/semantics/sections.rs +++ b/crates/antlr-rust-codegen/src/semantics/sections.rs @@ -179,9 +179,11 @@ pub(crate) struct SectionInventoryOptions<'a> { /// Extracts the Rust binding name from an authored `catch [...]` argument. /// -/// Accepts a bare identifier (`catch [error]`) or a Java-style -/// `Type name` pair (`catch [RecognitionException re]`), binding the last -/// identifier-shaped token. Anything else is unsupported. +/// Accepts a bare identifier (`catch [error]`) or the Java catch-all form +/// `catch [RecognitionException e]`, binding the last identifier. Narrower +/// exception types (e.g. `FailedPredicateException`) are rejected: the +/// generated handler receives every recognition error, so accepting a +/// narrowed clause would run it for errors Java's type match skips. pub(crate) fn exception_catch_binding(argument: &str) -> Option { let mut tokens = argument.split_whitespace().rev(); let binding = tokens.next()?; @@ -190,9 +192,14 @@ pub(crate) fn exception_catch_binding(argument: &str) -> Option { .next() .is_some_and(|ch| ch == '_' || ch.is_ascii_alphabetic()) && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()); - // At most one leading type token; longer argument lists are not a - // binding form this backend understands. - (valid && tokens.count() <= 1 && !is_rust_keyword(binding)).then(|| binding.to_owned()) + // An optional leading type token must be the catch-all recognition-error + // base type; the generated handler cannot narrow by exception type. + let type_token = tokens.next(); + (valid + && type_token.is_none_or(|token| token == "RecognitionException") + && tokens.next().is_none() + && !is_rust_keyword(binding)) + .then(|| binding.to_owned()) } /// Inventories every source-owned target-code section visible to one @@ -217,18 +224,23 @@ pub(crate) fn collect_recognizer_sections( }; let mut entries = Vec::new(); for action in &semantic.unit.actions { - let owned = match action.scope.as_deref() { - Some(scope) if scope == recognizer_scope => true, + let (owned, known_scope) = match action.scope.as_deref() { + Some(scope) if scope == recognizer_scope => (true, true), // The other recognizer of this grammar owns the section. - Some("lexer" | "parser") => false, - // Unknown scopes follow the unit's default scope, like unscoped - // sections. - Some(_) | None => options.owns_unscoped_actions, + Some("lexer" | "parser") => (false, true), + None => (options.owns_unscoped_actions, true), + // Unknown scopes follow the unit's default scope for ownership, + // but no backend consumes them, so they can never be embedded. + Some(_) => (options.owns_unscoped_actions, false), }; if !owned { continue; } - let (disposition, note) = grammar_action_disposition(action, recognizer_scope, options); + let (disposition, note) = if known_scope || action.body.trim().is_empty() { + grammar_action_disposition(action, recognizer_scope, options) + } else { + (SectionDisposition::Unsupported, Some(UNKNOWN_SCOPE_NOTE)) + }; entries.push(section_entry_for_action( data, action, @@ -281,7 +293,8 @@ pub(crate) fn collect_recognizer_sections( )); } if let Some(action) = &rule.finally_action { - let disposition = if options.embedded { + // An empty `finally` has no target code to lose in either mode. + let disposition = if options.embedded || action.body.trim().is_empty() { SectionDisposition::Embedded } else { SectionDisposition::Unsupported @@ -327,7 +340,9 @@ const RULE_SECTION_NOTE: &str = "unknown rule action; embedded Rust generation i const MULTIPLE_CATCH_NOTE: &str = "multiple catch clauses are not supported; merge the \ handlers into one clause and match on the bound error value"; const CATCH_ARGUMENT_NOTE: &str = "catch argument must be a Rust identifier (optionally \ - preceded by one type token); the handler receives the recognition error under that name"; + preceded by the catch-all RecognitionException type); the handler receives every \ + recognition error under that name and cannot narrow by exception type"; +const UNKNOWN_SCOPE_NOTE: &str = "unknown section scope; supported scopes are lexer and parser"; fn grammar_action_disposition( action: &grammar::model::NamedAction, @@ -365,10 +380,13 @@ fn rule_action_disposition( embedded: bool, seen_names: &mut BTreeSet, ) -> (SectionDisposition, Option<&'static str>) { + // Even an empty occurrence reserves the name: `structural_embedded_model` + // executes only the *first* `@init` / `@after`, so a later non-empty + // duplicate after an empty first one is still dropped. + let first = seen_names.insert(action.name.clone()); if action.body.trim().is_empty() { return (SectionDisposition::Embedded, None); } - let first = seen_names.insert(action.name.clone()); if !embedded { return (SectionDisposition::Unsupported, Some(TEMPLATES_MODE_NOTE)); } diff --git a/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/sections.rs b/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/sections.rs index 00f8d5f1..e09230ae 100644 --- a/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/sections.rs +++ b/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/sections.rs @@ -57,6 +57,18 @@ catch [RecognitionException e] { crate::record_event("java-style-catch"); } +caughtWithFinally + : A B EOF + ; +catch [error] { + let _ = &error; + crate::record_event("catch-before-finally"); + return; +} +finally { + crate::record_event("finally-after-catch"); +} + A: 'a'; B: 'b'; WS: [ \t\r\n]+ -> skip; @@ -167,6 +179,31 @@ mod section_lifecycle_tests { assert_eq!(parser.number_of_syntax_errors(), 0); assert_eq!(take_events(), ["java-style-catch"]); } + + #[test] + fn finally_runs_after_catch_even_when_the_handler_returns_early() { + // Java runs `finally` after the catch clause, including when the + // catch body returns; an authored `return` exits only the handler. + let mut parser = parser!("aa"); + parser + .caught_with_finally() + .expect("caught rule completes normally"); + assert_eq!(parser.number_of_syntax_errors(), 0); + assert_eq!( + take_events(), + ["catch-before-finally", "finally-after-catch"] + ); + } + + #[test] + fn finally_still_runs_on_success_for_catch_rules() { + let mut parser = parser!("ab"); + parser + .caught_with_finally() + .expect("clean input should parse"); + assert_eq!(parser.number_of_syntax_errors(), 0); + assert_eq!(take_events(), ["finally-after-catch"]); + } } "####; assert_generated_project( @@ -371,6 +408,11 @@ multi: A EOF; catch [first] { let _ = &first; } catch [second] { let _ = &second; } +narrowed: A EOF; +catch [FailedPredicateException fpe] { + let _ = &fpe; +} + A: 'a'; "#, ) @@ -446,7 +488,10 @@ fn require_full_semantics_rejects_unsupported_sections() { "catch argument must be a Rust identifier", "unsupported target-code section: rule multi(1) catch[...] at ", "multiple catch clauses are not supported", - "--require-full-semantics: 5 target-code section(s) would be silently dropped", + // A narrowed exception type would over-catch: the generated handler + // receives every recognition error, not Java's type-matched subset. + "unsupported target-code section: rule narrowed(2) catch[...] at ", + "--require-full-semantics: 6 target-code section(s) would be silently dropped", ] { assert!( stderr.contains(expected), diff --git a/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__sections__section_audit_semantics_manifest.snap b/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__sections__section_audit_semantics_manifest.snap index 0c4ab48f..9586aa0a 100644 --- a/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__sections__section_audit_semantics_manifest.snap +++ b/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__sections__section_audit_semantics_manifest.snap @@ -103,6 +103,18 @@ expression: manifest "column": 0, "body": "let _ = &second;", "disposition": "unsupported" + }, + { + "kind": "catch", + "name": null, + "scope": null, + "rule": "narrowed", + "rule_index": 2, + "source": "SectionAudit.g4", + "line": 28, + "column": 0, + "body": "let _ = &fpe;", + "disposition": "unsupported" } ] } diff --git a/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__sections__section_lifecycle_semantics_manifest.snap b/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__sections__section_lifecycle_semantics_manifest.snap index 3838f302..4572e3d2 100644 --- a/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__sections__section_lifecycle_semantics_manifest.snap +++ b/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__sections__section_lifecycle_semantics_manifest.snap @@ -78,6 +78,30 @@ expression: manifest "column": 0, "body": "let _ = &e; crate::record_event(\"java-style-catch\");", "disposition": "embedded" + }, + { + "kind": "catch", + "name": null, + "scope": null, + "rule": "caughtWithFinally", + "rule_index": 3, + "source": "SectionLifecycle.g4", + "line": 31, + "column": 0, + "body": "let _ = &error; crate::record_event(\"catch-before-finally\"); return;", + "disposition": "embedded" + }, + { + "kind": "finally", + "name": null, + "scope": null, + "rule": "caughtWithFinally", + "rule_index": 3, + "source": "SectionLifecycle.g4", + "line": 36, + "column": 0, + "body": "crate::record_event(\"finally-after-catch\");", + "disposition": "embedded" } ] } diff --git a/crates/antlr-rust-runtime/src/parser.rs b/crates/antlr-rust-runtime/src/parser.rs index 21b84aca..e003e2e3 100644 --- a/crates/antlr-rust-runtime/src/parser.rs +++ b/crates/antlr-rust-runtime/src/parser.rs @@ -391,11 +391,14 @@ macro_rules! __antlr4_rust_generated_rule { } else { $error }; - { + // The handler runs in its own closure so an authored `return` exits + // only the handler — the finally/seal slot and rule finalization + // below still run, matching Java's try/catch/finally ordering. + (|| { let $catch_bind = __caught; let _ = &$catch_bind; $($catch_body)* - } + })(); $($recovery)* let __tree = $parser.base.$finish($ctx, $consumed_eof); Ok(__tree) From c2acede77bec851e8baa29cd34a5495c9ca70560 Mon Sep 17 00:00:00 2001 From: Konstantin Vyatkin Date: Wed, 26 Aug 2026 00:15:04 +0200 Subject: [PATCH 3/5] fix(codegen): scope section strictness and cover orphaned scoped sections - Regenerate the checked-in XPath lexer (and its semantics.json) at generated-code API revision 16; it was the one bundled recognizer the first regeneration pass missed. - Scope section strictness per recognizer, like coordinate and option strictness: a Rust-support bundle's implicit strict gate no longer fails an unrelated templates-mode root, while enforcement still runs once after the loop so a strict run reports every violation. - Inventory sections scoped to a recognizer this invocation does not generate from the same source unit (e.g. @parser::members in a standalone lexer grammar) as unsupported instead of assuming a counterpart collects them; only the lexer half of a split combined grammar delegates its parser-scoped clones. - Validate catch bindings with the XID-aware Rust identifier logic instead of an ASCII approximation, and reject the `_` wildcard (the generated macro cannot bind it). - Run `@after` behind its own boundary when an authored `finally` follows, so an early `return` in the body cannot skip the finally body, the attrs seal, or rule finalization. - Document the `translated` section disposition in docs/migration.md, which still said `hooked`. --- crates/antlr-rust-codegen/src/driver.rs | 40 +++++++++---- .../antlr-rust-codegen/src/generator/tests.rs | 22 +++++++ .../antlr-rust-codegen/src/parser/routing.rs | 12 +++- .../src/semantics/sections.rs | 39 +++++++----- .../tests/antlr4_rust_gen_cli/sections.rs | 59 +++++++++++++++++++ .../src/xpath/generated/semantics.json | 3 +- .../src/xpath/generated/x_path_lexer.rs | 2 +- docs/migration.md | 2 +- 8 files changed, 147 insertions(+), 32 deletions(-) diff --git a/crates/antlr-rust-codegen/src/driver.rs b/crates/antlr-rust-codegen/src/driver.rs index 4333752f..eb040f5f 100644 --- a/crates/antlr-rust-codegen/src/driver.rs +++ b/crates/antlr-rust-codegen/src/driver.rs @@ -81,10 +81,12 @@ pub(crate) fn generate( let mut grammar_options = Vec::new(); let mut manifest_grammars: Vec<(&'static str, String, Vec, Vec)> = Vec::new(); - // Sections are enforced once, after the loop, so one strict run reports - // every unsupported section; a Rust-support bundle forces the strict gate - // the same way it forces --require-full-semantics. - let mut require_full_sections = args.require_full_semantics; + // Section strictness is scoped per recognizer, like coordinate and + // option strictness: --require-full-semantics covers every recognizer, + // and a Rust-support bundle covers only its own recognizers. Enforcement + // runs once, after the loop, so one strict run reports every unsupported + // section instead of stopping at the first failing recognizer. + let mut strict_sections: Vec = Vec::new(); let mut decision_report_grammars: Vec = Vec::new(); let mut rendered_modules = BTreeMap::::new(); let mut emitted_lexers = BTreeSet::new(); @@ -138,6 +140,11 @@ pub(crate) fn generate( embedded: embedded_actions, patterns: &args.sem_patterns, owns_unscoped_actions, + // Parser-scoped sections were cloned into this lexer unit + // only when it was split from a combined grammar — the + // same condition under which the parser owns the + // unscoped sections. + counterpart_covers_scoped: !owns_unscoped_actions, }, )?; let section_warnings = section_warning_messages(§ions); @@ -145,7 +152,9 @@ pub(crate) fn generate( report(warning).map_err(Error::generation)?; } warnings.extend(section_warnings); - require_full_sections |= support_enabled; + if require_full_semantics { + strict_sections.extend(sections.iter().cloned()); + } let grammar_name = compiled.semantic.recognizer.name.clone(); let render_model = LexerRenderModel::new( &grammar_name, @@ -194,12 +203,21 @@ pub(crate) fn generate( )?; enforce_sem_unknown(sem_unknown, &entries)?; enforce_require_full_semantics(require_full_semantics, &entries)?; + // A lexer generated from this same source unit (split combined + // grammar) is the only counterpart that receives this unit's + // lexer-scoped sections. + let counterpart_covers_scoped = root.lexer.is_some_and(|lexer_grammar| { + compilation.lexer(lexer_grammar).is_some_and(|lexer| { + lexer.semantic.unit.source == compiled.semantic.unit.source + }) + }); let sections = collect_recognizer_sections( &data, SectionInventoryOptions { embedded: embedded_actions, patterns: &args.sem_patterns, owns_unscoped_actions: true, + counterpart_covers_scoped, }, )?; let section_warnings = section_warning_messages(§ions); @@ -207,7 +225,9 @@ pub(crate) fn generate( report(warning).map_err(Error::generation)?; } warnings.extend(section_warnings); - require_full_sections |= support_enabled; + if require_full_semantics { + strict_sections.extend(sections.iter().cloned()); + } let grammar_name = compiled.semantic.recognizer.name.clone(); let (mut module, decision_report_rows) = render_parser_with_decision_report( &grammar_name, @@ -246,13 +266,7 @@ pub(crate) fn generate( } warnings.extend(option_warnings); enforce_require_full_options(args.require_full_semantics, &grammar_options)?; - // Aggregated across recognizers so one strict run reports every - // unsupported section, not just the first failing recognizer's. - let all_sections = manifest_grammars - .iter() - .flat_map(|(_, _, _, sections)| sections.iter().cloned()) - .collect::>(); - enforce_require_full_sections(require_full_sections, &all_sections)?; + enforce_require_full_sections(!strict_sections.is_empty(), &strict_sections)?; let manifest_policy = if prepared_support.all_roots_supported() { SemUnknownPolicy::Error } else { diff --git a/crates/antlr-rust-codegen/src/generator/tests.rs b/crates/antlr-rust-codegen/src/generator/tests.rs index 57e016d1..e4b59f08 100644 --- a/crates/antlr-rust-codegen/src/generator/tests.rs +++ b/crates/antlr-rust-codegen/src/generator/tests.rs @@ -5908,6 +5908,28 @@ fn indexed_action_overrides_precede_portable_boolean_lowering() { } } +#[test] +fn exception_catch_bindings_follow_rust_identifier_rules() { + use crate::semantics::exception_catch_binding; + assert_eq!(exception_catch_binding("error").as_deref(), Some("error")); + assert_eq!(exception_catch_binding(" error ").as_deref(), Some("error")); + // The Java catch-all form binds the identifier; narrower exception types + // would over-catch (the handler receives every recognition error). + assert_eq!( + exception_catch_binding("RecognitionException e").as_deref(), + Some("e") + ); + assert_eq!(exception_catch_binding("FailedPredicateException e"), None); + // Rust identifiers are XID-based, not ASCII-only. + assert_eq!(exception_catch_binding("é").as_deref(), Some("é")); + // `_` is a wildcard pattern, not an identifier the macro can bind. + assert_eq!(exception_catch_binding("_"), None); + assert_eq!(exception_catch_binding("fn"), None); + assert_eq!(exception_catch_binding("a b c"), None); + assert_eq!(exception_catch_binding(""), None); + assert_eq!(exception_catch_binding("1x"), None); +} + #[test] fn semantics_manifest_renders_empty_inventory() { let manifest = render_semantics_manifest( diff --git a/crates/antlr-rust-codegen/src/parser/routing.rs b/crates/antlr-rust-codegen/src/parser/routing.rs index 274cc20e..364f7f2c 100644 --- a/crates/antlr-rust-codegen/src/parser/routing.rs +++ b/crates/antlr-rust-codegen/src/parser/routing.rs @@ -466,7 +466,17 @@ pub(crate) fn render_embedded_after_and_seal( let pad = " ".repeat(indent); if run_after { if let Some(after) = embedded.after.get(&rule_index) { - writeln!(out, "{pad}{after}").expect("writing to a string cannot fail"); + if embedded.finally_bodies.contains_key(&rule_index) { + // With an authored `finally` following, `@after` runs behind + // its own boundary so an early `return` in the body cannot + // skip the finally body, the attrs seal, or rule + // finalization (Java's try/finally ordering). + writeln!(out, "{pad}(|| {{").expect("writing to a string cannot fail"); + writeln!(out, "{pad}{after}").expect("writing to a string cannot fail"); + writeln!(out, "{pad}}})();").expect("writing to a string cannot fail"); + } else { + writeln!(out, "{pad}{after}").expect("writing to a string cannot fail"); + } } } if let Some(finally_body) = embedded.finally_bodies.get(&rule_index) { diff --git a/crates/antlr-rust-codegen/src/semantics/sections.rs b/crates/antlr-rust-codegen/src/semantics/sections.rs index 98f907b6..511a3cbe 100644 --- a/crates/antlr-rust-codegen/src/semantics/sections.rs +++ b/crates/antlr-rust-codegen/src/semantics/sections.rs @@ -175,6 +175,12 @@ pub(crate) struct SectionInventoryOptions<'a> { /// lexer half of a split combined grammar, whose unscoped actions belong /// to the parser (ANTLR's default scope for combined grammars). pub(crate) owns_unscoped_actions: bool, + /// Whether sections explicitly scoped to the *other* recognizer are + /// collected by a counterpart generated from the same source unit. False + /// when no counterpart exists (or it comes from a different grammar + /// file), in which case those sections are inventoried here as + /// unsupported instead of silently vanishing. + pub(crate) counterpart_covers_scoped: bool, } /// Extracts the Rust binding name from an authored `catch [...]` argument. @@ -187,11 +193,8 @@ pub(crate) struct SectionInventoryOptions<'a> { pub(crate) fn exception_catch_binding(argument: &str) -> Option { let mut tokens = argument.split_whitespace().rev(); let binding = tokens.next()?; - let mut chars = binding.chars(); - let valid = chars - .next() - .is_some_and(|ch| ch == '_' || ch.is_ascii_alphabetic()) - && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()); + let valid = binding != "_" + && crate::rust_output::rust_identifier_end(binding, 0) == Some(binding.len()); // An optional leading type token must be the catch-all recognition-error // base type; the generated handler cannot narrow by exception type. let type_token = tokens.next(); @@ -224,22 +227,26 @@ pub(crate) fn collect_recognizer_sections( }; let mut entries = Vec::new(); for action in &semantic.unit.actions { - let (owned, known_scope) = match action.scope.as_deref() { - Some(scope) if scope == recognizer_scope => (true, true), - // The other recognizer of this grammar owns the section. - Some("lexer" | "parser") => (false, true), - None => (options.owns_unscoped_actions, true), + let empty = action.body.trim().is_empty(); + let (owned, forced_note) = match action.scope.as_deref() { + Some(scope) if scope == recognizer_scope => (true, None), + // The other recognizer of this grammar owns the section — but + // only when this invocation generates that recognizer from the + // same source unit (a split combined grammar clones the actions + // into both halves). Otherwise nothing would ever consume it. + Some("lexer" | "parser") if options.counterpart_covers_scoped => (false, None), + Some("lexer" | "parser") => (true, Some(NO_COUNTERPART_NOTE)), + None => (options.owns_unscoped_actions, None), // Unknown scopes follow the unit's default scope for ownership, // but no backend consumes them, so they can never be embedded. - Some(_) => (options.owns_unscoped_actions, false), + Some(_) => (options.owns_unscoped_actions, Some(UNKNOWN_SCOPE_NOTE)), }; if !owned { continue; } - let (disposition, note) = if known_scope || action.body.trim().is_empty() { - grammar_action_disposition(action, recognizer_scope, options) - } else { - (SectionDisposition::Unsupported, Some(UNKNOWN_SCOPE_NOTE)) + let (disposition, note) = match forced_note { + Some(note) if !empty => (SectionDisposition::Unsupported, Some(note)), + _ => grammar_action_disposition(action, recognizer_scope, options), }; entries.push(section_entry_for_action( data, @@ -343,6 +350,8 @@ const CATCH_ARGUMENT_NOTE: &str = "catch argument must be a Rust identifier (opt preceded by the catch-all RecognitionException type); the handler receives every \ recognition error under that name and cannot narrow by exception type"; const UNKNOWN_SCOPE_NOTE: &str = "unknown section scope; supported scopes are lexer and parser"; +const NO_COUNTERPART_NOTE: &str = "scoped to a recognizer this invocation does not generate from \ + this grammar; the section is never emitted"; fn grammar_action_disposition( action: &grammar::model::NamedAction, diff --git a/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/sections.rs b/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/sections.rs index e09230ae..9c10cba9 100644 --- a/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/sections.rs +++ b/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/sections.rs @@ -577,6 +577,65 @@ A: 'a'; } } +/// A section scoped to a recognizer this invocation never generates from the +/// grammar (here: `@parser::members` in a standalone lexer grammar) cannot +/// silently vanish — it is inventoried as unsupported by the recognizer that +/// carries it. +#[test] +fn cross_scoped_sections_without_a_counterpart_are_unsupported() { + let temp = temporary_directory("section-no-counterpart"); + let grammar = temp.path().join("SoloLexer.g4"); + let out = temp.path().join("generated"); + fs::write( + &grammar, + "lexer grammar SoloLexer;\n\n@parser::members {\n fn orphaned(&mut self) {}\n}\n\nA: 'a';\n", + ) + .expect("grammar should be writable"); + + let output = run_antlr4_rust_gen(&[ + grammar.as_os_str(), + OsStr::new("--actions"), + OsStr::new("embedded"), + OsStr::new("--out-dir"), + out.as_os_str(), + ]); + assert!( + output.status.success(), + "stdout: {}\nstderr: {}", + utf8(&output.stdout), + utf8(&output.stderr) + ); + let stderr = utf8(&output.stderr); + for expected in [ + "warning: unsupported target-code section: @parser::members at ", + "scoped to a recognizer this invocation does not generate", + ] { + assert!( + stderr.contains(expected), + "missing {expected:?} in {stderr}" + ); + } + + let strict_out = temp.path().join("generated-strict"); + let output = run_antlr4_rust_gen(&[ + grammar.as_os_str(), + OsStr::new("--actions"), + OsStr::new("embedded"), + OsStr::new("--require-full-semantics"), + OsStr::new("--out-dir"), + strict_out.as_os_str(), + ]); + assert!( + !output.status.success(), + "orphaned cross-scoped sections must fail strict generation" + ); + assert!( + utf8(&output.stderr).contains("unsupported target-code section: @parser::members at "), + "stderr: {}", + utf8(&output.stderr) + ); +} + /// Sections survive import resolution: an imported grammar's `@members` and a /// cloned rule's `finally` clause stay inventoried (and executed) in the /// importing recognizer, attributed to the imported source file. diff --git a/crates/antlr-rust-runtime/src/xpath/generated/semantics.json b/crates/antlr-rust-runtime/src/xpath/generated/semantics.json index 7979b264..5e6a08a1 100644 --- a/crates/antlr-rust-runtime/src/xpath/generated/semantics.json +++ b/crates/antlr-rust-runtime/src/xpath/generated/semantics.json @@ -7,7 +7,8 @@ { "kind": "lexer", "name": "XPathLexer", - "coordinates": [] + "coordinates": [], + "sections": [] } ] } diff --git a/crates/antlr-rust-runtime/src/xpath/generated/x_path_lexer.rs b/crates/antlr-rust-runtime/src/xpath/generated/x_path_lexer.rs index 7e8fa846..a48be6a4 100644 --- a/crates/antlr-rust-runtime/src/xpath/generated/x_path_lexer.rs +++ b/crates/antlr-rust-runtime/src/xpath/generated/x_path_lexer.rs @@ -1,6 +1,6 @@ // @generated by antlr-rust-codegen v0.34.0 - do not edit // project: https://github.com/ophi-dev/antlr-rust-runtime -antlr4_runtime::__antlr4_rust_require_codegen_api!(15, "0.34.0"); +antlr4_runtime::__antlr4_rust_require_codegen_api!(16, "0.34.0"); #[allow(warnings, missing_docs, clippy::all, clippy::pedantic, clippy::nursery)] #[rustfmt::skip] mod __antlr4_rust_generated { diff --git a/docs/migration.md b/docs/migration.md index baadd4d0..fa73769a 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -24,7 +24,7 @@ revisions silently dropped them. Revision 16 also makes every authored target-code section accountable (issue #355): `semantics.json` gains a per-grammar `sections` array covering grammar-level and rule-level named actions plus `catch`/`finally` clauses, -each with a deterministic disposition (`embedded`, `hooked`, or +each with a deterministic disposition (`embedded`, `translated`, or `unsupported`). Unsupported sections warn by default and fail generation under `--require-full-semantics` with a source-positioned diagnostic. Embedded generation now also emits `@header` bodies at the top of the generated module From 19d2f251c96e596650b92c9d24b84025f5c06a0b Mon Sep 17 00:00:00 2001 From: Konstantin Vyatkin Date: Thu, 27 Aug 2026 14:15:20 +0200 Subject: [PATCH 4/5] fix(codegen): keep catch bindings grammar-agnostic and exits loud - Drop the Java-specific `RecognitionException` special case from catch argument handling: the section inventory is generic codegen and must not model target-language type names (AGENTS.md codegen boundary). A catch argument is now exactly one plain or raw Rust identifier; every typed clause reports unsupported with remediation. - Accept raw identifiers (`catch [r#type]`) via the XID-aware validator, rejecting only the path keywords `r#` cannot rescue. - Bind the catch-handler and finally-guarded `@after` closures with `let () = ...` so a value-carrying exit (`?`, `return expr`, trailing expression) is a compile error instead of a silently discarded value; section bodies are infallible statements by contract. - Document that `[[member]]` declarations are the caller's explicit acknowledgment that the pattern file owns a recognizer's `@members` state (bodies are replaced wholesale, not partially matched), mirroring how --option-hook acknowledges option behavior. --- README.md | 17 +++++--- .../antlr-rust-codegen/src/generator/tests.rs | 16 +++---- .../antlr-rust-codegen/src/parser/routing.rs | 6 ++- .../src/semantics/sections.rs | 42 ++++++++++--------- .../tests/antlr4_rust_gen_cli/sections.rs | 24 +++++------ ..._section_lifecycle_semantics_manifest.snap | 4 +- crates/antlr-rust-runtime/src/parser.rs | 7 +++- docs/migration.md | 3 +- 8 files changed, 66 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 609b6e0d..31ba8f2c 100644 --- a/README.md +++ b/README.md @@ -721,8 +721,12 @@ makes them auditable. Each row carries the section kind, scope, owning rule, source position, body, and a disposition: `embedded` (the body is spliced into generated Rust), `translated` (the section's behavior lowers into generated metadata without splicing the body, e.g. `@members` state owned by -`[[member]]` declarations in `--sem-patterns`), or `unsupported`. Unsupported -sections warn on every run and fail generation under +`[[member]]` declarations in `--sem-patterns`), or `unsupported`. Declaring +`[[member]]` slots for a recognizer is the caller's explicit acknowledgment +that the pattern file owns that recognizer's `@members` state: the +target-language body is replaced wholesale, not parsed or partially matched, +exactly like `--option-hook` acknowledges an option's target behavior. +Unsupported sections warn on every run and fail generation under `--require-full-semantics` with a source-positioned diagnostic. Under `--actions embedded`, supported sections execute: @@ -735,10 +739,11 @@ Under `--actions embedded`, supported sections execute: are currently unsupported. - A single `catch [name] { ... }` clause per rule replaces the default report-and-recover handler, with the recognition error bound to `name` - (the Java catch-all form `catch [RecognitionException e]` binds `e`; - narrower exception types are rejected because the handler receives every - recognition error). The rule then completes normally, like ANTLR's - generated catch replacement. Multiple catch clauses are unsupported. + (a plain or raw Rust identifier). Typed clauses such as + `catch [RecognitionException e]` are rejected: the handler receives every + recognition error, and this backend models no target-language exception + types. The rule then completes normally, like ANTLR's generated catch + replacement. Multiple catch clauses are unsupported. - `finally { ... }` runs exactly once on every completed path — after `@after` on success, after default recovery or an authored catch handler, and before a fatal propagated error abandons the rule — and never during diff --git a/crates/antlr-rust-codegen/src/generator/tests.rs b/crates/antlr-rust-codegen/src/generator/tests.rs index e4b59f08..1796ac7b 100644 --- a/crates/antlr-rust-codegen/src/generator/tests.rs +++ b/crates/antlr-rust-codegen/src/generator/tests.rs @@ -5913,15 +5913,15 @@ fn exception_catch_bindings_follow_rust_identifier_rules() { use crate::semantics::exception_catch_binding; assert_eq!(exception_catch_binding("error").as_deref(), Some("error")); assert_eq!(exception_catch_binding(" error ").as_deref(), Some("error")); - // The Java catch-all form binds the identifier; narrower exception types - // would over-catch (the handler receives every recognition error). - assert_eq!( - exception_catch_binding("RecognitionException e").as_deref(), - Some("e") - ); - assert_eq!(exception_catch_binding("FailedPredicateException e"), None); - // Rust identifiers are XID-based, not ASCII-only. + // Rust identifiers are XID-based, not ASCII-only, and raw identifiers + // are bindable except for the path keywords `r#` cannot rescue. assert_eq!(exception_catch_binding("é").as_deref(), Some("é")); + assert_eq!(exception_catch_binding("r#type").as_deref(), Some("r#type")); + assert_eq!(exception_catch_binding("r#self"), None); + // Java-style typed clauses cannot narrow by exception type (the handler + // receives every recognition error), so no type token is recognized. + assert_eq!(exception_catch_binding("RecognitionException e"), None); + assert_eq!(exception_catch_binding("FailedPredicateException e"), None); // `_` is a wildcard pattern, not an identifier the macro can bind. assert_eq!(exception_catch_binding("_"), None); assert_eq!(exception_catch_binding("fn"), None); diff --git a/crates/antlr-rust-codegen/src/parser/routing.rs b/crates/antlr-rust-codegen/src/parser/routing.rs index 364f7f2c..189b8264 100644 --- a/crates/antlr-rust-codegen/src/parser/routing.rs +++ b/crates/antlr-rust-codegen/src/parser/routing.rs @@ -470,8 +470,10 @@ pub(crate) fn render_embedded_after_and_seal( // With an authored `finally` following, `@after` runs behind // its own boundary so an early `return` in the body cannot // skip the finally body, the attrs seal, or rule - // finalization (Java's try/finally ordering). - writeln!(out, "{pad}(|| {{").expect("writing to a string cannot fail"); + // finalization (Java's try/finally ordering). The `let ()` + // binding makes any value-carrying exit a compile error + // instead of a silent discard. + writeln!(out, "{pad}let () = (|| {{").expect("writing to a string cannot fail"); writeln!(out, "{pad}{after}").expect("writing to a string cannot fail"); writeln!(out, "{pad}}})();").expect("writing to a string cannot fail"); } else { diff --git a/crates/antlr-rust-codegen/src/semantics/sections.rs b/crates/antlr-rust-codegen/src/semantics/sections.rs index 511a3cbe..4de37bda 100644 --- a/crates/antlr-rust-codegen/src/semantics/sections.rs +++ b/crates/antlr-rust-codegen/src/semantics/sections.rs @@ -185,24 +185,26 @@ pub(crate) struct SectionInventoryOptions<'a> { /// Extracts the Rust binding name from an authored `catch [...]` argument. /// -/// Accepts a bare identifier (`catch [error]`) or the Java catch-all form -/// `catch [RecognitionException e]`, binding the last identifier. Narrower -/// exception types (e.g. `FailedPredicateException`) are rejected: the -/// generated handler receives every recognition error, so accepting a -/// narrowed clause would run it for errors Java's type match skips. +/// The argument must be a single Rust identifier (raw identifiers included): +/// the generated handler receives every recognition error under that name. +/// Java-style typed clauses are rejected as unsupported — the handler cannot +/// narrow by exception type, and this backend models no target-language type +/// names. pub(crate) fn exception_catch_binding(argument: &str) -> Option { - let mut tokens = argument.split_whitespace().rev(); - let binding = tokens.next()?; - let valid = binding != "_" - && crate::rust_output::rust_identifier_end(binding, 0) == Some(binding.len()); - // An optional leading type token must be the catch-all recognition-error - // base type; the generated handler cannot narrow by exception type. - let type_token = tokens.next(); - (valid - && type_token.is_none_or(|token| token == "RecognitionException") - && tokens.next().is_none() - && !is_rust_keyword(binding)) - .then(|| binding.to_owned()) + let binding = argument.trim(); + let (name, raw) = binding + .strip_prefix("r#") + .map_or((binding, false), |name| (name, true)); + // `_` is a wildcard pattern, not an identifier the macro can bind. + let shaped = name != "_" + && crate::rust_output::rust_identifier_end(name, 0) == Some(name.len()); + let bindable = if raw { + // `r#` cannot make path keywords bindable. + !matches!(name, "crate" | "self" | "super" | "Self") + } else { + !is_rust_keyword(name) + }; + (shaped && bindable).then(|| binding.to_owned()) } /// Inventories every source-owned target-code section visible to one @@ -346,9 +348,9 @@ const RULE_SECTION_NOTE: &str = "unknown rule action; embedded Rust generation i @init and @after at rule scope"; const MULTIPLE_CATCH_NOTE: &str = "multiple catch clauses are not supported; merge the \ handlers into one clause and match on the bound error value"; -const CATCH_ARGUMENT_NOTE: &str = "catch argument must be a Rust identifier (optionally \ - preceded by the catch-all RecognitionException type); the handler receives every \ - recognition error under that name and cannot narrow by exception type"; +const CATCH_ARGUMENT_NOTE: &str = "catch argument must be a single Rust identifier (e.g. \ + catch [error]); the handler receives every recognition error under that name, and typed \ + clauses cannot narrow by exception type"; const UNKNOWN_SCOPE_NOTE: &str = "unknown section scope; supported scopes are lexer and parser"; const NO_COUNTERPART_NOTE: &str = "scoped to a recognizer this invocation does not generate from \ this grammar; the section is never emitted"; diff --git a/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/sections.rs b/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/sections.rs index 9c10cba9..283c7471 100644 --- a/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/sections.rs +++ b/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/sections.rs @@ -49,12 +49,12 @@ catch [error] { crate::record_event("catch"); } -javaStyle +rawIdent : A B EOF ; -catch [RecognitionException e] { - let _ = &e; - crate::record_event("java-style-catch"); +catch [r#type] { + let _ = &r#type; + crate::record_event("raw-identifier-catch"); } caughtWithFinally @@ -101,8 +101,8 @@ WS: [ \t\r\n]+ -> skip; "missing authored catch handler:\n{parser}" ); assert!( - parser.contains("exception (|e| {"), - "Java-style catch argument should bind its last identifier:\n{parser}" + parser.contains("exception (|r#type| {"), + "raw-identifier catch argument should bind verbatim:\n{parser}" ); let test_source = r####" @@ -173,11 +173,11 @@ mod section_lifecycle_tests { } #[test] - fn java_style_catch_argument_binds_last_identifier() { + fn raw_identifier_catch_argument_binds_verbatim() { let mut parser = parser!("aa"); - parser.java_style().expect("caught rule completes normally"); + parser.raw_ident().expect("caught rule completes normally"); assert_eq!(parser.number_of_syntax_errors(), 0); - assert_eq!(take_events(), ["java-style-catch"]); + assert_eq!(take_events(), ["raw-identifier-catch"]); } #[test] @@ -485,11 +485,11 @@ fn require_full_semantics_rejects_unsupported_sections() { "lexer-scoped sections are not implemented in embedded mode", "unsupported target-code section: @parser::tokenfactory at ", "unsupported target-code section: rule start(0) catch[...] at ", - "catch argument must be a Rust identifier", + "catch argument must be a single Rust identifier", "unsupported target-code section: rule multi(1) catch[...] at ", "multiple catch clauses are not supported", - // A narrowed exception type would over-catch: the generated handler - // receives every recognition error, not Java's type-matched subset. + // Typed clauses cannot narrow: the generated handler receives every + // recognition error, so they are rejected rather than mistranslated. "unsupported target-code section: rule narrowed(2) catch[...] at ", "--require-full-semantics: 6 target-code section(s) would be silently dropped", ] { diff --git a/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__sections__section_lifecycle_semantics_manifest.snap b/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__sections__section_lifecycle_semantics_manifest.snap index 4572e3d2..fe35ca6c 100644 --- a/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__sections__section_lifecycle_semantics_manifest.snap +++ b/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__sections__section_lifecycle_semantics_manifest.snap @@ -71,12 +71,12 @@ expression: manifest "kind": "catch", "name": null, "scope": null, - "rule": "javaStyle", + "rule": "rawIdent", "rule_index": 2, "source": "SectionLifecycle.g4", "line": 23, "column": 0, - "body": "let _ = &e; crate::record_event(\"java-style-catch\");", + "body": "let _ = &r#type; crate::record_event(\"raw-identifier-catch\");", "disposition": "embedded" }, { diff --git a/crates/antlr-rust-runtime/src/parser.rs b/crates/antlr-rust-runtime/src/parser.rs index e003e2e3..0542ec6a 100644 --- a/crates/antlr-rust-runtime/src/parser.rs +++ b/crates/antlr-rust-runtime/src/parser.rs @@ -393,8 +393,11 @@ macro_rules! __antlr4_rust_generated_rule { }; // The handler runs in its own closure so an authored `return` exits // only the handler — the finally/seal slot and rule finalization - // below still run, matching Java's try/catch/finally ordering. - (|| { + // below still run, matching Java's try/catch/finally ordering. The + // `let ()` binding makes any value-carrying exit (`?`, `return expr`, + // a trailing expression) a compile error instead of a silent discard: + // handler bodies are infallible statements. + let () = (|| { let $catch_bind = __caught; let _ = &$catch_bind; $($catch_body)* diff --git a/docs/migration.md b/docs/migration.md index fa73769a..d9c9c23f 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -11,7 +11,8 @@ The current generator emits revision 16. Rules with an authored `catch [...]` or `finally { ... }` clause now expand through two additional `__antlr4_rust_generated_rule!` lifecycle sections: `exception (...)` (either `none` or an authored handler `|name| { ... }` that replaces the default -report-and-recover behavior, matching ANTLR's generated catch replacement) and +report-and-recover behavior, matching ANTLR's generated catch replacement; +`name` is the single Rust identifier from the catch argument) and `propagate { ... }` (the authored `finally` body for the propagated-failure unwind; the success and recovery paths carry the `finally` body inline). Neither section runs on the adaptive-retry unwind, whose re-entry executes the From fe40c2d046348c21c512d56ec00c950ee710106b Mon Sep 17 00:00:00 2001 From: Konstantin Vyatkin Date: Thu, 27 Aug 2026 14:42:39 +0200 Subject: [PATCH 5/5] fix(codegen): give finally bodies the same authored-exit boundary Authored `finally` bodies are now emitted behind `let () = (|| { ... })();` in every slot they are woven into (success, recovery, and the propagated-failure `propagate` slot), matching the catch-handler and finally-guarded `@after` boundaries: an early `return` exits only the body (the attrs seal, rule finalization, and the macro's abort/rollback steps still run), and a value-carrying exit (`?`, `return expr`, a trailing expression) is a compile error instead of a silently discarded value or a skipped teardown. The lifecycle test's `finally` body now ends with `return;` to pin the boundary on all three paths. --- crates/antlr-rust-codegen/src/parser/render/rules.rs | 5 +++++ crates/antlr-rust-codegen/src/parser/routing.rs | 5 +++++ .../tests/antlr4_rust_gen_cli/sections.rs | 1 + ...sections__section_lifecycle_semantics_manifest.snap | 10 +++++----- 4 files changed, 16 insertions(+), 5 deletions(-) diff --git a/crates/antlr-rust-codegen/src/parser/render/rules.rs b/crates/antlr-rust-codegen/src/parser/render/rules.rs index 1d6f4f97..a571c36a 100644 --- a/crates/antlr-rust-codegen/src/parser/render/rules.rs +++ b/crates/antlr-rust-codegen/src/parser/render/rules.rs @@ -120,11 +120,16 @@ fn render_generated_rule_exception_sections( } // `finally` runs on the propagated-failure path too: the recovery/success // sections cover completed parses, and this slot covers the fatal unwind. + // The closure boundary keeps authored exits local to the body so the + // macro's abort/rollback/diagnostic steps always run. match finally_body { Some(finally_body) => { writeln!(out, " propagate {{").expect("writing to a string cannot fail"); + writeln!(out, " let () = (|| {{") + .expect("writing to a string cannot fail"); writeln!(out, " {finally_body}") .expect("writing to a string cannot fail"); + writeln!(out, " }})();").expect("writing to a string cannot fail"); writeln!(out, " }};").expect("writing to a string cannot fail"); } None => { diff --git a/crates/antlr-rust-codegen/src/parser/routing.rs b/crates/antlr-rust-codegen/src/parser/routing.rs index 189b8264..c0e1aa16 100644 --- a/crates/antlr-rust-codegen/src/parser/routing.rs +++ b/crates/antlr-rust-codegen/src/parser/routing.rs @@ -482,7 +482,12 @@ pub(crate) fn render_embedded_after_and_seal( } } if let Some(finally_body) = embedded.finally_bodies.get(&rule_index) { + // Same boundary as the catch handler: an early `return` exits only + // the `finally` body (the seal and rule finalization still run), and + // a value-carrying exit is a compile error. + writeln!(out, "{pad}let () = (|| {{").expect("writing to a string cannot fail"); writeln!(out, "{pad}{finally_body}").expect("writing to a string cannot fail"); + writeln!(out, "{pad}}})();").expect("writing to a string cannot fail"); } if embedded .rule_has_attrs diff --git a/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/sections.rs b/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/sections.rs index 283c7471..3c41d169 100644 --- a/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/sections.rs +++ b/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/sections.rs @@ -39,6 +39,7 @@ start ; finally { crate::record_event("finally"); + return; } caught diff --git a/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__sections__section_lifecycle_semantics_manifest.snap b/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__sections__section_lifecycle_semantics_manifest.snap index fe35ca6c..88850aff 100644 --- a/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__sections__section_lifecycle_semantics_manifest.snap +++ b/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__sections__section_lifecycle_semantics_manifest.snap @@ -52,7 +52,7 @@ expression: manifest "source": "SectionLifecycle.g4", "line": 8, "column": 0, - "body": "crate::record_event(\"finally\");", + "body": "crate::record_event(\"finally\"); return;", "disposition": "embedded" }, { @@ -62,7 +62,7 @@ expression: manifest "rule": "caught", "rule_index": 1, "source": "SectionLifecycle.g4", - "line": 15, + "line": 16, "column": 0, "body": "let _ = &error; crate::record_event(\"catch\");", "disposition": "embedded" @@ -74,7 +74,7 @@ expression: manifest "rule": "rawIdent", "rule_index": 2, "source": "SectionLifecycle.g4", - "line": 23, + "line": 24, "column": 0, "body": "let _ = &r#type; crate::record_event(\"raw-identifier-catch\");", "disposition": "embedded" @@ -86,7 +86,7 @@ expression: manifest "rule": "caughtWithFinally", "rule_index": 3, "source": "SectionLifecycle.g4", - "line": 31, + "line": 32, "column": 0, "body": "let _ = &error; crate::record_event(\"catch-before-finally\"); return;", "disposition": "embedded" @@ -98,7 +98,7 @@ expression: manifest "rule": "caughtWithFinally", "rule_index": 3, "source": "SectionLifecycle.g4", - "line": 36, + "line": 37, "column": 0, "body": "crate::record_event(\"finally-after-catch\");", "disposition": "embedded"