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..44030eef 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,165 @@ 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); + } + } +} + +/// 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 c02d5ec0..6becc9fc 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,7 @@ struct TransformCandidateManifest<'a> { alternatives: Vec>, label_renames: Vec>, grouping_changes: Vec>, + inlined_call_sites: Vec>, } #[derive(Serialize)] @@ -97,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)] @@ -126,6 +127,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, @@ -194,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 @@ -218,6 +227,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..81b52227 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,24 @@ 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, +} + +/// 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, @@ -81,10 +101,11 @@ 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, + pub(crate) call_sites: Vec, } #[derive(Clone, Debug, Default, Eq, PartialEq)] @@ -129,3 +150,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 new file mode 100644 index 00000000..892d6450 --- /dev/null +++ b/crates/antlr-rust-codegen/src/grammar/transform/passes/inline_trivial.rs @@ -0,0 +1,940 @@ +// 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, rule_surface_is_observable, +}; +use crate::grammar::transform::clone::{TransformCloner, tombstone_rule}; +use crate::grammar::transform::{ + GrammarTransform, SafetyClass, TransformCallSite, TransformCandidateReport, + TransformCandidateStatus, TransformContext, TransformGrammar, TransformRemovedRule, + 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 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 + .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, + } + } + 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> { + 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 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![TransformRemovedRule { + rule: plan.callee_name.clone(), + target: None, + }], + 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 super::*; + 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(); + 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 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( + 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..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 @@ -2,21 +2,24 @@ // 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, rule_surface_is_observable, visit_elements, +}; +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; @@ -420,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(), ); @@ -829,147 +823,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 +877,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); } @@ -1045,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..] @@ -1086,6 +942,7 @@ fn apply_plan( alternatives: alternative_mappings, labels: label_mappings, grouping_changes, + call_sites: Vec::new(), } } @@ -1278,124 +1135,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 +1391,7 @@ fn declined_report( alternatives: Vec::new(), labels: Vec::new(), grouping_changes: Vec::new(), + call_sites: Vec::new(), } } @@ -1632,9 +1399,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#" @@ -1763,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() @@ -1829,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"] ); } @@ -2128,19 +1903,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/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__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__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__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/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..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 @@ -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,)" --- ( @@ -136,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 { @@ -306,6 +330,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__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/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..7ddce63c --- /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": null + } + ], + "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": null + } + ], + "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": null + } + ], + "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/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 80f094c5..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"); @@ -1000,3 +968,209 @@ 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, 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"); + 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 ;