From 8c71f751a022e2b79afc14c16b33f905d9d26c7d Mon Sep 17 00:00:00 2001 From: Konstantin Vyatkin Date: Wed, 19 Aug 2026 16:40:53 +0200 Subject: [PATCH 1/3] feat(codegen): inline trivial pure parser rules before ATN construction Add the opt-in, recognition-preserving `inline-trivial-rules` transform (`--inline-trivial-rules` / `--report-trivial-rules`, canonical order 150 between unreachable-rule pruning and precedence-ladder collapse). Two candidate classes are rewritten so the caller's decision sees the actual tokens instead of a rule transition, then the callee is removed: - token-set rules: a body that is only an alternation of single terminals is flattened into the referencing element as a token set, at any number of call sites; expansion is bounded by construction at one element per site. - single-use pure sequences: a single-alternative rule referenced exactly once moves into its call site as a parenthesized block. Candidates are inlined all-or-nothing and fail closed: configured or inferred entry rules, recursive, nullable, or target-code-observed rules, and bodies or call sites carrying labels, attributes, actions, predicates, options, arguments, or pinned precedence are declined with a recorded reason. Discovery re-runs after each accepted rewrite, so alias chains collapse in one invocation while every application removes exactly one rule, keeping the pass deterministic, idempotent, and free of composed growth. optimizations.json now records each candidate's rewritten call sites (`inlinedCallSites`) with original source spans beside status, reason, and removed rules. Motivated by the Atfinity grammar-optimization study, whose largest single win came from removing keyword-rule indirection. Because this generator builds the ATN natively after optional transforms, the flattened sets reach closure and prediction directly. Shared transform plumbing is extracted for reuse: TransformCloner and the rule/block tombstone helpers move to grammar/transform/clone.rs, and observed_rule_contexts/visit_elements move to transform/analysis.rs, with the precedence-ladder and prune passes rebased onto them. Verified: workspace tests (1500) and pedantic clippy are clean; the runtime testsuite passes 357/357 with zero skips both by default and with the pass forced on via ANTLR4_RUST_GEN_EXTRA_ARGS, where it applied in 5 descriptor grammars (including three ParserErrors recovery cases) with byte-identical upstream output and declined in 74; a CLI differential test compiles baseline and optimized parsers from one fixture and proves valid/invalid-input recognition parity alongside the intended generated-API changes. Refs #130 --- README.md | 49 + crates/antlr-rust-codegen/src/builder.rs | 21 + crates/antlr-rust-codegen/src/cli.rs | 10 + crates/antlr-rust-codegen/src/config.rs | 4 + .../src/grammar/transform/analysis.rs | 147 +++ .../src/grammar/transform/artifact.rs | 32 +- .../src/grammar/transform/clone.rs | 172 ++++ .../src/grammar/transform/mod.rs | 12 + .../transform/passes/inline_trivial.rs | 914 ++++++++++++++++++ .../transform/passes/precedence_ladder.rs | 301 +----- .../transform/passes/prune_unreachable.rs | 54 +- ...ts__alias_chain_shapes_and_candidates.snap | 13 + ...ial__tests__configured_entry_declines.snap | 7 + ...al__tests__labeled_call_site_declines.snap | 7 + ...ests__nullable_and_recursive_declines.snap | 8 + ...l__tests__opaque_target_code_declines.snap | 7 + ...ngle_use_inline_shapes_and_candidates.snap | 13 + ...oken_set_inline_shapes_and_candidates.snap | 13 + ...ce_ladder__tests__cel_ladder_collapse.snap | 2 +- .../src/optimization/config.rs | 51 +- .../src/optimization/descriptor.rs | 10 + crates/antlr-rust-codegen/src/testrig_cli.rs | 2 + ...st_gen_cli__cli__antlr4_rust_gen_help.snap | 4 + ..._trivial_inline_optimization_manifest.snap | 332 +++++++ .../tests/antlr4_rust_gen_cli/transforms.rs | 238 +++++ .../antlr4-rust-gen/trivial-inline/Inline.g4 | 60 ++ 26 files changed, 2168 insertions(+), 315 deletions(-) create mode 100644 crates/antlr-rust-codegen/src/grammar/transform/clone.rs create mode 100644 crates/antlr-rust-codegen/src/grammar/transform/passes/inline_trivial.rs create mode 100644 crates/antlr-rust-codegen/src/grammar/transform/passes/snapshots/antlr_rust_codegen__grammar__transform__passes__inline_trivial__tests__alias_chain_shapes_and_candidates.snap create mode 100644 crates/antlr-rust-codegen/src/grammar/transform/passes/snapshots/antlr_rust_codegen__grammar__transform__passes__inline_trivial__tests__configured_entry_declines.snap create mode 100644 crates/antlr-rust-codegen/src/grammar/transform/passes/snapshots/antlr_rust_codegen__grammar__transform__passes__inline_trivial__tests__labeled_call_site_declines.snap create mode 100644 crates/antlr-rust-codegen/src/grammar/transform/passes/snapshots/antlr_rust_codegen__grammar__transform__passes__inline_trivial__tests__nullable_and_recursive_declines.snap create mode 100644 crates/antlr-rust-codegen/src/grammar/transform/passes/snapshots/antlr_rust_codegen__grammar__transform__passes__inline_trivial__tests__opaque_target_code_declines.snap create mode 100644 crates/antlr-rust-codegen/src/grammar/transform/passes/snapshots/antlr_rust_codegen__grammar__transform__passes__inline_trivial__tests__single_use_inline_shapes_and_candidates.snap create mode 100644 crates/antlr-rust-codegen/src/grammar/transform/passes/snapshots/antlr_rust_codegen__grammar__transform__passes__inline_trivial__tests__token_set_inline_shapes_and_candidates.snap create mode 100644 crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__transforms__trivial_inline_optimization_manifest.snap create mode 100644 crates/antlr-rust-codegen/tests/fixtures/antlr4-rust-gen/trivial-inline/Inline.g4 diff --git a/README.md b/README.md index 0a652a02..42b6aa2f 100644 --- a/README.md +++ b/README.md @@ -1015,6 +1015,55 @@ rules. It removes generated rule methods, context types, and listener/visitor callbacks, so consumers that invoke other rules directly must declare them with `--entry-rule` or leave pruning disabled. +### Trivial-rule inlining + +`--inline-trivial-rules` is an explicit, off-by-default optimization that +inlines two classes of pure parser rules into their call sites before ATN +construction, so the caller's decision sees the actual tokens instead of a +rule transition: + +- **token-set rules** — a rule whose body is nothing but an alternation of + single terminals (keyword lists, operator names). Every reference is + replaced by the flattened token set, however many call sites exist; + expansion is bounded by construction at one element per site. +- **single-use pure sequences** — a single-alternative rule referenced + exactly once whose body carries no observable surface. Its body moves into + the call site as a parenthesized block. + +Inlined rules are removed, so this pass is **recognition preserving**, not +tree/API preserving: the callee's rule method, context type, and +listener/visitor callbacks disappear, its parse-tree level vanishes, and its +recovery boundary moves to the caller. The accepted language and consumed +input for valid text are unchanged. + +Candidates are inlined all-or-nothing and fail closed. A candidate is +declined — with a reason recorded in the manifest — when it is a configured or +inferred entry rule, recursive, nullable, referenced by grammar target code, +carries labels, attributes, actions, predicates, options, or exception +clauses, or when any call site binds a label, passes arguments, or pins +precedence. Discovery re-runs after every accepted rewrite, so alias chains +(`a : b ; b : X | Y ;`) collapse in one invocation while every application +removes exactly one rule. + +Every applied run writes `optimizations.json` recording each candidate's +status, reason, removed rule, and rewritten call sites with original source +spans. Inspect the same deterministic report without generating or changing a +parser with: + +```bash +antlr4-rust-gen Grammar.g4 \ + --report-trivial-rules \ + --out-dir target/grammar-report +``` + +Report mode writes only `optimizations.json`. Apply reviewed candidates with: + +```bash +antlr4-rust-gen Grammar.g4 \ + --inline-trivial-rules \ + --out-dir src/generated +``` + ### Precedence-ladder optimization `--optimize-precedence-ladders` is an explicit, off-by-default source diff --git a/crates/antlr-rust-codegen/src/builder.rs b/crates/antlr-rust-codegen/src/builder.rs index e1e6c98a..0a3a761f 100644 --- a/crates/antlr-rust-codegen/src/builder.rs +++ b/crates/antlr-rust-codegen/src/builder.rs @@ -63,6 +63,8 @@ pub struct Builder { fixed_lookahead: Option, entry_rules: BTreeSet, prune_unreachable: bool, + inline_trivial_rules: bool, + report_trivial_rules: bool, optimize_precedence_ladders: bool, report_precedence_ladders: bool, } @@ -85,6 +87,8 @@ impl Default for Builder { fixed_lookahead: None, entry_rules: BTreeSet::new(), prune_unreachable: false, + inline_trivial_rules: false, + report_trivial_rules: false, optimize_precedence_ladders: false, report_precedence_ladders: false, } @@ -177,6 +181,16 @@ impl Builder { self } + pub const fn inline_trivial_rules(mut self, enabled: bool) -> Self { + self.inline_trivial_rules = enabled; + self + } + + pub const fn report_trivial_rules(mut self, enabled: bool) -> Self { + self.report_trivial_rules = enabled; + self + } + pub const fn optimize_precedence_ladders(mut self, enabled: bool) -> Self { self.optimize_precedence_ladders = enabled; self @@ -201,6 +215,11 @@ impl Builder { let output_directory = self .output_directory .ok_or_else(|| Error::configuration("an output directory is required"))?; + if self.inline_trivial_rules && self.report_trivial_rules { + return Err(Error::configuration( + "trivial-rule inlining and report-only mode are mutually exclusive", + )); + } if self.optimize_precedence_ladders && self.report_precedence_ladders { return Err(Error::configuration( "precedence-ladder optimization and report-only mode are mutually exclusive", @@ -240,6 +259,8 @@ impl Builder { fixed_lookahead: self.fixed_lookahead, entry_rules: self.entry_rules, prune_unreachable: self.prune_unreachable, + inline_trivial_rules: self.inline_trivial_rules, + report_trivial_rules: self.report_trivial_rules, optimize_precedence_ladders: self.optimize_precedence_ladders, report_precedence_ladders: self.report_precedence_ladders, test_rig: None, diff --git a/crates/antlr-rust-codegen/src/cli.rs b/crates/antlr-rust-codegen/src/cli.rs index bedd84ea..2a5df73b 100644 --- a/crates/antlr-rust-codegen/src/cli.rs +++ b/crates/antlr-rust-codegen/src/cli.rs @@ -143,6 +143,14 @@ struct CliArgs { #[arg(long)] prune_unreachable: bool, + /// Inline trivial pure parser rules into their call sites (changes tree/API). + #[arg(long, conflicts_with = "report_trivial_rules")] + inline_trivial_rules: bool, + + /// Dry-run trivial-rule inlining and emit only optimizations.json. + #[arg(long, conflicts_with = "inline_trivial_rules")] + report_trivial_rules: bool, + /// Collapse proven linear precedence ladders (changes tree/API). #[arg(long, conflicts_with = "report_precedence_ladders")] optimize_precedence_ladders: bool, @@ -195,6 +203,8 @@ impl CliArgs { fixed_lookahead: self.fixed_lookahead.map(usize::from), entry_rules: self.entry_rules.into_iter().collect(), prune_unreachable: self.prune_unreachable, + inline_trivial_rules: self.inline_trivial_rules, + report_trivial_rules: self.report_trivial_rules, optimize_precedence_ladders: self.optimize_precedence_ladders, report_precedence_ladders: self.report_precedence_ladders, test_rig: None, diff --git a/crates/antlr-rust-codegen/src/config.rs b/crates/antlr-rust-codegen/src/config.rs index c9293f87..eedd072a 100644 --- a/crates/antlr-rust-codegen/src/config.rs +++ b/crates/antlr-rust-codegen/src/config.rs @@ -35,6 +35,10 @@ pub(crate) struct CompilerConfig { pub(crate) entry_rules: BTreeSet, /// Remove parser rules unreachable from every inferred/configured entry. pub(crate) prune_unreachable: bool, + /// Inline trivial pure parser rules into their call sites (issue #130). + pub(crate) inline_trivial_rules: bool, + /// Analyze trivial-rule inlining on a shadow model and emit only its manifest. + pub(crate) report_trivial_rules: bool, /// Recognition-preserving source rewrite from issue #225. pub(crate) optimize_precedence_ladders: bool, /// Analyze the same pass on a shadow model and emit only its manifest. diff --git a/crates/antlr-rust-codegen/src/grammar/transform/analysis.rs b/crates/antlr-rust-codegen/src/grammar/transform/analysis.rs index 6f13bb7d..99722e6a 100644 --- a/crates/antlr-rust-codegen/src/grammar/transform/analysis.rs +++ b/crates/antlr-rust-codegen/src/grammar/transform/analysis.rs @@ -5,6 +5,7 @@ use std::collections::{BTreeMap, BTreeSet}; use petgraph::algo::tarjan_scc; use petgraph::graph::DiGraph; +use crate::grammar::action::{ActionReferenceKind, ActionReferenceParser}; use crate::grammar::model::{Block, Element, ElementKind, GrammarUnit, Quantifier, RuleId}; #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] @@ -206,3 +207,149 @@ fn recursive_components(call_graph: &BTreeMap>) -> Vec, + action_reference_parser: ActionReferenceParser, +) -> BTreeSet { + let mut observed = BTreeSet::new(); + let mut has_opaque_target_code = false; + for action in &unit.actions { + collect_target_code_rule_references( + &action.body, + rules_by_name, + &mut observed, + &mut has_opaque_target_code, + action_reference_parser, + ); + } + for rule in &unit.rules { + for clause in rule + .arguments + .iter() + .chain(rule.returns.iter()) + .chain(rule.locals.iter()) + { + collect_target_code_rule_references( + &clause.text, + rules_by_name, + &mut observed, + &mut has_opaque_target_code, + action_reference_parser, + ); + } + for action in &rule.actions { + collect_target_code_rule_references( + &action.body, + rules_by_name, + &mut observed, + &mut has_opaque_target_code, + action_reference_parser, + ); + } + for handler in &rule.catches { + collect_target_code_rule_references( + &handler.body, + rules_by_name, + &mut observed, + &mut has_opaque_target_code, + action_reference_parser, + ); + } + if let Some(action) = &rule.finally_action { + collect_target_code_rule_references( + &action.body, + rules_by_name, + &mut observed, + &mut has_opaque_target_code, + action_reference_parser, + ); + } + visit_elements(&rule.block, &mut |element| match &element.kind { + ElementKind::RuleCall(call) => { + if let Some(arguments) = &call.arguments { + collect_target_code_rule_references( + arguments, + rules_by_name, + &mut observed, + &mut has_opaque_target_code, + action_reference_parser, + ); + } + } + ElementKind::Action { body, .. } => { + collect_target_code_rule_references( + body, + rules_by_name, + &mut observed, + &mut has_opaque_target_code, + action_reference_parser, + ); + } + ElementKind::Predicate { body, fail, .. } => { + collect_target_code_rule_references( + body, + rules_by_name, + &mut observed, + &mut has_opaque_target_code, + action_reference_parser, + ); + if let Some(fail) = fail { + collect_target_code_rule_references( + fail, + rules_by_name, + &mut observed, + &mut has_opaque_target_code, + action_reference_parser, + ); + } + } + ElementKind::Terminal(_) + | ElementKind::Range(..) + | ElementKind::Set { .. } + | ElementKind::Block(_) + | ElementKind::Epsilon => {} + }); + } + if has_opaque_target_code { + observed.extend(rules_by_name.values().copied()); + } + observed +} + +fn collect_target_code_rule_references( + body: &str, + rules_by_name: &BTreeMap, + observed: &mut BTreeSet, + has_opaque_target_code: &mut bool, + action_reference_parser: ActionReferenceParser, +) { + *has_opaque_target_code |= !body.trim().is_empty(); + for reference in action_reference_parser(body) { + let name = match reference.kind { + ActionReferenceKind::Attribute { name, .. } + | ActionReferenceKind::Qualified { name, .. } => Some(name), + ActionReferenceKind::NonLocal { rule, .. } => Some(rule), + }; + if let Some(rule) = name.and_then(|name| rules_by_name.get(name)) { + observed.insert(*rule); + } + } +} diff --git a/crates/antlr-rust-codegen/src/grammar/transform/artifact.rs b/crates/antlr-rust-codegen/src/grammar/transform/artifact.rs index c02d5ec0..afb31dbf 100644 --- a/crates/antlr-rust-codegen/src/grammar/transform/artifact.rs +++ b/crates/antlr-rust-codegen/src/grammar/transform/artifact.rs @@ -6,9 +6,9 @@ use std::collections::BTreeMap; use serde::Serialize; use super::{ - SafetyClass, StructuralMetrics, TransformAlternativeMapping, TransformCandidateReport, - TransformCandidateStatus, TransformLabelMapping, TransformProjection, TransformReport, - TransformReportEntry, + SafetyClass, StructuralMetrics, TransformAlternativeMapping, TransformCallSite, + TransformCandidateReport, TransformCandidateStatus, TransformLabelMapping, TransformProjection, + TransformReport, TransformReportEntry, }; use crate::grammar::frontend::{SourceId, SourceSpan}; use crate::grammar::source::SourceSet; @@ -61,6 +61,8 @@ struct TransformCandidateManifest<'a> { alternatives: Vec>, label_renames: Vec>, grouping_changes: Vec>, + #[serde(skip_serializing_if = "Vec::is_empty")] + inlined_call_sites: Vec>, } #[derive(Serialize)] @@ -126,6 +128,14 @@ struct GroupingChangeManifest<'a> { to: &'static str, } +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct CallSiteManifest<'a> { + caller: &'a str, + alternative: usize, + source: SourceManifest<'a>, +} + pub(crate) fn render_optimization_manifest( report: &TransformReport, sources: &SourceSet, @@ -218,6 +228,22 @@ fn transform_candidate_manifest<'a>( to: "left-recursive-nesting", }) .collect(), + inlined_call_sites: candidate + .call_sites + .iter() + .map(|site| call_site_manifest(site, sources)) + .collect(), + } +} + +fn call_site_manifest<'a>( + site: &'a TransformCallSite, + sources: &'a SourceSet, +) -> CallSiteManifest<'a> { + CallSiteManifest { + caller: &site.caller, + alternative: site.alternative, + source: source_manifest(&site.source_span, sources), } } diff --git a/crates/antlr-rust-codegen/src/grammar/transform/clone.rs b/crates/antlr-rust-codegen/src/grammar/transform/clone.rs new file mode 100644 index 00000000..607c0ddd --- /dev/null +++ b/crates/antlr-rust-codegen/src/grammar/transform/clone.rs @@ -0,0 +1,172 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026 Konstantin Vyatkin +//! Shared cloning and tombstoning helpers for optional grammar transforms. +//! +//! Every cloned model node receives a fresh ID and a provenance record that +//! chains the source node with an [`Origin::OptionalTransform`] entry, so +//! [`crate::grammar::validation::validate_model`] invariants hold after any +//! structural rewrite. + +use crate::grammar::frontend::SyntaxId; +use crate::grammar::model::{ + Alternative, Block, Element, ElementKind, Label, ModelIdAllocator, ModelNodeId, Rule, + TransformId, +}; +use crate::grammar::provenance::{Origin, ProvenanceIndex, Tombstone}; + +pub(crate) struct TransformCloner<'a> { + pub(crate) ids: &'a mut ModelIdAllocator, + pub(crate) provenance: &'a mut ProvenanceIndex, + pub(crate) pass: TransformId, +} + +impl TransformCloner<'_> { + pub(crate) fn block(&mut self, source: &Block) -> Block { + Block { + alternatives: source + .alternatives + .iter() + .map(|alternative| self.alternative(alternative)) + .collect(), + options: source.options.clone(), + syntax: source.syntax, + span: source.span.clone(), + } + } + + pub(crate) fn alternative(&mut self, source: &Alternative) -> Alternative { + let id = self.ids.alternative(); + self.record( + ModelNodeId::Alternative(id), + ModelNodeId::Alternative(source.id), + ); + Alternative { + id, + elements: source + .elements + .iter() + .map(|element| self.element(element)) + .collect(), + label: source.label.clone(), + options: source.options.clone(), + commands: source.commands.clone(), + syntax: source.syntax, + span: source.span.clone(), + } + } + + pub(crate) fn element(&mut self, source: &Element) -> Element { + let mut cloned = source.clone(); + cloned.id = self.ids.element(); + cloned.label = source.label.as_ref().map(|label| self.label(label)); + cloned.kind = match &source.kind { + ElementKind::Block(block) => ElementKind::Block(self.block(block)), + ElementKind::Action { id, body } => { + let cloned_id = self.ids.action(); + self.record(ModelNodeId::Action(cloned_id), ModelNodeId::Action(*id)); + ElementKind::Action { + id: cloned_id, + body: body.clone(), + } + } + ElementKind::Predicate { + id, + body, + fail, + precedence, + } => { + let cloned_id = self.ids.predicate(); + self.record( + ModelNodeId::Predicate(cloned_id), + ModelNodeId::Predicate(*id), + ); + ElementKind::Predicate { + id: cloned_id, + body: body.clone(), + fail: fail.clone(), + precedence: *precedence, + } + } + kind => kind.clone(), + }; + self.record( + ModelNodeId::Element(cloned.id), + ModelNodeId::Element(source.id), + ); + cloned + } + + pub(crate) fn label(&mut self, source: &Label) -> Label { + let mut cloned = source.clone(); + cloned.id = self.ids.label(); + self.record(ModelNodeId::Label(cloned.id), ModelNodeId::Label(source.id)); + cloned + } + + pub(crate) fn record(&mut self, destination: ModelNodeId, source: ModelNodeId) { + let mut origins = self.provenance.origins(source).to_vec(); + origins.push(Origin::OptionalTransform { + pass: self.pass, + inputs: Box::new([source]), + }); + self.provenance.record_model(destination, origins); + } +} + +pub(crate) fn tombstone_rule( + provenance: &mut ProvenanceIndex, + rule: &Rule, + reason: &'static str, + replacements: &[ModelNodeId], +) { + tombstone(provenance, rule.syntax, reason, replacements); + for action in &rule.actions { + tombstone(provenance, action.syntax, reason, replacements); + } + for handler in &rule.catches { + tombstone(provenance, handler.syntax, reason, replacements); + } + if let Some(action) = &rule.finally_action { + tombstone(provenance, action.syntax, reason, replacements); + } + tombstone_block(provenance, &rule.block, reason, replacements); +} + +pub(crate) fn tombstone_block( + provenance: &mut ProvenanceIndex, + block: &Block, + reason: &'static str, + replacements: &[ModelNodeId], +) { + for alternative in &block.alternatives { + tombstone(provenance, alternative.syntax, reason, replacements); + if let Some(label) = &alternative.label { + tombstone(provenance, label.syntax, reason, replacements); + } + for element in &alternative.elements { + tombstone(provenance, element.syntax, reason, replacements); + if let Some(label) = &element.label { + tombstone(provenance, label.syntax, reason, replacements); + } + if let ElementKind::Block(nested) = &element.kind { + tombstone_block(provenance, nested, reason, replacements); + } + } + } +} + +fn tombstone( + provenance: &mut ProvenanceIndex, + syntax: SyntaxId, + reason: &'static str, + replacements: &[ModelNodeId], +) { + provenance.tombstone( + syntax, + Tombstone { + phase: "optional-transform", + reason, + replacements: replacements.to_vec().into_boxed_slice(), + }, + ); +} diff --git a/crates/antlr-rust-codegen/src/grammar/transform/mod.rs b/crates/antlr-rust-codegen/src/grammar/transform/mod.rs index 9fa5370b..43e82922 100644 --- a/crates/antlr-rust-codegen/src/grammar/transform/mod.rs +++ b/crates/antlr-rust-codegen/src/grammar/transform/mod.rs @@ -2,9 +2,11 @@ // Copyright (c) 2026 Konstantin Vyatkin pub(crate) mod analysis; pub(crate) mod artifact; +pub(crate) mod clone; mod registry; pub(crate) mod passes { + pub(crate) mod inline_trivial; pub(crate) mod precedence_ladder; pub(crate) mod prune_unreachable; } @@ -70,6 +72,15 @@ pub(crate) struct TransformLabelMapping { pub(crate) target_label: String, } +/// One rewritten (or would-be rewritten) reference to an inlined rule. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct TransformCallSite { + pub(crate) caller: String, + /// One-based top-level alternative ordinal within the caller. + pub(crate) alternative: usize, + pub(crate) source_span: SourceSpan, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct TransformCandidateReport { pub(crate) pass: TransformId, @@ -85,6 +96,7 @@ pub(crate) struct TransformCandidateReport { pub(crate) alternatives: Vec, pub(crate) labels: Vec, pub(crate) grouping_changes: Vec, + pub(crate) call_sites: Vec, } #[derive(Clone, Debug, Default, Eq, PartialEq)] diff --git a/crates/antlr-rust-codegen/src/grammar/transform/passes/inline_trivial.rs b/crates/antlr-rust-codegen/src/grammar/transform/passes/inline_trivial.rs new file mode 100644 index 00000000..8ea40b1f --- /dev/null +++ b/crates/antlr-rust-codegen/src/grammar/transform/passes/inline_trivial.rs @@ -0,0 +1,914 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026 Konstantin Vyatkin +//! Inline trivial pure parser rules into their call sites. +//! +//! Two candidate classes are supported, both recognition preserving and +//! therefore opt-in (issue #130): +//! +//! - **token-set rules** — a parser rule whose body is nothing but an +//! alternation of single terminals (keyword lists, operator names). Every +//! reference is replaced by the flattened set, so the caller's decision +//! sees the actual tokens instead of a rule transition. Expansion is +//! bounded by construction: one element per call site. +//! - **single-use pure sequences** — a rule with exactly one alternative, +//! referenced exactly once, whose body carries no observable surface +//! (labels, actions, predicates, attributes, options, exceptions). Its +//! body moves into the call site as a parenthesized block, removing the +//! rule transition and context allocation without moving any decision. +//! +//! Candidates are inlined all-or-nothing: if any reference is ineligible the +//! whole candidate is declined with a reason. Applied callees are removed, +//! so the pass is idempotent, and iteration follows authored rule order, so +//! it is deterministic. Discovery re-runs after every accepted rewrite, +//! letting alias chains (`a : b ; b : X | Y ;`) collapse without composed +//! growth: every application removes exactly one rule. + +use std::collections::{BTreeMap, BTreeSet}; + +use crate::grammar::diagnostic::Diagnostic; +use crate::grammar::frontend::SourceSpan; +use crate::grammar::model::{ + Block, Element, ElementKind, GrammarKind, GrammarUnit, ModelIdAllocator, ModelNodeId, + Quantifier, Rule, RuleId, RuleKind, SetElement, Terminal, TransformId, +}; +use crate::grammar::provenance::{Origin, ProvenanceIndex}; +use crate::grammar::rule_reachability::{EntryRuleConfig, analyze}; +use crate::grammar::transform::analysis::{ + AnalysisInvalidation, TransformAnalysis, observed_rule_contexts, +}; +use crate::grammar::transform::clone::{TransformCloner, tombstone_rule}; +use crate::grammar::transform::{ + GrammarTransform, SafetyClass, TransformCallSite, TransformCandidateReport, + TransformCandidateStatus, TransformContext, TransformGrammar, TransformReport, + TransformRuleRemoval, +}; + +pub(crate) struct InlineTrivialRules { + entries: EntryRuleConfig, +} + +impl InlineTrivialRules { + pub(crate) const NAME: &'static str = "inline-trivial-rules"; + + pub(crate) const fn new(entries: EntryRuleConfig) -> Self { + Self { entries } + } +} + +impl GrammarTransform for InlineTrivialRules { + fn name(&self) -> &'static str { + Self::NAME + } + + fn safety_class(&self) -> SafetyClass { + SafetyClass::RecognitionPreserving + } + + fn invalidates(&self) -> AnalysisInvalidation { + AnalysisInvalidation::ALL + } + + fn apply( + &self, + input: &TransformContext<'_>, + grammar: &mut TransformGrammar, + ids: &mut ModelIdAllocator, + report: &mut TransformReport, + ) -> Result { + let mut changed = false; + let TransformGrammar { + units, + target_units, + preserved_rules, + provenance, + } = grammar; + for unit in units { + if !target_units.contains(&unit.id) || unit.kind != GrammarKind::Parser { + continue; + } + loop { + let InlineDiscovery { plans, declined } = + discover(unit, &self.entries, preserved_rules, input); + let Some(plan) = plans.into_iter().next() else { + report.candidates.extend( + declined + .into_iter() + .map(|declined| declined_report(input, &unit.name, declined)), + ); + break; + }; + let candidate = apply_plan(input, unit, &plan, ids, provenance); + report.rule_removals.push(TransformRuleRemoval { + pass: input.id, + grammar: unit.name.clone(), + rule: plan.callee_name.clone(), + source_span: plan.name_span.clone(), + }); + report.candidates.push(candidate); + changed = true; + } + } + Ok(changed) + } +} + +enum InlineBody { + TokenSet(Vec), + SingleUse(Block), +} + +struct InlinePlan { + callee: RuleId, + callee_name: String, + rule_span: SourceSpan, + name_span: SourceSpan, + body: InlineBody, + call_sites: Vec, +} + +struct DeclinedInline { + rule: String, + span: SourceSpan, + reason: String, + call_sites: Vec, +} + +#[derive(Default)] +struct InlineDiscovery { + plans: Vec, + declined: Vec, +} + +struct ScannedSite { + site: TransformCallSite, + problem: Option<&'static str>, +} + +struct DiscoveryContext { + analysis: TransformAnalysis, + entry_rules: BTreeSet, + observed: BTreeSet, + preserved: BTreeSet, +} + +fn discover( + unit: &GrammarUnit, + entries: &EntryRuleConfig, + preserved_rules: &BTreeSet, + input: &TransformContext<'_>, +) -> InlineDiscovery { + let analysis = TransformAnalysis::compute(std::slice::from_ref(unit)); + let context = DiscoveryContext { + entry_rules: analyze(unit, entries).entry_rules.into_iter().collect(), + observed: observed_rule_contexts( + unit, + &analysis.rules_by_name, + input.action_reference_parser, + ), + preserved: preserved_rules.clone(), + analysis, + }; + let sites_by_callee = scan_call_sites(unit, &context.analysis.rules_by_name); + let mut discovery = InlineDiscovery::default(); + for rule in &unit.rules { + if rule.kind != RuleKind::Parser { + continue; + } + let Some(sites) = sites_by_callee + .get(&rule.id) + .filter(|sites| !sites.is_empty()) + else { + continue; + }; + evaluate_candidate(rule, sites, &context, &mut discovery); + } + discovery +} + +/// Collects every reference to a same-unit parser rule, keyed by callee, +/// in authored order. +fn scan_call_sites( + unit: &GrammarUnit, + rules_by_name: &BTreeMap, +) -> BTreeMap> { + let mut sites = BTreeMap::>::new(); + for rule in &unit.rules { + if rule.kind != RuleKind::Parser { + continue; + } + for (index, alternative) in rule.block.alternatives.iter().enumerate() { + scan_alternative_elements( + &alternative.elements, + rules_by_name, + &mut sites, + rule, + index + 1, + ); + } + } + sites +} + +fn scan_alternative_elements( + elements: &[Element], + rules_by_name: &BTreeMap, + sites: &mut BTreeMap>, + caller: &Rule, + alternative: usize, +) { + for element in elements { + match &element.kind { + ElementKind::RuleCall(call) => { + let Some(target) = rules_by_name.get(&call.name) else { + continue; + }; + let problem = if element.label.is_some() { + Some("a call site binds a label to the rule context") + } else if !element.options.is_empty() { + Some("a call site carries element options") + } else if call.arguments.is_some() { + Some("a call site passes rule arguments") + } else if call.precedence.is_some() { + Some("a call site pins left-recursive precedence") + } else { + None + }; + sites.entry(*target).or_default().push(ScannedSite { + site: TransformCallSite { + caller: caller.name.clone(), + alternative, + source_span: element.span.clone(), + }, + problem, + }); + } + ElementKind::Block(nested) => { + for nested_alternative in &nested.alternatives { + scan_alternative_elements( + &nested_alternative.elements, + rules_by_name, + sites, + caller, + alternative, + ); + } + } + ElementKind::Terminal(_) + | ElementKind::Range(..) + | ElementKind::Set { .. } + | ElementKind::Action { .. } + | ElementKind::Predicate { .. } + | ElementKind::Epsilon => {} + } + } +} + +fn evaluate_candidate( + rule: &Rule, + sites: &[ScannedSite], + context: &DiscoveryContext, + discovery: &mut InlineDiscovery, +) { + let token_set = token_set_body(rule); + let single_use_sequence = sites.len() == 1 && rule.block.alternatives.len() == 1; + if token_set.is_none() && !single_use_sequence { + return; + } + let call_sites = sites.iter().map(|scanned| scanned.site.clone()).collect(); + match eligibility(rule, sites, token_set, context) { + Ok(body) => discovery.plans.push(InlinePlan { + callee: rule.id, + callee_name: rule.name.clone(), + rule_span: rule.span.clone(), + name_span: rule.name_span.clone(), + body, + call_sites, + }), + Err(reason) => discovery.declined.push(DeclinedInline { + rule: rule.name.clone(), + span: rule.span.clone(), + reason, + call_sites, + }), + } +} + +fn eligibility( + rule: &Rule, + sites: &[ScannedSite], + token_set: Option>, + context: &DiscoveryContext, +) -> Result { + if context.preserved.contains(&rule.id) { + return Err("configured parser entry rules keep their generated API".to_owned()); + } + if context.entry_rules.contains(&rule.id) { + return Err("inferred parser entry rules keep their generated API".to_owned()); + } + if context + .analysis + .recursive_components + .iter() + .any(|component| component.contains(&rule.id)) + { + return Err("recursive rules cannot be inlined".to_owned()); + } + if context.observed.contains(&rule.id) { + return Err("grammar target code observes the rule context".to_owned()); + } + if let Some(problem) = sites.iter().find_map(|scanned| { + scanned + .problem + .map(|problem| (problem, scanned.site.caller.clone())) + }) { + return Err(format!("{} in rule {}", problem.0, problem.1)); + } + if let Some(members) = token_set { + return Ok(InlineBody::TokenSet(members)); + } + single_use_purity(rule, &context.analysis)?; + Ok(InlineBody::SingleUse(rule.block.clone())) +} + +/// A body consisting solely of single-terminal alternatives, flattened into +/// prospective set members. `EOF`, wildcard, and any observable surface +/// disqualify the shape. +fn token_set_body(rule: &Rule) -> Option> { + if !rule.block.options.is_empty() { + return None; + } + let mut members = Vec::new(); + for alternative in &rule.block.alternatives { + if alternative.label.is_some() + || !alternative.options.is_empty() + || !alternative.commands.is_empty() + || alternative.elements.len() != 1 + { + return None; + } + let element = &alternative.elements[0]; + if element.quantifier != Quantifier::One + || element.label.is_some() + || !element.options.is_empty() + { + return None; + } + match &element.kind { + ElementKind::Terminal(terminal) => members.push(SetElement::Terminal { + source: element.id, + value: inlinable_terminal(terminal)?.clone(), + span: element.span.clone(), + options: Vec::new(), + }), + ElementKind::Set { + inverted: false, + elements, + } => { + for member in elements { + members.push(inlinable_set_member(member)?.clone()); + } + } + _ => return None, + } + } + (!members.is_empty()).then_some(members) +} + +fn inlinable_terminal(terminal: &Terminal) -> Option<&Terminal> { + match terminal { + Terminal::Token(name) if name != "EOF" => Some(terminal), + Terminal::Literal(_) => Some(terminal), + Terminal::Token(_) | Terminal::LexerCharSet(_) | Terminal::Wildcard | Terminal::Eof => None, + } +} + +fn inlinable_set_member(member: &SetElement) -> Option<&SetElement> { + match member { + SetElement::Terminal { value, options, .. } + if options.is_empty() && inlinable_terminal(value).is_some() => + { + Some(member) + } + SetElement::Terminal { .. } | SetElement::Range { .. } => None, + } +} + +fn single_use_purity(rule: &Rule, analysis: &TransformAnalysis) -> Result<(), String> { + if !rule.modifiers.is_empty() + || rule.arguments.is_some() + || rule.returns.is_some() + || rule.locals.is_some() + || !rule.throws.is_empty() + || !rule.options.is_empty() + || !rule.actions.is_empty() + || !rule.catches.is_empty() + || rule.finally_action.is_some() + { + return Err( + "rule-level attributes, actions, options, or exceptions are observable".to_owned(), + ); + } + if analysis.side_effecting.contains(&rule.id) { + return Err("embedded actions or predicates are observable".to_owned()); + } + if analysis.nullable.contains(&rule.id) { + return Err("a nullable body can change decision ownership at the call site".to_owned()); + } + validate_pure_block(&rule.block) +} + +fn validate_pure_block(block: &Block) -> Result<(), String> { + if !block.options.is_empty() { + return Err("block options are observable".to_owned()); + } + for alternative in &block.alternatives { + if alternative.label.is_some() { + return Err("alternative labels define generated context types".to_owned()); + } + if !alternative.options.is_empty() || !alternative.commands.is_empty() { + return Err("alternative options or commands are observable".to_owned()); + } + for element in &alternative.elements { + validate_pure_element(element)?; + } + } + Ok(()) +} + +fn validate_pure_element(element: &Element) -> Result<(), String> { + if element.label.is_some() { + return Err("element labels bind generated accessors".to_owned()); + } + if !element.options.is_empty() { + return Err("element options are observable".to_owned()); + } + match &element.kind { + ElementKind::Action { .. } | ElementKind::Predicate { .. } => { + Err("embedded actions or predicates are observable".to_owned()) + } + ElementKind::RuleCall(call) if call.arguments.is_some() || call.precedence.is_some() => { + Err("nested rule calls pass arguments or pin precedence".to_owned()) + } + ElementKind::Range(..) => Err("token ranges are not valid in parser rules".to_owned()), + ElementKind::Block(nested) => validate_pure_block(nested), + ElementKind::RuleCall(_) + | ElementKind::Terminal(_) + | ElementKind::Set { .. } + | ElementKind::Epsilon => Ok(()), + } +} + +fn apply_plan( + input: &TransformContext<'_>, + unit: &mut GrammarUnit, + plan: &InlinePlan, + ids: &mut ModelIdAllocator, + provenance: &mut ProvenanceIndex, +) -> TransformCandidateReport { + let mut rewritten = Vec::new(); + for rule in &mut unit.rules { + if rule.id != plan.callee { + rewrite_block( + &mut rule.block, + plan, + input.id, + ids, + provenance, + &mut rewritten, + ); + } + } + if let Some(callee) = unit.rules.iter().find(|rule| rule.id == plan.callee) { + tombstone_rule( + provenance, + callee, + "trivial rule inlined into its call sites", + &rewritten, + ); + } + unit.rules.retain(|rule| rule.id != plan.callee); + applied_report(input, &unit.name, plan) +} + +fn rewrite_block( + block: &mut Block, + plan: &InlinePlan, + pass: TransformId, + ids: &mut ModelIdAllocator, + provenance: &mut ProvenanceIndex, + rewritten: &mut Vec, +) { + for alternative in &mut block.alternatives { + for element in &mut alternative.elements { + match &mut element.kind { + ElementKind::Block(nested) => { + rewrite_block(nested, plan, pass, ids, provenance, rewritten); + } + ElementKind::RuleCall(call) if call.name == plan.callee_name => { + element.kind = replacement_kind(&plan.body, pass, ids, provenance); + let node = ModelNodeId::Element(element.id); + let mut origins = provenance.origins(node).to_vec(); + origins.push(Origin::OptionalTransform { + pass, + inputs: Box::new([ModelNodeId::Rule(plan.callee)]), + }); + provenance.record_model(node, origins); + rewritten.push(node); + } + _ => {} + } + } + } +} + +fn replacement_kind( + body: &InlineBody, + pass: TransformId, + ids: &mut ModelIdAllocator, + provenance: &mut ProvenanceIndex, +) -> ElementKind { + match body { + InlineBody::TokenSet(members) => { + if let [SetElement::Terminal { value, .. }] = members.as_slice() { + ElementKind::Terminal(value.clone()) + } else { + ElementKind::Set { + inverted: false, + elements: members.clone(), + } + } + } + InlineBody::SingleUse(callee_block) => { + let mut cloner = TransformCloner { + ids, + provenance, + pass, + }; + ElementKind::Block(cloner.block(callee_block)) + } + } +} + +fn applied_report( + input: &TransformContext<'_>, + unit_name: &str, + plan: &InlinePlan, +) -> TransformCandidateReport { + let reason = match &plan.body { + InlineBody::TokenSet(members) => format!( + "inlined token-set rule {} ({} members) into {} call site{}", + plan.callee_name, + members.len(), + plan.call_sites.len(), + if plan.call_sites.len() == 1 { "" } else { "s" } + ), + InlineBody::SingleUse(_) => format!( + "inlined single-use rule {} into its call site in {}", + plan.callee_name, plan.call_sites[0].caller + ), + }; + TransformCandidateReport { + pass: input.id, + grammar: unit_name.to_owned(), + entry_rule: plan.callee_name.clone(), + source_span: plan.rule_span.clone(), + status: if input.report_only { + TransformCandidateStatus::Eligible + } else { + TransformCandidateStatus::Applied + }, + reason, + rungs: Vec::new(), + boundary_rule: None, + projection: None, + removed_rules: vec![plan.callee_name.clone()], + alternatives: Vec::new(), + labels: Vec::new(), + grouping_changes: Vec::new(), + call_sites: plan.call_sites.clone(), + } +} + +fn declined_report( + input: &TransformContext<'_>, + unit_name: &str, + declined: DeclinedInline, +) -> TransformCandidateReport { + TransformCandidateReport { + pass: input.id, + grammar: unit_name.to_owned(), + entry_rule: declined.rule, + source_span: declined.span, + status: TransformCandidateStatus::Declined, + reason: declined.reason, + rungs: Vec::new(), + boundary_rule: None, + projection: None, + removed_rules: Vec::new(), + alternatives: Vec::new(), + labels: Vec::new(), + grouping_changes: Vec::new(), + call_sites: declined.call_sites, + } +} + +#[cfg(test)] +#[allow(clippy::disallowed_methods)] // `insta` assertion macros unwrap internal I/O. +mod tests { + use std::collections::BTreeSet; + + use super::*; + use crate::grammar::frontend::{SourceId, parse_source}; + use crate::grammar::model::GrammarId; + use crate::grammar::syntax::parse_grammar_unit; + use crate::grammar::transform::{TransformGrammar, TransformRegistry}; + + fn fixture(text: &str) -> (TransformGrammar, ModelIdAllocator) { + let file = parse_source(SourceId::new(0), "P.g4", text).expect("valid grammar"); + let mut ids = ModelIdAllocator::after_loaded_grammars(1); + let mut provenance = ProvenanceIndex::default(); + let unit = parse_grammar_unit(&file, GrammarId::new(0), &mut ids, &mut provenance); + ( + TransformGrammar { + units: vec![unit], + target_units: BTreeSet::from([GrammarId::new(0)]), + preserved_rules: BTreeSet::new(), + provenance, + }, + ids, + ) + } + + fn registry(entries: EntryRuleConfig) -> TransformRegistry { + let mut registry = TransformRegistry::default(); + registry.push(InlineTrivialRules::new(entries)); + registry + } + + fn shape(unit: &GrammarUnit) -> Vec { + unit.rules + .iter() + .map(|rule| format!("{} : {}", rule.name, block_shape(&rule.block))) + .collect() + } + + fn block_shape(block: &Block) -> String { + block + .alternatives + .iter() + .map(|alternative| { + alternative + .elements + .iter() + .map(element_shape) + .collect::>() + .join(" ") + }) + .collect::>() + .join(" | ") + } + + fn element_shape(element: &Element) -> String { + let label = element + .label + .as_ref() + .map_or_else(String::new, |label| format!("{}=", label.name)); + let kind = match &element.kind { + ElementKind::Terminal(terminal) => terminal_shape(terminal), + ElementKind::RuleCall(call) => call.name.clone(), + ElementKind::Set { inverted, elements } => format!( + "{}{{{}}}", + if *inverted { "~" } else { "" }, + elements + .iter() + .map(|member| match member { + SetElement::Terminal { value, .. } => terminal_shape(value), + SetElement::Range { start, stop, .. } => format!("{start}..{stop}"), + }) + .collect::>() + .join(",") + ), + ElementKind::Block(nested) => format!("({})", block_shape(nested)), + ElementKind::Range(start, stop, _) => format!("{}..{}", start.value, stop.value), + ElementKind::Action { .. } => "{action}".to_owned(), + ElementKind::Predicate { .. } => "{predicate}?".to_owned(), + ElementKind::Epsilon => "ε".to_owned(), + }; + let quantifier = match element.quantifier { + Quantifier::One => "", + Quantifier::Optional { .. } => "?", + Quantifier::ZeroOrMore { .. } => "*", + Quantifier::OneOrMore { .. } => "+", + }; + format!("{label}{kind}{quantifier}") + } + + fn terminal_shape(terminal: &Terminal) -> String { + match terminal { + Terminal::Token(name) => name.clone(), + Terminal::Literal(literal) | Terminal::LexerCharSet(literal) => literal.clone(), + Terminal::Wildcard => ".".to_owned(), + Terminal::Eof => "EOF".to_owned(), + } + } + + fn candidate_lines(report: &TransformReport) -> Vec { + report + .candidates + .iter() + .map(|candidate| { + format!( + "{:?} {} [{}]: {}", + candidate.status, + candidate.entry_rule, + candidate + .call_sites + .iter() + .map(|site| format!("{}#{}", site.caller, site.alternative)) + .collect::>() + .join(", "), + candidate.reason + ) + }) + .collect() + } + + #[test] + fn inlines_multi_use_token_set_rules_and_is_idempotent() { + let (mut grammar, mut ids) = fixture( + r" +parser grammar P; +start : stmt+ EOF ; +stmt : kw ID SEMI | ID kw* SEMI ; +kw : 'select' | 'from' | KWTOK ; +", + ); + let registry = registry(EntryRuleConfig::default()); + let report = registry + .run(&mut grammar, &mut ids, false) + .expect("token-set inlining should apply"); + + insta::assert_debug_snapshot!( + "token_set_inline_shapes_and_candidates", + (shape(&grammar.units[0]), candidate_lines(&report)) + ); + assert_eq!(report.rule_removals.len(), 1); + assert_eq!(report.rule_removals[0].rule, "kw"); + assert!(report.entries[0].changed); + assert_eq!(report.entries[0].before.rules, 3); + assert_eq!(report.entries[0].after.rules, 2); + + let second = registry + .run(&mut grammar, &mut ids, false) + .expect("a second run should remain valid"); + assert!(!second.entries[0].changed); + assert!(second.candidates.is_empty()); + } + + #[test] + fn single_use_pure_sequence_is_inlined_as_a_block() { + let (mut grammar, mut ids) = fixture( + r" +parser grammar P; +start : item (COMMA item)* EOF ; +item : prefix ID ; +prefix : AT AT? ; +", + ); + let report = registry(EntryRuleConfig::default()) + .run(&mut grammar, &mut ids, false) + .expect("single-use inlining should apply"); + + insta::assert_debug_snapshot!( + "single_use_inline_shapes_and_candidates", + (shape(&grammar.units[0]), candidate_lines(&report)) + ); + assert_eq!( + grammar.units[0] + .rules + .iter() + .map(|rule| rule.name.as_str()) + .collect::>(), + ["start", "item"] + ); + } + + #[test] + fn alias_chains_collapse_through_the_fixpoint() { + let (mut grammar, mut ids) = fixture( + r" +parser grammar P; +start : a EOF ; +a : b ; +b : X | Y ; +", + ); + let report = registry(EntryRuleConfig::default()) + .run(&mut grammar, &mut ids, false) + .expect("alias chain should collapse"); + + insta::assert_debug_snapshot!( + "alias_chain_shapes_and_candidates", + (shape(&grammar.units[0]), candidate_lines(&report)) + ); + assert_eq!(grammar.units[0].rules.len(), 1); + } + + #[test] + fn labeled_call_sites_decline_the_whole_candidate() { + let (mut grammar, mut ids) = fixture( + r" +parser grammar P; +start : x=kw kw ID EOF ; +kw : A | B ; +", + ); + let report = registry(EntryRuleConfig::default()) + .run(&mut grammar, &mut ids, false) + .expect("declined candidates should not fail the pass"); + + assert!(!report.entries[0].changed); + assert_eq!(grammar.units[0].rules.len(), 2, "kw must be retained"); + insta::assert_debug_snapshot!("labeled_call_site_declines", candidate_lines(&report)); + } + + #[test] + fn nullable_and_recursive_single_use_rules_are_declined() { + let (mut grammar, mut ids) = fixture( + r" +parser grammar P; +start : wrap nul EOF ; +wrap : rec | X ; +rec : LP wrap RP ; +nul : AT? ; +", + ); + let report = registry(EntryRuleConfig::default()) + .run(&mut grammar, &mut ids, false) + .expect("declined candidates should not fail the pass"); + + assert!(!report.entries[0].changed); + insta::assert_debug_snapshot!("nullable_and_recursive_declines", candidate_lines(&report)); + } + + #[test] + fn configured_entry_rules_keep_their_api() { + let entries = EntryRuleConfig::new(["kw".to_owned()]); + let (mut grammar, mut ids) = fixture( + r" +parser grammar P; +start : kw ID EOF ; +kw : A | B ; +", + ); + grammar.preserved_rules = entries.matching_rule_ids(&grammar.units, &grammar.target_units); + let report = registry(entries) + .run(&mut grammar, &mut ids, false) + .expect("declined candidates should not fail the pass"); + + assert!(!report.entries[0].changed); + insta::assert_debug_snapshot!("configured_entry_declines", candidate_lines(&report)); + } + + #[test] + fn opaque_target_code_declines_every_candidate() { + let (mut grammar, mut ids) = fixture( + r" +parser grammar P; +@members { int depth; } +start : kw ID EOF ; +kw : A | B ; +", + ); + let report = registry(EntryRuleConfig::default()) + .run(&mut grammar, &mut ids, false) + .expect("declined candidates should not fail the pass"); + + assert!(!report.entries[0].changed); + insta::assert_debug_snapshot!("opaque_target_code_declines", candidate_lines(&report)); + } + + #[test] + fn report_only_projects_the_rewrite_without_mutating_the_grammar() { + let (mut grammar, mut ids) = fixture( + r" +parser grammar P; +start : stmt+ EOF ; +stmt : kw ID SEMI | ID kw* SEMI ; +kw : 'select' | 'from' | KWTOK ; +", + ); + let before = grammar.units.clone(); + let report = registry(EntryRuleConfig::default()) + .run(&mut grammar, &mut ids, true) + .expect("dry-run should analyze candidates"); + + assert_eq!(grammar.units, before); + assert_eq!( + report.candidates[0].status, + TransformCandidateStatus::Eligible + ); + assert_eq!(report.entries[0].before.rules, 3); + assert_eq!(report.entries[0].after.rules, 2); + } +} diff --git a/crates/antlr-rust-codegen/src/grammar/transform/passes/precedence_ladder.rs b/crates/antlr-rust-codegen/src/grammar/transform/passes/precedence_ladder.rs index ea9b7cf5..69b0a0b5 100644 --- a/crates/antlr-rust-codegen/src/grammar/transform/passes/precedence_ladder.rs +++ b/crates/antlr-rust-codegen/src/grammar/transform/passes/precedence_ladder.rs @@ -2,17 +2,20 @@ // Copyright (c) 2026 Konstantin Vyatkin use std::collections::{BTreeMap, BTreeSet}; +use crate::grammar::action::ActionReferenceParser; #[cfg(test)] use crate::grammar::action::action_references; -use crate::grammar::action::{ActionReferenceKind, ActionReferenceParser}; use crate::grammar::char_support::get_string_from_grammar_string_literal; use crate::grammar::diagnostic::Diagnostic; use crate::grammar::model::{ - Alternative, Authored, Block, Element, ElementKind, GrammarUnit, Label, ModelIdAllocator, - ModelNodeId, OptionDecl, Quantifier, Rule, RuleCall, RuleId, RuleKind, SetElement, Terminal, + Alternative, Authored, Block, Element, ElementKind, GrammarUnit, ModelIdAllocator, ModelNodeId, + OptionDecl, Quantifier, Rule, RuleCall, RuleId, RuleKind, SetElement, Terminal, }; use crate::grammar::provenance::{Origin, ProvenanceIndex, Tombstone}; -use crate::grammar::transform::analysis::AnalysisInvalidation; +use crate::grammar::transform::analysis::{ + AnalysisInvalidation, observed_rule_contexts, visit_elements, +}; +use crate::grammar::transform::clone::TransformCloner; use crate::grammar::transform::{ GrammarTransform, SafetyClass, TransformAlternativeMapping, TransformCandidateReport, TransformCandidateStatus, TransformContext, TransformGrammar, TransformLabelMapping, @@ -829,147 +832,6 @@ fn incoming_callers( incoming } -fn observed_rule_contexts( - unit: &GrammarUnit, - rules_by_name: &BTreeMap, - action_reference_parser: ActionReferenceParser, -) -> BTreeSet { - let mut observed = BTreeSet::new(); - let mut has_opaque_target_code = false; - for action in &unit.actions { - collect_target_code_rule_references( - &action.body, - rules_by_name, - &mut observed, - &mut has_opaque_target_code, - action_reference_parser, - ); - } - for rule in &unit.rules { - for clause in rule - .arguments - .iter() - .chain(rule.returns.iter()) - .chain(rule.locals.iter()) - { - collect_target_code_rule_references( - &clause.text, - rules_by_name, - &mut observed, - &mut has_opaque_target_code, - action_reference_parser, - ); - } - for action in &rule.actions { - collect_target_code_rule_references( - &action.body, - rules_by_name, - &mut observed, - &mut has_opaque_target_code, - action_reference_parser, - ); - } - for handler in &rule.catches { - collect_target_code_rule_references( - &handler.body, - rules_by_name, - &mut observed, - &mut has_opaque_target_code, - action_reference_parser, - ); - } - if let Some(action) = &rule.finally_action { - collect_target_code_rule_references( - &action.body, - rules_by_name, - &mut observed, - &mut has_opaque_target_code, - action_reference_parser, - ); - } - visit_elements(&rule.block, &mut |element| match &element.kind { - ElementKind::RuleCall(call) => { - if let Some(arguments) = &call.arguments { - collect_target_code_rule_references( - arguments, - rules_by_name, - &mut observed, - &mut has_opaque_target_code, - action_reference_parser, - ); - } - } - ElementKind::Action { body, .. } => { - collect_target_code_rule_references( - body, - rules_by_name, - &mut observed, - &mut has_opaque_target_code, - action_reference_parser, - ); - } - ElementKind::Predicate { body, fail, .. } => { - collect_target_code_rule_references( - body, - rules_by_name, - &mut observed, - &mut has_opaque_target_code, - action_reference_parser, - ); - if let Some(fail) = fail { - collect_target_code_rule_references( - fail, - rules_by_name, - &mut observed, - &mut has_opaque_target_code, - action_reference_parser, - ); - } - } - ElementKind::Terminal(_) - | ElementKind::Range(..) - | ElementKind::Set { .. } - | ElementKind::Block(_) - | ElementKind::Epsilon => {} - }); - } - if has_opaque_target_code { - observed.extend(rules_by_name.values().copied()); - } - observed -} - -fn collect_target_code_rule_references( - body: &str, - rules_by_name: &BTreeMap, - observed: &mut BTreeSet, - has_opaque_target_code: &mut bool, - action_reference_parser: ActionReferenceParser, -) { - *has_opaque_target_code |= !body.trim().is_empty(); - for reference in action_reference_parser(body) { - let name = match reference.kind { - ActionReferenceKind::Attribute { name, .. } - | ActionReferenceKind::Qualified { name, .. } => Some(name), - ActionReferenceKind::NonLocal { rule, .. } => Some(rule), - }; - if let Some(rule) = name.and_then(|name| rules_by_name.get(name)) { - observed.insert(*rule); - } - } -} - -fn visit_elements(block: &Block, visitor: &mut impl FnMut(&Element)) { - for alternative in &block.alternatives { - for element in &alternative.elements { - visitor(element); - if let ElementKind::Block(nested) = &element.kind { - visit_elements(nested, visitor); - } - } - } -} - #[derive(Debug)] struct OutputAlternative { source_rule: RuleId, @@ -1024,7 +886,7 @@ fn apply_plan( .entry((output.source_rule, output.source_alternative)) .or_default() .push(label.value.clone()); - let mut alternative = cloner.alternative(output, label); + let mut alternative = clone_output_alternative(&mut cloner, output, label); replace_ladder_calls(&mut alternative.elements, &included_names, &hub.name); rewritten.push(alternative); } @@ -1086,6 +948,7 @@ fn apply_plan( alternatives: alternative_mappings, labels: label_mappings, grouping_changes, + call_sites: Vec::new(), } } @@ -1278,124 +1141,33 @@ fn replace_ladder_calls(elements: &mut [Element], names: &BTreeSet, hub: } } -struct TransformCloner<'a> { - ids: &'a mut ModelIdAllocator, - provenance: &'a mut ProvenanceIndex, - pass: crate::grammar::model::TransformId, -} - -impl TransformCloner<'_> { - fn alternative(&mut self, output: &OutputAlternative, label: Authored) -> Alternative { - let id = self.ids.alternative(); - self.record( - ModelNodeId::Alternative(id), - ModelNodeId::Alternative(output.source.id), - ); - let options = if output.right_associative { - vec![right_association_option(&output.source)] - } else { - output.source.options.clone() - }; - Alternative { - id, - elements: output - .elements - .iter() - .map(|element| self.element(element)) - .collect(), - label: Some(label), - options, - commands: Vec::new(), - syntax: output.source.syntax, - span: output.source.span.clone(), - } - } - - fn element(&mut self, source: &Element) -> Element { - let mut cloned = source.clone(); - cloned.id = self.ids.element(); - cloned.label = source.label.as_ref().map(|label| self.label(label)); - cloned.kind = match &source.kind { - ElementKind::Block(block) => ElementKind::Block(Block { - alternatives: block - .alternatives - .iter() - .map(|alternative| self.nested_alternative(alternative)) - .collect(), - options: block.options.clone(), - syntax: block.syntax, - span: block.span.clone(), - }), - ElementKind::Action { id, body } => { - let cloned_id = self.ids.action(); - self.record(ModelNodeId::Action(cloned_id), ModelNodeId::Action(*id)); - ElementKind::Action { - id: cloned_id, - body: body.clone(), - } - } - ElementKind::Predicate { - id, - body, - fail, - precedence, - } => { - let cloned_id = self.ids.predicate(); - self.record( - ModelNodeId::Predicate(cloned_id), - ModelNodeId::Predicate(*id), - ); - ElementKind::Predicate { - id: cloned_id, - body: body.clone(), - fail: fail.clone(), - precedence: *precedence, - } - } - kind => kind.clone(), - }; - self.record( - ModelNodeId::Element(cloned.id), - ModelNodeId::Element(source.id), - ); - cloned - } - - fn nested_alternative(&mut self, source: &Alternative) -> Alternative { - let id = self.ids.alternative(); - self.record( - ModelNodeId::Alternative(id), - ModelNodeId::Alternative(source.id), - ); - Alternative { - id, - elements: source - .elements - .iter() - .map(|element| self.element(element)) - .collect(), - label: source.label.clone(), - options: source.options.clone(), - commands: source.commands.clone(), - syntax: source.syntax, - span: source.span.clone(), - } - } - - fn label(&mut self, source: &Label) -> Label { - let mut cloned = source.clone(); - cloned.id = self.ids.label(); - self.record(ModelNodeId::Label(cloned.id), ModelNodeId::Label(source.id)); - cloned - } - - fn record(&mut self, destination: ModelNodeId, source: ModelNodeId) { - let mut origins = self.provenance.origins(source).to_vec(); - origins.push(Origin::OptionalTransform { - pass: self.pass, - inputs: Box::new([source]), - }); - self.provenance.record_model(destination, origins); +fn clone_output_alternative( + cloner: &mut TransformCloner<'_>, + output: &OutputAlternative, + label: Authored, +) -> Alternative { + let id = cloner.ids.alternative(); + cloner.record( + ModelNodeId::Alternative(id), + ModelNodeId::Alternative(output.source.id), + ); + let options = if output.right_associative { + vec![right_association_option(&output.source)] + } else { + output.source.options.clone() + }; + Alternative { + id, + elements: output + .elements + .iter() + .map(|element| cloner.element(element)) + .collect(), + label: Some(label), + options, + commands: Vec::new(), + syntax: output.source.syntax, + span: output.source.span.clone(), } } @@ -1625,6 +1397,7 @@ fn declined_report( alternatives: Vec::new(), labels: Vec::new(), grouping_changes: Vec::new(), + call_sites: Vec::new(), } } diff --git a/crates/antlr-rust-codegen/src/grammar/transform/passes/prune_unreachable.rs b/crates/antlr-rust-codegen/src/grammar/transform/passes/prune_unreachable.rs index 3e4dfd6d..79fedd08 100644 --- a/crates/antlr-rust-codegen/src/grammar/transform/passes/prune_unreachable.rs +++ b/crates/antlr-rust-codegen/src/grammar/transform/passes/prune_unreachable.rs @@ -3,10 +3,10 @@ use std::collections::BTreeSet; use crate::grammar::diagnostic::Diagnostic; -use crate::grammar::model::{Block, ElementKind, ModelIdAllocator, Rule, RuleId}; -use crate::grammar::provenance::{ProvenanceIndex, Tombstone}; +use crate::grammar::model::{ModelIdAllocator, RuleId}; use crate::grammar::rule_reachability::{EntryRuleConfig, analyze}; use crate::grammar::transform::analysis::AnalysisInvalidation; +use crate::grammar::transform::clone::tombstone_rule; use crate::grammar::transform::{ GrammarTransform, SafetyClass, TransformContext, TransformGrammar, TransformReport, TransformRuleRemoval, @@ -67,7 +67,12 @@ impl GrammarTransform for PruneUnreachableRules { rule: rule.name.clone(), source_span: rule.name_span.clone(), }); - tombstone_rule(&mut grammar.provenance, rule); + tombstone_rule( + &mut grammar.provenance, + rule, + "unreachable parser rule pruned", + &[], + ); } unit.rules.retain(|rule| !unreachable.contains(&rule.id)); remove_mode_rule_references(unit, &unreachable); @@ -85,46 +90,3 @@ fn remove_mode_rule_references( mode.rules.retain(|rule| !removed.contains(rule)); } } - -fn tombstone_rule(provenance: &mut ProvenanceIndex, rule: &Rule) { - tombstone(provenance, rule.syntax); - for action in &rule.actions { - tombstone(provenance, action.syntax); - } - for handler in &rule.catches { - tombstone(provenance, handler.syntax); - } - if let Some(action) = &rule.finally_action { - tombstone(provenance, action.syntax); - } - tombstone_block(provenance, &rule.block); -} - -fn tombstone_block(provenance: &mut ProvenanceIndex, block: &Block) { - for alternative in &block.alternatives { - tombstone(provenance, alternative.syntax); - if let Some(label) = &alternative.label { - tombstone(provenance, label.syntax); - } - for element in &alternative.elements { - tombstone(provenance, element.syntax); - if let Some(label) = &element.label { - tombstone(provenance, label.syntax); - } - if let ElementKind::Block(nested) = &element.kind { - tombstone_block(provenance, nested); - } - } - } -} - -fn tombstone(provenance: &mut ProvenanceIndex, syntax: crate::grammar::frontend::SyntaxId) { - provenance.tombstone( - syntax, - Tombstone { - phase: "optional-transform", - reason: "unreachable parser rule pruned", - replacements: Box::new([]), - }, - ); -} diff --git a/crates/antlr-rust-codegen/src/grammar/transform/passes/snapshots/antlr_rust_codegen__grammar__transform__passes__inline_trivial__tests__alias_chain_shapes_and_candidates.snap b/crates/antlr-rust-codegen/src/grammar/transform/passes/snapshots/antlr_rust_codegen__grammar__transform__passes__inline_trivial__tests__alias_chain_shapes_and_candidates.snap new file mode 100644 index 00000000..eb81029a --- /dev/null +++ b/crates/antlr-rust-codegen/src/grammar/transform/passes/snapshots/antlr_rust_codegen__grammar__transform__passes__inline_trivial__tests__alias_chain_shapes_and_candidates.snap @@ -0,0 +1,13 @@ +--- +source: crates/antlr-rust-codegen/src/grammar/transform/passes/inline_trivial.rs +expression: "(shape(&grammar.units[0]), candidate_lines(&report))" +--- +( + [ + "start : ({X,Y}) EOF", + ], + [ + "Applied a [start#1]: inlined single-use rule a into its call site in start", + "Applied b [start#1]: inlined token-set rule b (2 members) into 1 call site", + ], +) diff --git a/crates/antlr-rust-codegen/src/grammar/transform/passes/snapshots/antlr_rust_codegen__grammar__transform__passes__inline_trivial__tests__configured_entry_declines.snap b/crates/antlr-rust-codegen/src/grammar/transform/passes/snapshots/antlr_rust_codegen__grammar__transform__passes__inline_trivial__tests__configured_entry_declines.snap new file mode 100644 index 00000000..9bda6d8a --- /dev/null +++ b/crates/antlr-rust-codegen/src/grammar/transform/passes/snapshots/antlr_rust_codegen__grammar__transform__passes__inline_trivial__tests__configured_entry_declines.snap @@ -0,0 +1,7 @@ +--- +source: crates/antlr-rust-codegen/src/grammar/transform/passes/inline_trivial.rs +expression: candidate_lines(&report) +--- +[ + "Declined kw [start#1]: configured parser entry rules keep their generated API", +] diff --git a/crates/antlr-rust-codegen/src/grammar/transform/passes/snapshots/antlr_rust_codegen__grammar__transform__passes__inline_trivial__tests__labeled_call_site_declines.snap b/crates/antlr-rust-codegen/src/grammar/transform/passes/snapshots/antlr_rust_codegen__grammar__transform__passes__inline_trivial__tests__labeled_call_site_declines.snap new file mode 100644 index 00000000..80105127 --- /dev/null +++ b/crates/antlr-rust-codegen/src/grammar/transform/passes/snapshots/antlr_rust_codegen__grammar__transform__passes__inline_trivial__tests__labeled_call_site_declines.snap @@ -0,0 +1,7 @@ +--- +source: crates/antlr-rust-codegen/src/grammar/transform/passes/inline_trivial.rs +expression: candidate_lines(&report) +--- +[ + "Declined kw [start#1, start#1]: a call site binds a label to the rule context in rule start", +] diff --git a/crates/antlr-rust-codegen/src/grammar/transform/passes/snapshots/antlr_rust_codegen__grammar__transform__passes__inline_trivial__tests__nullable_and_recursive_declines.snap b/crates/antlr-rust-codegen/src/grammar/transform/passes/snapshots/antlr_rust_codegen__grammar__transform__passes__inline_trivial__tests__nullable_and_recursive_declines.snap new file mode 100644 index 00000000..4e91e97d --- /dev/null +++ b/crates/antlr-rust-codegen/src/grammar/transform/passes/snapshots/antlr_rust_codegen__grammar__transform__passes__inline_trivial__tests__nullable_and_recursive_declines.snap @@ -0,0 +1,8 @@ +--- +source: crates/antlr-rust-codegen/src/grammar/transform/passes/inline_trivial.rs +expression: candidate_lines(&report) +--- +[ + "Declined rec [wrap#1]: recursive rules cannot be inlined", + "Declined nul [start#1]: a nullable body can change decision ownership at the call site", +] diff --git a/crates/antlr-rust-codegen/src/grammar/transform/passes/snapshots/antlr_rust_codegen__grammar__transform__passes__inline_trivial__tests__opaque_target_code_declines.snap b/crates/antlr-rust-codegen/src/grammar/transform/passes/snapshots/antlr_rust_codegen__grammar__transform__passes__inline_trivial__tests__opaque_target_code_declines.snap new file mode 100644 index 00000000..d6dfffb7 --- /dev/null +++ b/crates/antlr-rust-codegen/src/grammar/transform/passes/snapshots/antlr_rust_codegen__grammar__transform__passes__inline_trivial__tests__opaque_target_code_declines.snap @@ -0,0 +1,7 @@ +--- +source: crates/antlr-rust-codegen/src/grammar/transform/passes/inline_trivial.rs +expression: candidate_lines(&report) +--- +[ + "Declined kw [start#1]: grammar target code observes the rule context", +] diff --git a/crates/antlr-rust-codegen/src/grammar/transform/passes/snapshots/antlr_rust_codegen__grammar__transform__passes__inline_trivial__tests__single_use_inline_shapes_and_candidates.snap b/crates/antlr-rust-codegen/src/grammar/transform/passes/snapshots/antlr_rust_codegen__grammar__transform__passes__inline_trivial__tests__single_use_inline_shapes_and_candidates.snap new file mode 100644 index 00000000..eb887d63 --- /dev/null +++ b/crates/antlr-rust-codegen/src/grammar/transform/passes/snapshots/antlr_rust_codegen__grammar__transform__passes__inline_trivial__tests__single_use_inline_shapes_and_candidates.snap @@ -0,0 +1,13 @@ +--- +source: crates/antlr-rust-codegen/src/grammar/transform/passes/inline_trivial.rs +expression: "(shape(&grammar.units[0]), candidate_lines(&report))" +--- +( + [ + "start : item (COMMA item)* EOF", + "item : (AT AT?) ID", + ], + [ + "Applied prefix [item#1]: inlined single-use rule prefix into its call site in item", + ], +) diff --git a/crates/antlr-rust-codegen/src/grammar/transform/passes/snapshots/antlr_rust_codegen__grammar__transform__passes__inline_trivial__tests__token_set_inline_shapes_and_candidates.snap b/crates/antlr-rust-codegen/src/grammar/transform/passes/snapshots/antlr_rust_codegen__grammar__transform__passes__inline_trivial__tests__token_set_inline_shapes_and_candidates.snap new file mode 100644 index 00000000..616cfdde --- /dev/null +++ b/crates/antlr-rust-codegen/src/grammar/transform/passes/snapshots/antlr_rust_codegen__grammar__transform__passes__inline_trivial__tests__token_set_inline_shapes_and_candidates.snap @@ -0,0 +1,13 @@ +--- +source: crates/antlr-rust-codegen/src/grammar/transform/passes/inline_trivial.rs +expression: "(shape(&grammar.units[0]), candidate_lines(&report))" +--- +( + [ + "start : stmt+ EOF", + "stmt : {'select','from',KWTOK} ID SEMI | ID {'select','from',KWTOK}* SEMI", + ], + [ + "Applied kw [stmt#1, stmt#2]: inlined token-set rule kw (3 members) into 2 call sites", + ], +) diff --git a/crates/antlr-rust-codegen/src/grammar/transform/passes/snapshots/antlr_rust_codegen__grammar__transform__passes__precedence_ladder__tests__cel_ladder_collapse.snap b/crates/antlr-rust-codegen/src/grammar/transform/passes/snapshots/antlr_rust_codegen__grammar__transform__passes__precedence_ladder__tests__cel_ladder_collapse.snap index 65d327b1..5ad70cdc 100644 --- a/crates/antlr-rust-codegen/src/grammar/transform/passes/snapshots/antlr_rust_codegen__grammar__transform__passes__precedence_ladder__tests__cel_ladder_collapse.snap +++ b/crates/antlr-rust-codegen/src/grammar/transform/passes/snapshots/antlr_rust_codegen__grammar__transform__passes__precedence_ladder__tests__cel_ladder_collapse.snap @@ -1,6 +1,5 @@ --- source: crates/antlr-rust-codegen/src/grammar/transform/passes/precedence_ladder.rs -assertion_line: 1671 expression: "(summarize(&grammar.units[0]), &report.entries, &report.candidates,)" --- ( @@ -306,6 +305,7 @@ expression: "(summarize(&grammar.units[0]), &report.entries, &report.candidates, "conditionalOr", "conditionalAnd", ], + call_sites: [], }, ], ) diff --git a/crates/antlr-rust-codegen/src/optimization/config.rs b/crates/antlr-rust-codegen/src/optimization/config.rs index 9ee51911..60697f24 100644 --- a/crates/antlr-rust-codegen/src/optimization/config.rs +++ b/crates/antlr-rust-codegen/src/optimization/config.rs @@ -6,12 +6,14 @@ use crate::config::CompilerConfig; use crate::error::Error; use crate::grammar::compiler::Compilation; use crate::grammar::rule_reachability::EntryRuleConfig; +use crate::grammar::transform::passes::inline_trivial::InlineTrivialRules; use crate::grammar::transform::passes::precedence_ladder::CollapsePrecedenceLadders; use crate::grammar::transform::passes::prune_unreachable::PruneUnreachableRules; use crate::grammar::transform::{GrammarTransform, TransformRegistry}; use super::descriptor::{ - COLLAPSE_PRECEDENCE_LADDERS, OptimizationStage, PRUNE_UNREACHABLE_RULES, PassDescriptor, + COLLAPSE_PRECEDENCE_LADDERS, INLINE_TRIVIAL_RULES, OptimizationStage, PRUNE_UNREACHABLE_RULES, + PassDescriptor, }; use super::report::pruned_unreachable_rule_messages; @@ -39,11 +41,19 @@ struct PassRegistration { struct SelectionSettings { entry_rules: EntryRuleConfig, prune_unreachable: bool, + trivial_inlining: Option, precedence_ladders: Option, } impl SelectionSettings { fn from_compiler_config(config: &CompilerConfig) -> Self { + let trivial_inlining = if config.report_trivial_rules { + Some(PassExecution::ReportOnly) + } else if config.inline_trivial_rules { + Some(PassExecution::Apply) + } else { + None + }; let precedence_ladders = if config.report_precedence_ladders { Some(PassExecution::ReportOnly) } else if config.optimize_precedence_ladders { @@ -54,6 +64,7 @@ impl SelectionSettings { Self { entry_rules: EntryRuleConfig::new(config.entry_rules.iter().cloned()), prune_unreachable: config.prune_unreachable, + trivial_inlining, precedence_ladders, } } @@ -78,6 +89,10 @@ const PASS_REGISTRY: &[PassRegistration] = &[ descriptor: &PRUNE_UNREACHABLE_RULES, configure: configure_prune_unreachable, }, + PassRegistration { + descriptor: &INLINE_TRIVIAL_RULES, + configure: configure_trivial_inlining, + }, PassRegistration { descriptor: &COLLAPSE_PRECEDENCE_LADDERS, configure: configure_precedence_ladders, @@ -143,6 +158,17 @@ fn configure_prune_unreachable(settings: &SelectionSettings) -> Option Option { + settings + .trivial_inlining + .map(|execution| ConfiguredGrammarPass { + transform: Box::new(InlineTrivialRules::new(settings.entry_rules.clone())), + execution, + emit_manifest: true, + messages: None, + }) +} + fn configure_precedence_ladders(settings: &SelectionSettings) -> Option { settings .precedence_ladders @@ -251,6 +277,7 @@ mod tests { let plan = OptimizationPlan::from_settings(SelectionSettings { entry_rules: EntryRuleConfig::default(), prune_unreachable: true, + trivial_inlining: Some(PassExecution::Apply), precedence_ladders: Some(PassExecution::Apply), }) .expect("registered optimization passes should be valid"); @@ -262,6 +289,7 @@ mod tests { .collect::>(), [ (PruneUnreachableRules::NAME, 100), + (InlineTrivialRules::NAME, 150), (CollapsePrecedenceLadders::NAME, 200), ] ); @@ -273,6 +301,7 @@ mod tests { let plan = OptimizationPlan::from_settings(SelectionSettings { entry_rules: EntryRuleConfig::default(), prune_unreachable: true, + trivial_inlining: None, precedence_ladders: Some(PassExecution::ReportOnly), }) .expect("registered optimization passes should be valid"); @@ -280,4 +309,24 @@ mod tests { assert!(plan.report_only()); assert_eq!(plan.selected.len(), 2); } + + #[test] + fn report_only_trivial_inlining_shadows_the_whole_selection() { + let plan = OptimizationPlan::from_settings(SelectionSettings { + entry_rules: EntryRuleConfig::default(), + prune_unreachable: false, + trivial_inlining: Some(PassExecution::ReportOnly), + precedence_ladders: None, + }) + .expect("registered optimization passes should be valid"); + + assert!(plan.report_only()); + assert_eq!( + plan.selected + .iter() + .map(|pass| pass.descriptor.id) + .collect::>(), + [InlineTrivialRules::NAME] + ); + } } diff --git a/crates/antlr-rust-codegen/src/optimization/descriptor.rs b/crates/antlr-rust-codegen/src/optimization/descriptor.rs index dbc16ba5..e671d443 100644 --- a/crates/antlr-rust-codegen/src/optimization/descriptor.rs +++ b/crates/antlr-rust-codegen/src/optimization/descriptor.rs @@ -1,6 +1,7 @@ // SPDX-License-Identifier: BSD-3-Clause // Copyright (c) 2026 Konstantin Vyatkin use crate::grammar::transform::SafetyClass; +use crate::grammar::transform::passes::inline_trivial::InlineTrivialRules; use crate::grammar::transform::passes::precedence_ladder::CollapsePrecedenceLadders; use crate::grammar::transform::passes::prune_unreachable::PruneUnreachableRules; @@ -28,6 +29,15 @@ pub(super) const PRUNE_UNREACHABLE_RULES: PassDescriptor = PassDescriptor { conflicts: &[], }; +pub(super) const INLINE_TRIVIAL_RULES: PassDescriptor = PassDescriptor { + id: InlineTrivialRules::NAME, + stage: OptimizationStage::IntegratedGrammar, + safety: SafetyClass::RecognitionPreserving, + canonical_order: 150, + prerequisites: &[], + conflicts: &[], +}; + pub(super) const COLLAPSE_PRECEDENCE_LADDERS: PassDescriptor = PassDescriptor { id: CollapsePrecedenceLadders::NAME, stage: OptimizationStage::IntegratedGrammar, diff --git a/crates/antlr-rust-codegen/src/testrig_cli.rs b/crates/antlr-rust-codegen/src/testrig_cli.rs index bc4d720e..358eb57e 100644 --- a/crates/antlr-rust-codegen/src/testrig_cli.rs +++ b/crates/antlr-rust-codegen/src/testrig_cli.rs @@ -164,6 +164,8 @@ impl CliArgs { fixed_lookahead: self.fixed_lookahead.map(usize::from), entry_rules: BTreeSet::new(), prune_unreachable: false, + inline_trivial_rules: false, + report_trivial_rules: false, optimize_precedence_ladders: false, report_precedence_ladders: false, test_rig: Some(TestRigConfig { diff --git a/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__cli__antlr4_rust_gen_help.snap b/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__cli__antlr4_rust_gen_help.snap index a80a9e53..11087e1a 100644 --- a/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__cli__antlr4_rust_gen_help.snap +++ b/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__cli__antlr4_rust_gen_help.snap @@ -42,6 +42,10 @@ Options: Declare a parser entry rule by bare name (repeatable) --prune-unreachable Remove parser rules unreachable from every entry rule + --inline-trivial-rules + Inline trivial pure parser rules into their call sites (changes tree/API) + --report-trivial-rules + Dry-run trivial-rule inlining and emit only optimizations.json --optimize-precedence-ladders Collapse proven linear precedence ladders (changes tree/API) --report-precedence-ladders diff --git a/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__transforms__trivial_inline_optimization_manifest.snap b/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__transforms__trivial_inline_optimization_manifest.snap new file mode 100644 index 00000000..37203caf --- /dev/null +++ b/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__transforms__trivial_inline_optimization_manifest.snap @@ -0,0 +1,332 @@ +--- +source: crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/transforms.rs +expression: stable_manifest +--- +{ + "version": 1, + "reportOnly": false, + "passes": [ + { + "id": 0, + "name": "inline-trivial-rules", + "safetyClass": "recognition-preserving", + "changed": true, + "metrics": { + "before": { + "rules": 21, + "alternatives": 24, + "elements": 37 + }, + "after": { + "rules": 18, + "alternatives": 23, + "elements": 36 + } + }, + "candidates": [ + { + "grammar": "InlineParser", + "entryRule": "kw", + "source": { + "path": "$CARGO_MANIFEST_DIR/tests/fixtures/antlr4-rust-gen/trivial-inline/Inline.g4", + "byteStart": 205, + "byteEnd": 255, + "start": { + "line": 15, + "column": 0 + }, + "end": { + "line": 19, + "column": 5 + } + }, + "status": "applied", + "reason": "inlined token-set rule kw (3 members) into 2 call sites", + "rungs": [], + "boundaryRule": null, + "projected": null, + "removedRules": [ + { + "rule": "kw", + "targetRule": "kw" + } + ], + "alternatives": [], + "labelRenames": [], + "groupingChanges": [], + "inlinedCallSites": [ + { + "caller": "stmt", + "alternative": 1, + "source": { + "path": "$CARGO_MANIFEST_DIR/tests/fixtures/antlr4-rust-gen/trivial-inline/Inline.g4", + "byteStart": 57, + "byteEnd": 59, + "start": { + "line": 8, + "column": 6 + }, + "end": { + "line": 8, + "column": 8 + } + } + }, + { + "caller": "stmt", + "alternative": 2, + "source": { + "path": "$CARGO_MANIFEST_DIR/tests/fixtures/antlr4-rust-gen/trivial-inline/Inline.g4", + "byteStart": 85, + "byteEnd": 87, + "start": { + "line": 9, + "column": 13 + }, + "end": { + "line": 9, + "column": 15 + } + } + } + ] + }, + { + "grammar": "InlineParser", + "entryRule": "qualifier", + "source": { + "path": "$CARGO_MANIFEST_DIR/tests/fixtures/antlr4-rust-gen/trivial-inline/Inline.g4", + "byteStart": 391, + "byteEnd": 419, + "start": { + "line": 27, + "column": 0 + }, + "end": { + "line": 29, + "column": 5 + } + }, + "status": "applied", + "reason": "inlined single-use rule qualifier into its call site in target", + "rungs": [], + "boundaryRule": null, + "projected": null, + "removedRules": [ + { + "rule": "qualifier", + "targetRule": "qualifier" + } + ], + "alternatives": [], + "labelRenames": [], + "groupingChanges": [], + "inlinedCallSites": [ + { + "caller": "target", + "alternative": 1, + "source": { + "path": "$CARGO_MANIFEST_DIR/tests/fixtures/antlr4-rust-gen/trivial-inline/Inline.g4", + "byteStart": 311, + "byteEnd": 320, + "start": { + "line": 23, + "column": 6 + }, + "end": { + "line": 23, + "column": 15 + } + } + } + ] + }, + { + "grammar": "InlineParser", + "entryRule": "group", + "source": { + "path": "$CARGO_MANIFEST_DIR/tests/fixtures/antlr4-rust-gen/trivial-inline/Inline.g4", + "byteStart": 672, + "byteEnd": 706, + "start": { + "line": 43, + "column": 0 + }, + "end": { + "line": 45, + "column": 5 + } + }, + "status": "applied", + "reason": "inlined single-use rule group into its call site in stmt", + "rungs": [], + "boundaryRule": null, + "projected": null, + "removedRules": [ + { + "rule": "group", + "targetRule": "group" + } + ], + "alternatives": [], + "labelRenames": [], + "groupingChanges": [], + "inlinedCallSites": [ + { + "caller": "stmt", + "alternative": 4, + "source": { + "path": "$CARGO_MANIFEST_DIR/tests/fixtures/antlr4-rust-gen/trivial-inline/Inline.g4", + "byteStart": 118, + "byteEnd": 123, + "start": { + "line": 11, + "column": 6 + }, + "end": { + "line": 11, + "column": 11 + } + } + } + ] + }, + { + "grammar": "InlineParser", + "entryRule": "marked", + "source": { + "path": "$CARGO_MANIFEST_DIR/tests/fixtures/antlr4-rust-gen/trivial-inline/Inline.g4", + "byteStart": 473, + "byteEnd": 506, + "start": { + "line": 32, + "column": 0 + }, + "end": { + "line": 34, + "column": 5 + } + }, + "status": "declined", + "reason": "element labels bind generated accessors", + "rungs": [], + "boundaryRule": null, + "projected": null, + "removedRules": [], + "alternatives": [], + "labelRenames": [], + "groupingChanges": [], + "inlinedCallSites": [ + { + "caller": "stmt", + "alternative": 3, + "source": { + "path": "$CARGO_MANIFEST_DIR/tests/fixtures/antlr4-rust-gen/trivial-inline/Inline.g4", + "byteStart": 100, + "byteEnd": 106, + "start": { + "line": 10, + "column": 6 + }, + "end": { + "line": 10, + "column": 12 + } + } + } + ] + }, + { + "grammar": "InlineParser", + "entryRule": "badge", + "source": { + "path": "$CARGO_MANIFEST_DIR/tests/fixtures/antlr4-rust-gen/trivial-inline/Inline.g4", + "byteStart": 579, + "byteEnd": 612, + "start": { + "line": 37, + "column": 0 + }, + "end": { + "line": 40, + "column": 5 + } + }, + "status": "declined", + "reason": "a call site binds a label to the rule context in rule marked", + "rungs": [], + "boundaryRule": null, + "projected": null, + "removedRules": [], + "alternatives": [], + "labelRenames": [], + "groupingChanges": [], + "inlinedCallSites": [ + { + "caller": "marked", + "alternative": 1, + "source": { + "path": "$CARGO_MANIFEST_DIR/tests/fixtures/antlr4-rust-gen/trivial-inline/Inline.g4", + "byteStart": 492, + "byteEnd": 497, + "start": { + "line": 33, + "column": 12 + }, + "end": { + "line": 33, + "column": 17 + } + } + } + ] + }, + { + "grammar": "InlineParser", + "entryRule": "opt", + "source": { + "path": "$CARGO_MANIFEST_DIR/tests/fixtures/antlr4-rust-gen/trivial-inline/Inline.g4", + "byteStart": 747, + "byteEnd": 769, + "start": { + "line": 48, + "column": 0 + }, + "end": { + "line": 50, + "column": 5 + } + }, + "status": "declined", + "reason": "a nullable body can change decision ownership at the call site", + "rungs": [], + "boundaryRule": null, + "projected": null, + "removedRules": [], + "alternatives": [], + "labelRenames": [], + "groupingChanges": [], + "inlinedCallSites": [ + { + "caller": "stmt", + "alternative": 4, + "source": { + "path": "$CARGO_MANIFEST_DIR/tests/fixtures/antlr4-rust-gen/trivial-inline/Inline.g4", + "byteStart": 694, + "byteEnd": 697, + "start": { + "line": 44, + "column": 16 + }, + "end": { + "line": 44, + "column": 19 + } + } + } + ] + } + ] + } + ] +} diff --git a/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/transforms.rs b/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/transforms.rs index 80f094c5..d34b88f6 100644 --- a/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/transforms.rs +++ b/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/transforms.rs @@ -1000,3 +1000,241 @@ fn embedded_rule_attributes_keep_potentially_referenced_ladder_contexts() { &["attribute_ladder_lexer.rs", "attribute_ladder_parser.rs"], ); } + +#[test] +fn trivial_rule_inlining_is_explicit_auditable_and_recognition_preserving() { + let temp = temporary_directory("trivial-inline"); + let grammar = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/antlr4-rust-gen/trivial-inline/Inline.g4"); + let baseline = temp.path().join("baseline"); + let optimized = temp.path().join("optimized"); + let report = temp.path().join("report"); + + for (out, extra) in [ + (&baseline, None), + (&optimized, Some("--inline-trivial-rules")), + (&report, Some("--report-trivial-rules")), + ] { + let mut args = vec![ + grammar.as_os_str(), + OsStr::new("--out-dir"), + out.as_os_str(), + ]; + if let Some(flag) = extra { + args.push(OsStr::new(flag)); + } + let output = run_antlr4_rust_gen(&args); + assert!( + output.status.success(), + "{extra:?} failed\nstdout: {}\nstderr: {}", + utf8(&output.stdout), + utf8(&output.stderr) + ); + } + + assert!(!baseline.join("optimizations.json").exists()); + let report_files = fs::read_dir(&report) + .expect("report directory should exist") + .map(|entry| { + entry + .expect("report entry should be readable") + .file_name() + .to_string_lossy() + .into_owned() + }) + .collect::>(); + assert_eq!(report_files, ["optimizations.json"]); + + let manifest = fs::read_to_string(optimized.join("optimizations.json")) + .expect("applied optimization manifest should be emitted"); + let stable_manifest = manifest.replace(env!("CARGO_MANIFEST_DIR"), "$CARGO_MANIFEST_DIR"); + insta::assert_snapshot!("trivial_inline_optimization_manifest", stable_manifest); + let dry_run_manifest = fs::read_to_string(report.join("optimizations.json")) + .expect("dry-run optimization manifest should be emitted"); + assert!(dry_run_manifest.contains("\"reportOnly\": true")); + assert!(dry_run_manifest.contains("\"status\": \"eligible\"")); + assert!(dry_run_manifest.contains("\"status\": \"declined\"")); + assert!(!dry_run_manifest.contains("\"status\": \"applied\"")); + + let baseline_parser = fs::read_to_string(baseline.join("inline_parser.rs")) + .expect("baseline parser should be emitted"); + let optimized_parser = fs::read_to_string(optimized.join("inline_parser.rs")) + .expect("optimized parser should be emitted"); + for retained in ["pub fn kw(", "pub fn qualifier(", "pub fn group("] { + assert!( + baseline_parser.contains(retained), + "baseline lost {retained}" + ); + assert!( + !optimized_parser.contains(retained), + "optimized parser should inline away {retained}" + ); + } + for kept in [ + "pub fn target(", + "pub fn marked(", + "pub fn badge(", + "pub fn opt(", + ] { + assert!( + optimized_parser.contains(kept), + "declined or shared rule {kept} must keep its API" + ); + } + + let differential = temp.path().join("differential"); + let generated = differential.join("generated"); + fs::create_dir_all(&generated).expect("differential source directory"); + for (source, destination) in [ + ( + baseline.join("inline_lexer.rs"), + generated.join("baseline_inline_lexer.rs"), + ), + ( + baseline.join("inline_parser.rs"), + generated.join("baseline_inline_parser.rs"), + ), + ( + optimized.join("inline_lexer.rs"), + generated.join("optimized_inline_lexer.rs"), + ), + ( + optimized.join("inline_parser.rs"), + generated.join("optimized_inline_parser.rs"), + ), + ] { + fs::copy(source, destination).expect("generated differential module should be copied"); + } + assert_generated_project( + &differential, + &[ + "baseline_inline_lexer.rs", + "baseline_inline_parser.rs", + "optimized_inline_lexer.rs", + "optimized_inline_parser.rs", + ], + r#" +#[cfg(test)] +mod trivial_inline_differential { + use super::{ + baseline_inline_lexer, baseline_inline_parser, optimized_inline_lexer, + optimized_inline_parser, + }; + use antlr4_runtime::{IntStream as _, Parser as _}; + + #[derive(Debug)] + struct ParseOutcome { + completed: bool, + syntax_errors: usize, + token_index: usize, + } + + impl ParseOutcome { + fn accepted(&self) -> bool { + self.completed && self.syntax_errors == 0 + } + } + + macro_rules! parse_result { + ($name:ident, $lexer:ident, $parser:ident, $entry:ident) => { + fn $name(input: &str) -> ParseOutcome { + match $parser::parse_with_parser( + input, + $lexer::InlineLexer::new, + $parser::InlineParser::$entry, + ) { + Ok(output) => { + let syntax_errors = output.parser.number_of_syntax_errors(); + let token_index = output.parser.into_token_stream().index(); + ParseOutcome { + completed: true, + syntax_errors, + token_index, + } + } + Err(_) => ParseOutcome { + completed: false, + syntax_errors: usize::MAX, + token_index: 0, + }, + } + } + }; + } + + fn assert_same_recognition(input: &str, baseline: ParseOutcome, optimized: ParseOutcome) { + assert_eq!( + optimized.accepted(), + baseline.accepted(), + "recognition diverged for {input:?}: baseline={baseline:?}, optimized={optimized:?}" + ); + if baseline.accepted() { + assert_eq!( + optimized.token_index, baseline.token_index, + "valid-input consumption diverged for {input:?}: \ + baseline={baseline:?}, optimized={optimized:?}" + ); + } + } + + parse_result!(baseline, baseline_inline_lexer, baseline_inline_parser, start); + parse_result!( + optimized, + optimized_inline_lexer, + optimized_inline_parser, + start + ); + + #[test] + fn valid_and_invalid_inputs_keep_the_authored_language() { + for input in [ + "select @x;", + "from @@abc;", + "@x select where from;", + "@@y;", + "*z; +q;", + "(@x,);", + "(@@deep);", + "select @a; @b from; (@c,); *d;", + ] { + let baseline = baseline(input); + assert!( + baseline.accepted(), + "baseline should accept {input:?}: {baseline:?}" + ); + assert_same_recognition(input, baseline, optimized(input)); + } + for input in [ + "select;", + "@x", + "select from @x;", + "* ;", + "(@x;", + "(@x,,);", + ",;", + "x;", + "select @x", + ] { + let baseline = baseline(input); + assert!( + !baseline.accepted(), + "baseline should reject {input:?}: {baseline:?}" + ); + assert_same_recognition(input, baseline, optimized(input)); + } + } + + #[test] + fn inlined_token_sets_and_blocks_survive_direct_reparse() { + let parsed = optimized_inline_parser::parse_with_parser( + "select @x; @y select from where; (@z,);", + optimized_inline_lexer::InlineLexer::new, + optimized_inline_parser::InlineParser::start, + ) + .expect("optimized parser should accept the mixed statement input"); + assert_eq!(parsed.parser.number_of_syntax_errors(), 0); + } +} +"#, + ); +} diff --git a/crates/antlr-rust-codegen/tests/fixtures/antlr4-rust-gen/trivial-inline/Inline.g4 b/crates/antlr-rust-codegen/tests/fixtures/antlr4-rust-gen/trivial-inline/Inline.g4 new file mode 100644 index 00000000..c5eefa3f --- /dev/null +++ b/crates/antlr-rust-codegen/tests/fixtures/antlr4-rust-gen/trivial-inline/Inline.g4 @@ -0,0 +1,60 @@ +grammar Inline; + +start + : stmt+ EOF + ; + +stmt + : kw target SEMI + | target kw* SEMI + | marked SEMI + | group SEMI + ; + +// Multi-use token-set candidate: inlined into both stmt references. +kw + : 'select' + | 'from' + | 'where' + ; + +// Multi-use non-trivial rule: retained. +target + : qualifier ID + ; + +// Single-use pure sequence candidate: inlined into target. +qualifier + : AT AT? + ; + +// Single-use rule with an element label: declined. +marked + : label=badge ID + ; + +// Token-set rule behind a labeled call site: declined all-or-nothing. +badge + : STAR + | PLUS + ; + +// Single-use pure sequence candidate: inlined into stmt. +group + : LP target opt RP + ; + +// Nullable single-use rule: declined. +opt + : COMMA? + ; + +ID : [a-z]+ ; +AT : '@' ; +SEMI : ';' ; +STAR : '*' ; +PLUS : '+' ; +LP : '(' ; +RP : ')' ; +COMMA : ',' ; +WS : [ \t\r\n]+ -> skip ; From 373fc7774ee356603c334f116bb43e11870c28da Mon Sep 17 00:00:00 2001 From: Konstantin Vyatkin Date: Wed, 19 Aug 2026 17:13:45 +0200 Subject: [PATCH 2/3] fix(codegen): gate token-set inlining on the rule-level surface Address review findings on the trivial-rule inlining pass: - share one rule_surface_is_observable check (now also covering caseInsensitive rule options) between both candidate classes, so a token-set rule carrying rule-level options, modifiers, throws, or exception clauses is declined instead of silently dropping that surface - deduplicate flattened token-set members so duplicate authored alternatives cannot overstate the manifest member count or emit redundant set entries - always emit inlinedCallSites in optimizations.json, matching the other candidate array fields instead of disappearing when empty - extract the shared single-unit transform fixture and the baseline/optimized/report CLI generation matrix into reusable test support, removing the duplication CPD flagged Re-verified: workspace tests and pedantic clippy clean; runtime testsuite 357/357 zero-skip with the pass forced on, still applying in 5 descriptor grammars with byte-identical upstream output. --- .../src/grammar/transform/analysis.rs | 16 +++ .../src/grammar/transform/artifact.rs | 1 - .../src/grammar/transform/mod.rs | 29 ++++++ .../transform/passes/inline_trivial.rs | 99 ++++++++++++------- .../transform/passes/precedence_ladder.rs | 30 +----- ...te_member_dedup_shapes_and_candidates.snap | 12 +++ ...al__tests__rule_level_option_declines.snap | 7 ++ ...ecedence_ladder_optimization_manifest.snap | 12 ++- .../tests/antlr4_rust_gen_cli/support.rs | 49 +++++++++ .../tests/antlr4_rust_gen_cli/transforms.rs | 88 +++-------------- 10 files changed, 197 insertions(+), 146 deletions(-) create mode 100644 crates/antlr-rust-codegen/src/grammar/transform/passes/snapshots/antlr_rust_codegen__grammar__transform__passes__inline_trivial__tests__duplicate_member_dedup_shapes_and_candidates.snap create mode 100644 crates/antlr-rust-codegen/src/grammar/transform/passes/snapshots/antlr_rust_codegen__grammar__transform__passes__inline_trivial__tests__rule_level_option_declines.snap diff --git a/crates/antlr-rust-codegen/src/grammar/transform/analysis.rs b/crates/antlr-rust-codegen/src/grammar/transform/analysis.rs index 99722e6a..44030eef 100644 --- a/crates/antlr-rust-codegen/src/grammar/transform/analysis.rs +++ b/crates/antlr-rust-codegen/src/grammar/transform/analysis.rs @@ -353,3 +353,19 @@ fn collect_target_code_rule_references( } } } + +/// Whether the rule carries any rule-level surface that generated consumers +/// or target code can observe: modifiers, attribute clauses, `throws`, +/// options, named actions, exception handlers, or case-insensitivity. +pub(crate) const fn rule_surface_is_observable(rule: &crate::grammar::model::Rule) -> bool { + !rule.modifiers.is_empty() + || rule.arguments.is_some() + || rule.returns.is_some() + || rule.locals.is_some() + || !rule.throws.is_empty() + || !rule.options.is_empty() + || !rule.actions.is_empty() + || !rule.catches.is_empty() + || rule.finally_action.is_some() + || rule.case_insensitive.is_some() +} diff --git a/crates/antlr-rust-codegen/src/grammar/transform/artifact.rs b/crates/antlr-rust-codegen/src/grammar/transform/artifact.rs index afb31dbf..18ea91f8 100644 --- a/crates/antlr-rust-codegen/src/grammar/transform/artifact.rs +++ b/crates/antlr-rust-codegen/src/grammar/transform/artifact.rs @@ -61,7 +61,6 @@ struct TransformCandidateManifest<'a> { alternatives: Vec>, label_renames: Vec>, grouping_changes: Vec>, - #[serde(skip_serializing_if = "Vec::is_empty")] inlined_call_sites: Vec>, } diff --git a/crates/antlr-rust-codegen/src/grammar/transform/mod.rs b/crates/antlr-rust-codegen/src/grammar/transform/mod.rs index 43e82922..1879be6c 100644 --- a/crates/antlr-rust-codegen/src/grammar/transform/mod.rs +++ b/crates/antlr-rust-codegen/src/grammar/transform/mod.rs @@ -141,3 +141,32 @@ pub(crate) trait GrammarTransform { report: &mut TransformReport, ) -> Result; } + +#[cfg(test)] +pub(crate) mod test_support { + use std::collections::BTreeSet; + + use super::TransformGrammar; + use crate::grammar::frontend::{SourceId, parse_source}; + use crate::grammar::model::{GrammarId, ModelIdAllocator}; + use crate::grammar::provenance::ProvenanceIndex; + use crate::grammar::syntax::parse_grammar_unit; + + /// Parses one grammar source into a single-unit [`TransformGrammar`] + /// targeted for optional transforms, without running integration. + pub(crate) fn single_unit_fixture(text: &str) -> (TransformGrammar, ModelIdAllocator) { + let file = parse_source(SourceId::new(0), "P.g4", text).expect("valid grammar"); + let mut ids = ModelIdAllocator::after_loaded_grammars(1); + let mut provenance = ProvenanceIndex::default(); + let unit = parse_grammar_unit(&file, GrammarId::new(0), &mut ids, &mut provenance); + ( + TransformGrammar { + units: vec![unit], + target_units: BTreeSet::from([GrammarId::new(0)]), + preserved_rules: BTreeSet::new(), + provenance, + }, + ids, + ) + } +} diff --git a/crates/antlr-rust-codegen/src/grammar/transform/passes/inline_trivial.rs b/crates/antlr-rust-codegen/src/grammar/transform/passes/inline_trivial.rs index 8ea40b1f..826b3d95 100644 --- a/crates/antlr-rust-codegen/src/grammar/transform/passes/inline_trivial.rs +++ b/crates/antlr-rust-codegen/src/grammar/transform/passes/inline_trivial.rs @@ -34,7 +34,7 @@ use crate::grammar::model::{ use crate::grammar::provenance::{Origin, ProvenanceIndex}; use crate::grammar::rule_reachability::{EntryRuleConfig, analyze}; use crate::grammar::transform::analysis::{ - AnalysisInvalidation, TransformAnalysis, observed_rule_contexts, + AnalysisInvalidation, TransformAnalysis, observed_rule_contexts, rule_surface_is_observable, }; use crate::grammar::transform::clone::{TransformCloner, tombstone_rule}; use crate::grammar::transform::{ @@ -316,6 +316,11 @@ fn eligibility( if context.observed.contains(&rule.id) { return Err("grammar target code observes the rule context".to_owned()); } + if rule_surface_is_observable(rule) { + return Err( + "rule-level attributes, actions, options, or exceptions are observable".to_owned(), + ); + } if let Some(problem) = sites.iter().find_map(|scanned| { scanned .problem @@ -371,7 +376,22 @@ fn token_set_body(rule: &Rule) -> Option> { _ => return None, } } - (!members.is_empty()).then_some(members) + if members.is_empty() { + return None; + } + let mut seen: Vec = Vec::new(); + members.retain(|member| match member { + SetElement::Terminal { value, .. } => { + if seen.contains(value) { + false + } else { + seen.push(value.clone()); + true + } + } + SetElement::Range { .. } => true, + }); + Some(members) } fn inlinable_terminal(terminal: &Terminal) -> Option<&Terminal> { @@ -394,20 +414,6 @@ fn inlinable_set_member(member: &SetElement) -> Option<&SetElement> { } fn single_use_purity(rule: &Rule, analysis: &TransformAnalysis) -> Result<(), String> { - if !rule.modifiers.is_empty() - || rule.arguments.is_some() - || rule.returns.is_some() - || rule.locals.is_some() - || !rule.throws.is_empty() - || !rule.options.is_empty() - || !rule.actions.is_empty() - || !rule.catches.is_empty() - || rule.finally_action.is_some() - { - return Err( - "rule-level attributes, actions, options, or exceptions are observable".to_owned(), - ); - } if analysis.side_effecting.contains(&rule.id) { return Err("embedded actions or predicates are observable".to_owned()); } @@ -615,29 +621,9 @@ fn declined_report( #[cfg(test)] #[allow(clippy::disallowed_methods)] // `insta` assertion macros unwrap internal I/O. mod tests { - use std::collections::BTreeSet; - use super::*; - use crate::grammar::frontend::{SourceId, parse_source}; - use crate::grammar::model::GrammarId; - use crate::grammar::syntax::parse_grammar_unit; - use crate::grammar::transform::{TransformGrammar, TransformRegistry}; - - fn fixture(text: &str) -> (TransformGrammar, ModelIdAllocator) { - let file = parse_source(SourceId::new(0), "P.g4", text).expect("valid grammar"); - let mut ids = ModelIdAllocator::after_loaded_grammars(1); - let mut provenance = ProvenanceIndex::default(); - let unit = parse_grammar_unit(&file, GrammarId::new(0), &mut ids, &mut provenance); - ( - TransformGrammar { - units: vec![unit], - target_units: BTreeSet::from([GrammarId::new(0)]), - preserved_rules: BTreeSet::new(), - provenance, - }, - ids, - ) - } + use crate::grammar::transform::TransformRegistry; + use crate::grammar::transform::test_support::single_unit_fixture as fixture; fn registry(entries: EntryRuleConfig) -> TransformRegistry { let mut registry = TransformRegistry::default(); @@ -888,6 +874,43 @@ kw : A | B ; insta::assert_debug_snapshot!("opaque_target_code_declines", candidate_lines(&report)); } + #[test] + fn rule_level_options_decline_token_set_candidates() { + let (mut grammar, mut ids) = fixture( + r" +parser grammar P; +start : kw ID EOF ; +kw options { caseInsensitive=true; } : A | B ; +", + ); + let report = registry(EntryRuleConfig::default()) + .run(&mut grammar, &mut ids, false) + .expect("declined candidates should not fail the pass"); + + assert!(!report.entries[0].changed); + assert_eq!(grammar.units[0].rules.len(), 2, "kw must be retained"); + insta::assert_debug_snapshot!("rule_level_option_declines", candidate_lines(&report)); + } + + #[test] + fn duplicate_token_set_members_are_deduplicated() { + let (mut grammar, mut ids) = fixture( + r" +parser grammar P; +start : kw ID EOF ; +kw : A | B | A ; +", + ); + let report = registry(EntryRuleConfig::default()) + .run(&mut grammar, &mut ids, false) + .expect("duplicate members should still inline"); + + insta::assert_debug_snapshot!( + "duplicate_member_dedup_shapes_and_candidates", + (shape(&grammar.units[0]), candidate_lines(&report)) + ); + } + #[test] fn report_only_projects_the_rewrite_without_mutating_the_grammar() { let (mut grammar, mut ids) = fixture( diff --git a/crates/antlr-rust-codegen/src/grammar/transform/passes/precedence_ladder.rs b/crates/antlr-rust-codegen/src/grammar/transform/passes/precedence_ladder.rs index 69b0a0b5..cc7e5e67 100644 --- a/crates/antlr-rust-codegen/src/grammar/transform/passes/precedence_ladder.rs +++ b/crates/antlr-rust-codegen/src/grammar/transform/passes/precedence_ladder.rs @@ -13,7 +13,7 @@ use crate::grammar::model::{ }; use crate::grammar::provenance::{Origin, ProvenanceIndex, Tombstone}; use crate::grammar::transform::analysis::{ - AnalysisInvalidation, observed_rule_contexts, visit_elements, + AnalysisInvalidation, observed_rule_contexts, rule_surface_is_observable, visit_elements, }; use crate::grammar::transform::clone::TransformCloner; use crate::grammar::transform::{ @@ -423,16 +423,7 @@ fn rough_base_rule(rule: &Rule) -> Option { } fn validate_rule_surface(rule: &Rule) -> Result<(), String> { - if !rule.modifiers.is_empty() - || rule.arguments.is_some() - || rule.returns.is_some() - || rule.locals.is_some() - || !rule.throws.is_empty() - || !rule.options.is_empty() - || !rule.actions.is_empty() - || !rule.catches.is_empty() - || rule.finally_action.is_some() - { + if rule_surface_is_observable(rule) { return Err( "rule-level attributes, actions, options, or exceptions are observable".to_owned(), ); @@ -1405,9 +1396,6 @@ fn declined_report( #[allow(clippy::disallowed_methods)] // `insta` assertion macros unwrap internal I/O. mod tests { use super::*; - use crate::grammar::frontend::{SourceId, parse_source}; - use crate::grammar::model::GrammarId; - use crate::grammar::syntax::parse_grammar_unit; use crate::grammar::transform::{TransformGrammar, TransformRegistry}; const CEL_LADDER: &str = r#" @@ -1901,19 +1889,7 @@ low : INT ; } fn fixture(text: &str) -> (TransformGrammar, ModelIdAllocator) { - let file = parse_source(SourceId::new(0), "P.g4", text).expect("valid grammar"); - let mut ids = ModelIdAllocator::after_loaded_grammars(1); - let mut provenance = ProvenanceIndex::default(); - let unit = parse_grammar_unit(&file, GrammarId::new(0), &mut ids, &mut provenance); - ( - TransformGrammar { - units: vec![unit], - target_units: BTreeSet::from([GrammarId::new(0)]), - preserved_rules: BTreeSet::new(), - provenance, - }, - ids, - ) + crate::grammar::transform::test_support::single_unit_fixture(text) } fn summarize(unit: &GrammarUnit) -> Vec<(String, Vec<(String, Vec)>)> { diff --git a/crates/antlr-rust-codegen/src/grammar/transform/passes/snapshots/antlr_rust_codegen__grammar__transform__passes__inline_trivial__tests__duplicate_member_dedup_shapes_and_candidates.snap b/crates/antlr-rust-codegen/src/grammar/transform/passes/snapshots/antlr_rust_codegen__grammar__transform__passes__inline_trivial__tests__duplicate_member_dedup_shapes_and_candidates.snap new file mode 100644 index 00000000..79487f93 --- /dev/null +++ b/crates/antlr-rust-codegen/src/grammar/transform/passes/snapshots/antlr_rust_codegen__grammar__transform__passes__inline_trivial__tests__duplicate_member_dedup_shapes_and_candidates.snap @@ -0,0 +1,12 @@ +--- +source: crates/antlr-rust-codegen/src/grammar/transform/passes/inline_trivial.rs +expression: "(shape(&grammar.units[0]), candidate_lines(&report))" +--- +( + [ + "start : {A,B} ID EOF", + ], + [ + "Applied kw [start#1]: inlined token-set rule kw (2 members) into 1 call site", + ], +) diff --git a/crates/antlr-rust-codegen/src/grammar/transform/passes/snapshots/antlr_rust_codegen__grammar__transform__passes__inline_trivial__tests__rule_level_option_declines.snap b/crates/antlr-rust-codegen/src/grammar/transform/passes/snapshots/antlr_rust_codegen__grammar__transform__passes__inline_trivial__tests__rule_level_option_declines.snap new file mode 100644 index 00000000..8e85d989 --- /dev/null +++ b/crates/antlr-rust-codegen/src/grammar/transform/passes/snapshots/antlr_rust_codegen__grammar__transform__passes__inline_trivial__tests__rule_level_option_declines.snap @@ -0,0 +1,7 @@ +--- +source: crates/antlr-rust-codegen/src/grammar/transform/passes/inline_trivial.rs +expression: candidate_lines(&report) +--- +[ + "Declined kw [start#1]: rule-level attributes, actions, options, or exceptions are observable", +] diff --git a/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__transforms__precedence_ladder_optimization_manifest.snap b/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__transforms__precedence_ladder_optimization_manifest.snap index 4e717d01..e14de068 100644 --- a/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__transforms__precedence_ladder_optimization_manifest.snap +++ b/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__transforms__precedence_ladder_optimization_manifest.snap @@ -51,7 +51,8 @@ expression: stable_manifest "removedRules": [], "alternatives": [], "labelRenames": [], - "groupingChanges": [] + "groupingChanges": [], + "inlinedCallSites": [] }, { "grammar": "LadderParser", @@ -152,7 +153,8 @@ expression: stable_manifest "from": "flat-loop", "to": "left-recursive-nesting" } - ] + ], + "inlinedCallSites": [] }, { "grammar": "LadderParser", @@ -288,7 +290,8 @@ expression: stable_manifest } ], "labelRenames": [], - "groupingChanges": [] + "groupingChanges": [], + "inlinedCallSites": [] }, { "grammar": "LadderParser", @@ -599,7 +602,8 @@ expression: stable_manifest "from": "flat-loop", "to": "left-recursive-nesting" } - ] + ], + "inlinedCallSites": [] } ] } diff --git a/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/support.rs b/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/support.rs index 0ea70b34..e02cac14 100644 --- a/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/support.rs +++ b/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/support.rs @@ -426,3 +426,52 @@ impl Drop for TempDirectory { let _ = fs::remove_dir_all(&self.0); } } + +/// Generates one grammar three ways — unoptimized baseline, applied +/// optimization, and report-only — asserting each run succeeds, that the +/// baseline emits no optimization manifest, and that report mode emits only +/// `optimizations.json`. Returns the three output directories. +pub(super) fn run_optimization_matrix( + root: &Path, + grammar: &Path, + apply_flag: &str, + report_flag: &str, +) -> (PathBuf, PathBuf, PathBuf) { + let baseline = root.join("baseline"); + let optimized = root.join("optimized"); + let report = root.join("report"); + for (out, extra) in [ + (&baseline, None), + (&optimized, Some(apply_flag)), + (&report, Some(report_flag)), + ] { + let mut args = vec![ + grammar.as_os_str(), + OsStr::new("--out-dir"), + out.as_os_str(), + ]; + if let Some(flag) = extra { + args.push(OsStr::new(flag)); + } + let output = run_antlr4_rust_gen(&args); + assert!( + output.status.success(), + "{extra:?} failed\nstdout: {}\nstderr: {}", + utf8(&output.stdout), + utf8(&output.stderr) + ); + } + assert!(!baseline.join("optimizations.json").exists()); + let report_files = fs::read_dir(&report) + .expect("report directory should exist") + .map(|entry| { + entry + .expect("report entry should be readable") + .file_name() + .to_string_lossy() + .into_owned() + }) + .collect::>(); + assert_eq!(report_files, ["optimizations.json"]); + (baseline, optimized, report) +} diff --git a/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/transforms.rs b/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/transforms.rs index d34b88f6..92990f5f 100644 --- a/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/transforms.rs +++ b/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/transforms.rs @@ -458,44 +458,12 @@ fn precedence_ladder_optimization_is_explicit_auditable_and_recognition_preservi let temp = temporary_directory("precedence-ladder"); let grammar = Path::new(env!("CARGO_MANIFEST_DIR")) .join("tests/fixtures/antlr4-rust-gen/precedence-ladder/Ladder.g4"); - let baseline = temp.path().join("baseline"); - let optimized = temp.path().join("optimized"); - let report = temp.path().join("report"); - - for (out, extra) in [ - (&baseline, None), - (&optimized, Some("--optimize-precedence-ladders")), - (&report, Some("--report-precedence-ladders")), - ] { - let mut args = vec![ - grammar.as_os_str(), - OsStr::new("--out-dir"), - out.as_os_str(), - ]; - if let Some(flag) = extra { - args.push(OsStr::new(flag)); - } - let output = run_antlr4_rust_gen(&args); - assert!( - output.status.success(), - "{extra:?} failed\nstdout: {}\nstderr: {}", - utf8(&output.stdout), - utf8(&output.stderr) - ); - } - - assert!(!baseline.join("optimizations.json").exists()); - let report_files = fs::read_dir(&report) - .expect("report directory should exist") - .map(|entry| { - entry - .expect("report entry should be readable") - .file_name() - .to_string_lossy() - .into_owned() - }) - .collect::>(); - assert_eq!(report_files, ["optimizations.json"]); + let (baseline, optimized, report) = run_optimization_matrix( + temp.path(), + &grammar, + "--optimize-precedence-ladders", + "--report-precedence-ladders", + ); let manifest = fs::read_to_string(optimized.join("optimizations.json")) .expect("applied optimization manifest should be emitted"); @@ -1006,44 +974,12 @@ fn trivial_rule_inlining_is_explicit_auditable_and_recognition_preserving() { let temp = temporary_directory("trivial-inline"); let grammar = Path::new(env!("CARGO_MANIFEST_DIR")) .join("tests/fixtures/antlr4-rust-gen/trivial-inline/Inline.g4"); - let baseline = temp.path().join("baseline"); - let optimized = temp.path().join("optimized"); - let report = temp.path().join("report"); - - for (out, extra) in [ - (&baseline, None), - (&optimized, Some("--inline-trivial-rules")), - (&report, Some("--report-trivial-rules")), - ] { - let mut args = vec![ - grammar.as_os_str(), - OsStr::new("--out-dir"), - out.as_os_str(), - ]; - if let Some(flag) = extra { - args.push(OsStr::new(flag)); - } - let output = run_antlr4_rust_gen(&args); - assert!( - output.status.success(), - "{extra:?} failed\nstdout: {}\nstderr: {}", - utf8(&output.stdout), - utf8(&output.stderr) - ); - } - - assert!(!baseline.join("optimizations.json").exists()); - let report_files = fs::read_dir(&report) - .expect("report directory should exist") - .map(|entry| { - entry - .expect("report entry should be readable") - .file_name() - .to_string_lossy() - .into_owned() - }) - .collect::>(); - assert_eq!(report_files, ["optimizations.json"]); + let (baseline, optimized, report) = run_optimization_matrix( + temp.path(), + &grammar, + "--inline-trivial-rules", + "--report-trivial-rules", + ); let manifest = fs::read_to_string(optimized.join("optimizations.json")) .expect("applied optimization manifest should be emitted"); From dc44020ae74aef47b6bee1f374053f39a1cc3d12 Mon Sep 17 00:00:00 2001 From: Konstantin Vyatkin Date: Wed, 19 Aug 2026 17:24:27 +0200 Subject: [PATCH 3/3] fix(codegen): report explicit removal targets in the manifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Give removed-rule manifest entries an explicit optional target instead of rendering the candidate's entry rule: precedence-ladder rungs keep their surviving hub as the target, while inlined rules — which dissolve into their call sites rather than into a single rule — serialize targetRule as null, with the rewritten locations carried by inlinedCallSites. This removes the degenerate self-referential {"rule": "kw", "targetRule": "kw"} entries from trivial-rule inlining candidates. --- .../src/grammar/transform/artifact.rs | 8 ++--- .../src/grammar/transform/mod.rs | 11 +++++- .../transform/passes/inline_trivial.rs | 9 +++-- .../transform/passes/precedence_ladder.rs | 22 +++++++++--- ...ce_ladder__tests__cel_ladder_collapse.snap | 35 ++++++++++++++++--- ..._trivial_inline_optimization_manifest.snap | 6 ++-- 6 files changed, 71 insertions(+), 20 deletions(-) diff --git a/crates/antlr-rust-codegen/src/grammar/transform/artifact.rs b/crates/antlr-rust-codegen/src/grammar/transform/artifact.rs index 18ea91f8..6becc9fc 100644 --- a/crates/antlr-rust-codegen/src/grammar/transform/artifact.rs +++ b/crates/antlr-rust-codegen/src/grammar/transform/artifact.rs @@ -98,7 +98,7 @@ struct ReductionManifest { #[serde(rename_all = "camelCase")] struct RemovedRuleManifest<'a> { rule: &'a str, - target_rule: &'a str, + target_rule: Option<&'a str>, } #[derive(Serialize)] @@ -203,9 +203,9 @@ fn transform_candidate_manifest<'a>( removed_rules: candidate .removed_rules .iter() - .map(|rule| RemovedRuleManifest { - rule, - target_rule: &candidate.entry_rule, + .map(|removed| RemovedRuleManifest { + rule: &removed.rule, + target_rule: removed.target.as_deref(), }) .collect(), alternatives: candidate diff --git a/crates/antlr-rust-codegen/src/grammar/transform/mod.rs b/crates/antlr-rust-codegen/src/grammar/transform/mod.rs index 1879be6c..81b52227 100644 --- a/crates/antlr-rust-codegen/src/grammar/transform/mod.rs +++ b/crates/antlr-rust-codegen/src/grammar/transform/mod.rs @@ -81,6 +81,15 @@ pub(crate) struct TransformCallSite { pub(crate) source_span: SourceSpan, } +/// One rule deleted by a transform, with the rule that absorbed it when a +/// single surviving target exists (inlined rules dissolve into their call +/// sites instead). +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct TransformRemovedRule { + pub(crate) rule: String, + pub(crate) target: Option, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct TransformCandidateReport { pub(crate) pass: TransformId, @@ -92,7 +101,7 @@ pub(crate) struct TransformCandidateReport { pub(crate) rungs: Vec, pub(crate) boundary_rule: Option, pub(crate) projection: Option, - pub(crate) removed_rules: Vec, + pub(crate) removed_rules: Vec, pub(crate) alternatives: Vec, pub(crate) labels: Vec, pub(crate) grouping_changes: Vec, diff --git a/crates/antlr-rust-codegen/src/grammar/transform/passes/inline_trivial.rs b/crates/antlr-rust-codegen/src/grammar/transform/passes/inline_trivial.rs index 826b3d95..892d6450 100644 --- a/crates/antlr-rust-codegen/src/grammar/transform/passes/inline_trivial.rs +++ b/crates/antlr-rust-codegen/src/grammar/transform/passes/inline_trivial.rs @@ -39,8 +39,8 @@ use crate::grammar::transform::analysis::{ use crate::grammar::transform::clone::{TransformCloner, tombstone_rule}; use crate::grammar::transform::{ GrammarTransform, SafetyClass, TransformCallSite, TransformCandidateReport, - TransformCandidateStatus, TransformContext, TransformGrammar, TransformReport, - TransformRuleRemoval, + TransformCandidateStatus, TransformContext, TransformGrammar, TransformRemovedRule, + TransformReport, TransformRuleRemoval, }; pub(crate) struct InlineTrivialRules { @@ -587,7 +587,10 @@ fn applied_report( rungs: Vec::new(), boundary_rule: None, projection: None, - removed_rules: vec![plan.callee_name.clone()], + removed_rules: vec![TransformRemovedRule { + rule: plan.callee_name.clone(), + target: None, + }], alternatives: Vec::new(), labels: Vec::new(), grouping_changes: Vec::new(), diff --git a/crates/antlr-rust-codegen/src/grammar/transform/passes/precedence_ladder.rs b/crates/antlr-rust-codegen/src/grammar/transform/passes/precedence_ladder.rs index cc7e5e67..2bd316bc 100644 --- a/crates/antlr-rust-codegen/src/grammar/transform/passes/precedence_ladder.rs +++ b/crates/antlr-rust-codegen/src/grammar/transform/passes/precedence_ladder.rs @@ -19,7 +19,7 @@ use crate::grammar::transform::clone::TransformCloner; use crate::grammar::transform::{ GrammarTransform, SafetyClass, TransformAlternativeMapping, TransformCandidateReport, TransformCandidateStatus, TransformContext, TransformGrammar, TransformLabelMapping, - TransformProjection, TransformReport, + TransformProjection, TransformRemovedRule, TransformReport, }; pub(crate) struct CollapsePrecedenceLadders; @@ -898,7 +898,10 @@ fn apply_plan( .collect::>(); let removed_rules = plan.rungs[1..] .iter() - .map(|rung| rung.rule.name.clone()) + .map(|rung| TransformRemovedRule { + rule: rung.rule.name.clone(), + target: Some(hub.name.clone()), + }) .collect::>(); let removed_ids = plan.rungs[1..] @@ -1524,7 +1527,14 @@ atom : INT ; ["top", "other", "middle", "atom"] ); assert_eq!(report.candidates[0].entry_rule, "middle"); - assert_eq!(report.candidates[0].removed_rules, ["low"]); + assert_eq!( + report.candidates[0] + .removed_rules + .iter() + .map(|removed| (removed.rule.as_str(), removed.target.as_deref())) + .collect::>(), + [("low", Some("middle"))] + ); let middle = grammar.units[0] .rules .iter() @@ -1590,7 +1600,11 @@ atom : INT ; ] ); assert_eq!( - candidate.removed_rules, + candidate + .removed_rules + .iter() + .map(|removed| removed.rule.as_str()) + .collect::>(), ["conditionalAnd", "relation", "calc", "unary"] ); } diff --git a/crates/antlr-rust-codegen/src/grammar/transform/passes/snapshots/antlr_rust_codegen__grammar__transform__passes__precedence_ladder__tests__cel_ladder_collapse.snap b/crates/antlr-rust-codegen/src/grammar/transform/passes/snapshots/antlr_rust_codegen__grammar__transform__passes__precedence_ladder__tests__cel_ladder_collapse.snap index 5ad70cdc..2ee2910a 100644 --- a/crates/antlr-rust-codegen/src/grammar/transform/passes/snapshots/antlr_rust_codegen__grammar__transform__passes__precedence_ladder__tests__cel_ladder_collapse.snap +++ b/crates/antlr-rust-codegen/src/grammar/transform/passes/snapshots/antlr_rust_codegen__grammar__transform__passes__precedence_ladder__tests__cel_ladder_collapse.snap @@ -135,11 +135,36 @@ expression: "(summarize(&grammar.units[0]), &report.entries, &report.candidates, }, ), removed_rules: [ - "conditionalOr", - "conditionalAnd", - "relation", - "calc", - "unary", + TransformRemovedRule { + rule: "conditionalOr", + target: Some( + "expr", + ), + }, + TransformRemovedRule { + rule: "conditionalAnd", + target: Some( + "expr", + ), + }, + TransformRemovedRule { + rule: "relation", + target: Some( + "expr", + ), + }, + TransformRemovedRule { + rule: "calc", + target: Some( + "expr", + ), + }, + TransformRemovedRule { + rule: "unary", + target: Some( + "expr", + ), + }, ], alternatives: [ TransformAlternativeMapping { diff --git a/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__transforms__trivial_inline_optimization_manifest.snap b/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__transforms__trivial_inline_optimization_manifest.snap index 37203caf..7ddce63c 100644 --- a/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__transforms__trivial_inline_optimization_manifest.snap +++ b/crates/antlr-rust-codegen/tests/antlr4_rust_gen_cli/snapshots/antlr4_rust_gen_cli__transforms__trivial_inline_optimization_manifest.snap @@ -48,7 +48,7 @@ expression: stable_manifest "removedRules": [ { "rule": "kw", - "targetRule": "kw" + "targetRule": null } ], "alternatives": [], @@ -115,7 +115,7 @@ expression: stable_manifest "removedRules": [ { "rule": "qualifier", - "targetRule": "qualifier" + "targetRule": null } ], "alternatives": [], @@ -165,7 +165,7 @@ expression: stable_manifest "removedRules": [ { "rule": "group", - "targetRule": "group" + "targetRule": null } ], "alternatives": [],