diff --git a/README.md b/README.md index b525092d..6519c5a7 100644 --- a/README.md +++ b/README.md @@ -423,10 +423,25 @@ parser action events are offered to `SemanticHooks::action` after the committed parse path is selected. Predicate hooks may run speculatively during prediction, so they must be replay-safe. -For bare helper-call predicates, generated parsers also emit a typed hook -adapter (`MyParserHooks` plus `MyParserTypedHooks`) that maps stable -manifest coordinates to named Rust methods. Lexer callers can use -`LexerSemCtx` with `atn::lexer::next_token_with_semantic_hooks` or the +For helper-call predicates written as `helper()`, `this.helper()`, or +`self.helper()`, generated parsers also emit a typed hook adapter +(`MyParserHooks` plus `MyParserTypedHooks`) that maps stable manifest +coordinates to named Rust methods. A `[[helper]]` pattern can opt into one +additional receiver spelling with `receiver = "..."`. For example, an +antlr4rust grammar using its `recog.helper()` convention can retain that source +spelling during migration: + +```toml +[[helper]] +kind = "parser-predicate" +name = "helper" +receiver = "recog" +returns = "bool" +lower = "hook" +``` + +Lexer callers can use `LexerSemCtx` with +`atn::lexer::next_token_with_semantic_hooks` or the compiled-DFA variant to route lexer predicates/actions through the same `SemanticHooks` trait. On the committed action path, `LexerSemCtx` exposes the pending token type/channel, character lookahead and consumption, and mode diff --git a/src/bin/antlr4-rust-gen.rs b/src/bin/antlr4-rust-gen.rs index 5255338c..599d06fb 100644 --- a/src/bin/antlr4-rust-gen.rs +++ b/src/bin/antlr4-rust-gen.rs @@ -549,6 +549,9 @@ struct SemHelperRule { /// Explicit helper kind. A missing kind preserves the pre-v1 wildcard /// behavior for lexer and parser predicates. kind: Option, + /// Additional receiver spelling accepted for this helper. Bare calls and + /// the established `this.` / `self.` forms remain available by default. + receiver: Option, name: String, arguments: Vec, lower: String, @@ -603,7 +606,7 @@ impl SemPatternFile { .iter() .filter(|helper| semantic_helper_kind_matches(helper, kind)) .filter_map(|helper| { - parse_semantic_helper_call(body, kind) + parse_semantic_helper_call(body, kind, helper.receiver.as_deref()) .filter(|call| helper_call_matches(call, helper)) .map(|_| helper) }) @@ -680,16 +683,17 @@ impl SemPatternFile { kind: SemanticsKind, body: &str, ) -> io::Result> { - let Some(call) = parse_semantic_helper_call(body, kind) else { - return Ok(None); - }; let matches = self .helpers .iter() .filter(|helper| { semantic_helper_kind_matches(helper, kind) && helper.lower.trim() == "hook" }) - .filter(|helper| helper_call_matches(&call, helper)) + .filter_map(|helper| { + parse_semantic_helper_call(body, kind, helper.receiver.as_deref()) + .filter(|call| helper_call_matches(call, helper)) + .map(|call| (helper, call)) + }) .collect::>(); if matches.len() > 1 { return Err(io::Error::new( @@ -698,13 +702,13 @@ impl SemPatternFile { "ambiguous semantic helper patterns for {body:?}: {}", matches .iter() - .map(|helper| helper.name.as_str()) + .map(|(helper, _)| helper.name.as_str()) .collect::>() .join(", ") ), )); } - Ok(matches.first().map(|_| call)) + Ok(matches.into_iter().next().map(|(_, call)| call)) } fn coordinate_disposition( @@ -824,7 +828,11 @@ fn helper_call_matches(call: &SemanticHelperCall, helper: &SemHelperRule) -> boo }) } -fn parse_semantic_helper_call(body: &str, kind: SemanticsKind) -> Option { +fn parse_semantic_helper_call( + body: &str, + kind: SemanticsKind, + receiver: Option<&str>, +) -> Option { let mut body = body.trim(); if matches!( kind, @@ -842,6 +850,7 @@ fn parse_semantic_helper_call(body: &str, kind: SemanticsKind) -> Option, ) -> io::Result<()> { - let helper_call = parse_semantic_helper_call(body, SemanticsKind::ParserPredicate); + let helper_call = match parse_semantic_helper_call(body, SemanticsKind::ParserPredicate, None) { + Some(call) => Some(call), + None => patterns.hook_helper_call(SemanticsKind::ParserPredicate, body)?, + }; let forced_hook = patterns .coordinate_predicate_template( SemanticsKind::ParserPredicate, @@ -19431,10 +19459,39 @@ lower = "pop_member(depths)" #[test] fn semantic_helper_calls_capture_kind_negation_and_literals() { + let expected = Some(SemanticHelperCall { + name: "isTypeName".to_owned(), + arguments: Vec::new(), + negated: false, + }); + for body in ["isTypeName()", "this.isTypeName()", "self.isTypeName()"] { + assert_eq!( + parse_semantic_helper_call(body, SemanticsKind::ParserPredicate, None), + expected, + "receiver form {body:?} should parse" + ); + } + assert!( + parse_semantic_helper_call( + "recognizer.isTypeName()", + SemanticsKind::ParserPredicate, + None, + ) + .is_none() + ); + assert_eq!( + parse_semantic_helper_call( + "recognizer.isTypeName()", + SemanticsKind::ParserPredicate, + Some("recognizer"), + ), + expected + ); assert_eq!( parse_semantic_helper_call( r#"!this.matches("marker", true, -2)"#, SemanticsKind::LexerPredicate, + None, ), Some(SemanticHelperCall { name: "matches".to_owned(), @@ -19447,7 +19504,7 @@ lower = "pop_member(depths)" }) ); assert_eq!( - parse_semantic_helper_call("this.HandleAction();", SemanticsKind::LexerAction,), + parse_semantic_helper_call("this.HandleAction();", SemanticsKind::LexerAction, None,), Some(SemanticHelperCall { name: "HandleAction".to_owned(), arguments: Vec::new(), @@ -19455,13 +19512,18 @@ lower = "pop_member(depths)" }) ); assert!( - parse_semantic_helper_call("this.n(dynamicValue)", SemanticsKind::ParserPredicate,) - .is_none() + parse_semantic_helper_call( + "this.n(dynamicValue)", + SemanticsKind::ParserPredicate, + None, + ) + .is_none() ); assert_eq!( parse_semantic_helper_call( r"this.n('line\n\'quoted\'')", SemanticsKind::ParserPredicate, + None, ) .expect("single-quoted literal parses") .arguments, @@ -19472,7 +19534,7 @@ lower = "pop_member(depths)" #[test] fn semantic_helper_patterns_are_scoped_by_kind_and_signature() { let patterns = parse_sem_patterns( - "version = 1\n[[helper]]\nkind = \"lexer-action\"\nname = \"handle\"\nreturns = \"unit\"\nlower = \"hook\"\n[[helper]]\nname = \"n\"\narguments = \"string\"\nreturns = \"bool\"\nlower = \"hook\"\n", + "version = 1\n[[helper]]\nkind = \"lexer-action\"\nname = \"handle\"\nreturns = \"unit\"\nlower = \"hook\"\n[[helper]]\nname = \"n\"\nreceiver = \"recognizer\"\narguments = \"string\"\nreturns = \"bool\"\nlower = \"hook\"\n", ) .expect("pattern file parses"); assert!( @@ -19501,6 +19563,18 @@ lower = "pop_member(depths)" .expect("matching cannot fail") .is_some() ); + assert!( + patterns + .hook_helper_call(SemanticsKind::ParserPredicate, r#"recognizer.n("value")"#,) + .expect("matching cannot fail") + .is_some() + ); + assert!( + patterns + .hook_helper_call(SemanticsKind::ParserPredicate, r#"other.n("value")"#) + .expect("matching cannot fail") + .is_none() + ); assert!( patterns .hook_helper_call(SemanticsKind::LexerAction, r#"this.n("value");"#) @@ -19509,6 +19583,18 @@ lower = "pop_member(depths)" ); } + #[test] + fn semantic_helper_patterns_reject_invalid_receiver_aliases() { + let error = parse_sem_patterns( + "version = 1\n[[helper]]\nname = \"n\"\nreceiver = \"recognizer.state\"\nreturns = \"bool\"\nlower = \"hook\"\n", + ) + .expect_err("receiver aliases must be identifiers"); + insta::assert_snapshot!( + error.to_string(), + @r#"semantic helper receiver must be an identifier, got "recognizer.state""# + ); + } + #[test] fn lexer_typed_hook_signatures_reject_normalized_conflicts() { let mappings = [ diff --git a/tests/antlr4_rust_gen_cli.rs b/tests/antlr4_rust_gen_cli.rs index 362258a3..e1725113 100644 --- a/tests/antlr4_rust_gen_cli.rs +++ b/tests/antlr4_rust_gen_cli.rs @@ -2022,6 +2022,142 @@ fn imported_parser_predicate_generates_typed_hook_from_structural_body() { ); } +/// Issue #241: antlr4rust grammars use `recog` as the parser-predicate receiver. +/// Supporting that convention keeps migrations source-compatible by routing it +/// through the same typed helper hook as bare, `this.`, and `self.` calls. +#[allow(clippy::disallowed_methods)] // `insta` assertion macros unwrap internal I/O. +#[test] +fn recog_receiver_parser_predicate_routes_to_typed_hook() { + let temp = temporary_directory("recog-parser-hook"); + let grammar = temp.path().join("RecogPredicate.g4"); + let patterns = temp.path().join("patterns.toml"); + let out = temp.path().join("generated"); + fs::write( + &grammar, + r#"grammar RecogPredicate; + +options { + superClass = ParserBase; +} + +shl + : {recog.IsOk()}? LT LT EOF + ; + +LT: '<'; +WS: [ \t\r\n]+ -> skip; +"#, + ) + .expect("grammar should be writable"); + fs::write( + &patterns, + r#"version = 1 + +[[helper]] +kind = "parser-predicate" +name = "IsOk" +receiver = "recog" +returns = "bool" +lower = "hook" +"#, + ) + .expect("semantic patterns should be writable"); + + let output = run_antlr4_rust_gen(&[ + grammar.as_os_str(), + OsStr::new("--sem-patterns"), + patterns.as_os_str(), + OsStr::new("--option-hook"), + OsStr::new("superClass=ParserBase"), + OsStr::new("--sem-unknown"), + OsStr::new("error"), + OsStr::new("--require-full-semantics"), + OsStr::new("--out-dir"), + out.as_os_str(), + ]); + assert!( + output.status.success(), + "stdout: {}\nstderr: {}", + utf8(&output.stdout), + utf8(&output.stderr) + ); + + let manifest = + fs::read_to_string(out.join("semantics.json")).expect("manifest should be emitted"); + let predicate = manifest + .lines() + .find(|line| line.contains("\"kind\": \"parser-predicate\"")) + .expect("parser predicate should be inventoried"); + insta::assert_snapshot!("recog_receiver_semantics_manifest", predicate); + + let parser = fs::read_to_string(out.join("recog_predicate_parser.rs")) + .expect("parser should be emitted"); + let (_, adapter) = parser + .split_once("pub trait RecogPredicateParserHooks") + .expect("typed hook adapter should be emitted"); + let (adapter, _) = adapter + .split_once( + "\n\n#[derive(Clone, Debug, Default)]\n\ + #[allow(non_snake_case, dead_code)]\n\ + pub struct __RuleAttrs", + ) + .expect("generated rule attributes should follow the typed hook adapter"); + insta::assert_snapshot!( + "recog_receiver_typed_hook_adapter", + format!("pub trait RecogPredicateParserHooks{adapter}") + ); + + let test_source = r####" +#[cfg(test)] +mod recog_receiver_tests { + use super::recog_predicate_lexer::RecogPredicateLexer; + use super::recog_predicate_parser::{ + RecogPredicateParser, RecogPredicateParserHooks, + }; + use antlr4_runtime::{ + CommonTokenStream, InputStream, Parser as _, ParserSemCtx, TokenSource, + }; + use std::cell::Cell; + use std::rc::Rc; + + struct Hooks { + calls: Rc>, + } + + impl RecogPredicateParserHooks for Hooks { + fn is_ok(&mut self, _ctx: &mut ParserSemCtx<'_, L>) -> bool + where + L: TokenSource, + { + self.calls.set(self.calls.get() + 1); + true + } + } + + #[test] + fn typed_hook_accepts_the_predicated_alternative() { + let calls = Rc::new(Cell::new(0)); + let lexer = RecogPredicateLexer::new(InputStream::new("<<")); + let mut parser = RecogPredicateParser::with_typed_hooks( + CommonTokenStream::new(lexer), + Hooks { + calls: Rc::clone(&calls), + }, + ); + let _ = parser.shl().expect("predicated rule should parse"); + assert_eq!(parser.number_of_syntax_errors(), 0); + assert!(calls.get() > 0, "typed predicate hook was not invoked"); + } +} +"####; + + assert_generated_project( + temp.path(), + &["recog_predicate_lexer.rs", "recog_predicate_parser.rs"], + test_source, + ); +} + #[test] fn imported_lexer_action_generates_typed_hook_from_structural_body() { let temp = temporary_directory("imported-lexer-hook"); diff --git a/tests/snapshots/antlr4_rust_gen_cli__recog_receiver_semantics_manifest.snap b/tests/snapshots/antlr4_rust_gen_cli__recog_receiver_semantics_manifest.snap new file mode 100644 index 00000000..ac545907 --- /dev/null +++ b/tests/snapshots/antlr4_rust_gen_cli__recog_receiver_semantics_manifest.snap @@ -0,0 +1,5 @@ +--- +source: tests/antlr4_rust_gen_cli.rs +expression: predicate +--- + {"kind": "parser-predicate", "rule": "shl", "rule_index": 0, "index": 0, "atn_state": null, "line": 8, "column": 6, "body": "recog.IsOk()", "disposition": "hooked", "template": "Hook"} diff --git a/tests/snapshots/antlr4_rust_gen_cli__recog_receiver_typed_hook_adapter.snap b/tests/snapshots/antlr4_rust_gen_cli__recog_receiver_typed_hook_adapter.snap new file mode 100644 index 00000000..4032bbe5 --- /dev/null +++ b/tests/snapshots/antlr4_rust_gen_cli__recog_receiver_typed_hook_adapter.snap @@ -0,0 +1,49 @@ +--- +source: tests/antlr4_rust_gen_cli.rs +expression: "format!(\"pub trait RecogPredicateParserHooks{adapter}\")" +--- +pub trait RecogPredicateParserHooks: Sized { + fn is_ok(&mut self, ctx: &mut antlr4_runtime::ParserSemCtx<'_, L>) -> bool + where + L: TokenSource; + + /// Handles a committed parser action routed to the typed hook. Return + /// `true` when the action is handled so it satisfies a `hook`/`error` + /// unknown-semantic policy; the default no-op returns `false` (unhandled), + /// which fails loud under those policies. + fn custom_action(&mut self, _ctx: &mut antlr4_runtime::ParserSemCtx<'_, L>, _action: antlr4_runtime::ParserAction) -> bool + where + L: TokenSource, + { + false + } +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct RecogPredicateParserTypedHooks(pub T); + +impl RecogPredicateParserTypedHooks { + pub const fn new(inner: T) -> Self { Self(inner) } +} + +impl antlr4_runtime::SemanticHooks for RecogPredicateParserTypedHooks +where + T: RecogPredicateParserHooks, +{ + fn sempred(&mut self, ctx: &mut antlr4_runtime::ParserSemCtx<'_, L>, rule_index: usize, pred_index: usize) -> Option + where + L: TokenSource, + { + match (rule_index, pred_index) { + (0, 0) => Some(self.0.is_ok(ctx)), + _ => None, + } + } + + fn action(&mut self, ctx: &mut antlr4_runtime::ParserSemCtx<'_, L>, action: antlr4_runtime::ParserAction) -> bool + where + L: TokenSource, + { + self.0.custom_action(ctx, action) + } +}