@@ -897,6 +897,219 @@ fn expect_repetition(tokens: &mut Tokens) -> Result<TokenStream> {
897897 }
898898}
899899
900+ // ---------------------------------------------------------------------------
901+ // rules! parsing — bundle a list of rules with input/output schema paths.
902+ //
903+ // The macro accepts both bare rule bodies (`(query) => (template)`) and
904+ // explicit `rule!(...)` invocations. The schema paths are recorded but
905+ // not yet consumed; a later change layers compile-time type-checking on
906+ // top, using these paths to load the input/output schemas.
907+ // ---------------------------------------------------------------------------
908+
909+ /// Parse `rules! { input: "path", output: "path", [ items, ... ] }`.
910+ ///
911+ /// Each item in the bracketed list can be:
912+ /// * a **bare rule body** `(query) => (template)` — wrapped implicitly
913+ /// in `yeast::rule! { ... }` for codegen;
914+ /// * an explicit `rule!(...)` (or `rule!(...).repeated()`,
915+ /// `yeast::rule!(...)`, etc.) — passed through verbatim;
916+ /// * any other expression returning a `Rule` (helper-function calls,
917+ /// conditionals) — passed through verbatim.
918+ ///
919+ /// Returns a `Vec<Rule>` containing the items in order. The expansion
920+ /// also emits `include_str!` references to the resolved schema paths so
921+ /// Cargo treats them as inputs to the consuming crate; this validates
922+ /// path existence at compile time and prepares the ground for later
923+ /// schema-aware checks.
924+ pub fn parse_rules_top ( input : TokenStream ) -> Result < TokenStream > {
925+ let mut tokens = input. into_iter ( ) . peekable ( ) ;
926+
927+ let input_path = parse_named_string_arg ( & mut tokens, "input" ) ?;
928+ expect_punct ( & mut tokens, ',' , "expected `,` after input path" ) ?;
929+ let output_path = parse_named_string_arg ( & mut tokens, "output" ) ?;
930+ expect_punct ( & mut tokens, ',' , "expected `,` after output path" ) ?;
931+
932+ // Resolve paths relative to the consuming crate's CARGO_MANIFEST_DIR
933+ // so callers can write paths like "tree-sitter-swift/node-types.yml"
934+ // alongside their other workspace-relative includes (e.g. include_str!).
935+ let manifest_dir = std:: env:: var ( "CARGO_MANIFEST_DIR" ) . map_err ( |_| {
936+ syn:: Error :: new (
937+ Span :: call_site ( ) ,
938+ "rules!: CARGO_MANIFEST_DIR is not set; cannot resolve schema paths" ,
939+ )
940+ } ) ?;
941+ let resolve_path = |raw : & str | -> std:: path:: PathBuf {
942+ let p = std:: path:: PathBuf :: from ( raw) ;
943+ if p. is_absolute ( ) {
944+ p
945+ } else {
946+ std:: path:: PathBuf :: from ( & manifest_dir) . join ( p)
947+ }
948+ } ;
949+ let input_abs = resolve_path ( & input_path. value ) ;
950+ let output_abs = resolve_path ( & output_path. value ) ;
951+
952+ let list = expect_group ( & mut tokens, Delimiter :: Bracket ) ?;
953+ if let Some ( tok) = tokens. next ( ) {
954+ return Err ( syn:: Error :: new_spanned (
955+ tok,
956+ "unexpected token after `rules!` list" ,
957+ ) ) ;
958+ }
959+
960+ let items = split_top_level_commas ( list. stream ( ) ) ;
961+ let emitted_items: Vec < TokenStream > = items
962+ . into_iter ( )
963+ . map ( |item| {
964+ // Bare rule body — wrap in `yeast::rule! { ... }` so the
965+ // existing rule-construction macro handles codegen. Other
966+ // items pass through unchanged.
967+ if has_top_level_arrow ( & item) {
968+ quote ! { yeast:: rule! { #item } }
969+ } else {
970+ item
971+ }
972+ } )
973+ . collect ( ) ;
974+
975+ // Emit `include_str!` references to both schema files so Cargo
976+ // treats them as inputs to the consuming crate's compilation. The
977+ // `const _` bindings are unused; rustc/LLVM drop them after the
978+ // file-input dependency edge is recorded. Absolute paths are used
979+ // because `include_str!` resolves relative paths against the source
980+ // file, while `rules!`'s own paths are relative to
981+ // `CARGO_MANIFEST_DIR`.
982+ let input_abs_str = input_abs. to_string_lossy ( ) . into_owned ( ) ;
983+ let output_abs_str = output_abs. to_string_lossy ( ) . into_owned ( ) ;
984+ let input_lit = proc_macro2:: Literal :: string ( & input_abs_str) ;
985+ let output_lit = proc_macro2:: Literal :: string ( & output_abs_str) ;
986+
987+ Ok ( quote ! {
988+ {
989+ const _: & :: core:: primitive:: str = :: core:: include_str!( #input_lit) ;
990+ const _: & :: core:: primitive:: str = :: core:: include_str!( #output_lit) ;
991+ vec![ #( #emitted_items) , * ]
992+ }
993+ } )
994+ }
995+
996+ /// True iff `item` contains a `=>` operator at the top level (not nested
997+ /// inside any group). Used to detect bare rule bodies inside `rules!`.
998+ fn has_top_level_arrow ( item : & TokenStream ) -> bool {
999+ let toks: Vec < TokenTree > = item. clone ( ) . into_iter ( ) . collect ( ) ;
1000+ find_top_level_arrow ( & toks) . is_some ( )
1001+ }
1002+
1003+ /// Find the index of the first token of a top-level `=>` operator (the
1004+ /// `=`), ignoring `=>` inside any group. Returns `None` if not present.
1005+ fn find_top_level_arrow ( toks : & [ TokenTree ] ) -> Option < usize > {
1006+ let mut i = 0 ;
1007+ while i + 1 < toks. len ( ) {
1008+ if let ( TokenTree :: Punct ( p1) , TokenTree :: Punct ( p2) ) = ( & toks[ i] , & toks[ i + 1 ] ) {
1009+ if p1. as_char ( ) == '='
1010+ && p1. spacing ( ) == proc_macro2:: Spacing :: Joint
1011+ && p2. as_char ( ) == '>'
1012+ {
1013+ return Some ( i) ;
1014+ }
1015+ }
1016+ i += 1 ;
1017+ }
1018+ None
1019+ }
1020+
1021+ /// A string literal argument named `expected_name` parsed from `name: "value"`.
1022+ struct NamedString {
1023+ value : String ,
1024+ #[ allow( dead_code) ]
1025+ span : Span ,
1026+ }
1027+
1028+ fn parse_named_string_arg ( tokens : & mut Tokens , expected_name : & str ) -> Result < NamedString > {
1029+ let name = expect_ident (
1030+ tokens,
1031+ & format ! ( "expected `{expected_name}:` argument" ) ,
1032+ ) ?;
1033+ if name != expected_name {
1034+ return Err ( syn:: Error :: new_spanned (
1035+ name,
1036+ format ! ( "expected `{expected_name}:` argument" ) ,
1037+ ) ) ;
1038+ }
1039+ expect_punct (
1040+ tokens,
1041+ ':' ,
1042+ & format ! ( "expected `:` after `{expected_name}`" ) ,
1043+ ) ?;
1044+ let lit = expect_literal ( tokens) ?;
1045+ let span = lit. span ( ) ;
1046+ let value = string_literal_value ( & lit) . ok_or_else ( || {
1047+ syn:: Error :: new (
1048+ span,
1049+ format ! ( "`{expected_name}` must be a string literal path" ) ,
1050+ )
1051+ } ) ?;
1052+ Ok ( NamedString { value, span } )
1053+ }
1054+
1055+ /// Read a literal as a plain Rust string, stripping the surrounding quotes
1056+ /// and unescaping. Falls back to `None` if the literal isn't a string.
1057+ fn string_literal_value ( lit : & Literal ) -> Option < String > {
1058+ let raw = lit. to_string ( ) ;
1059+ let bytes = raw. as_bytes ( ) ;
1060+ // Match plain `"..."` literals; reject byte strings, raw strings (for
1061+ // simplicity), char literals, numbers, etc.
1062+ if bytes. first ( ) != Some ( & b'"' ) || bytes. last ( ) != Some ( & b'"' ) {
1063+ return None ;
1064+ }
1065+ let mut out = String :: with_capacity ( raw. len ( ) ) ;
1066+ let mut chars = raw[ 1 ..raw. len ( ) - 1 ] . chars ( ) ;
1067+ while let Some ( c) = chars. next ( ) {
1068+ if c != '\\' {
1069+ out. push ( c) ;
1070+ continue ;
1071+ }
1072+ match chars. next ( ) ? {
1073+ 'n' => out. push ( '\n' ) ,
1074+ 't' => out. push ( '\t' ) ,
1075+ 'r' => out. push ( '\r' ) ,
1076+ '\\' => out. push ( '\\' ) ,
1077+ '\'' => out. push ( '\'' ) ,
1078+ '"' => out. push ( '"' ) ,
1079+ '0' => out. push ( '\0' ) ,
1080+ other => {
1081+ // Unknown escape — give up rather than silently mis-parse.
1082+ out. push ( '\\' ) ;
1083+ out. push ( other) ;
1084+ }
1085+ }
1086+ }
1087+ Some ( out)
1088+ }
1089+
1090+ /// Split a token stream into top-level comma-separated items. Commas inside
1091+ /// any group token (parens, brackets, braces) are ignored so that things
1092+ /// like `rule!(a, b)` aren't accidentally split.
1093+ fn split_top_level_commas ( stream : TokenStream ) -> Vec < TokenStream > {
1094+ let mut items = Vec :: new ( ) ;
1095+ let mut current: Vec < TokenTree > = Vec :: new ( ) ;
1096+ for tt in stream {
1097+ if let TokenTree :: Punct ( p) = & tt {
1098+ if p. as_char ( ) == ',' && p. spacing ( ) == proc_macro2:: Spacing :: Alone {
1099+ if !current. is_empty ( ) {
1100+ items. push ( current. drain ( ..) . collect ( ) ) ;
1101+ }
1102+ continue ;
1103+ }
1104+ }
1105+ current. push ( tt) ;
1106+ }
1107+ if !current. is_empty ( ) {
1108+ items. push ( current. into_iter ( ) . collect ( ) ) ;
1109+ }
1110+ items
1111+ }
1112+
9001113fn maybe_wrap_capture ( tokens : & mut Tokens , base : TokenStream ) -> Result < TokenStream > {
9011114 if peek_is_at ( tokens) {
9021115 let name = consume_capture_marker ( tokens) ?;
@@ -970,3 +1183,33 @@ fn maybe_wrap_list_capture(tokens: &mut Tokens, elem: TokenStream) -> Result<Tok
9701183 Ok ( elem)
9711184 }
9721185}
1186+
1187+ // ---------------------------------------------------------------------------
1188+ // Internal unit tests for the rules! macro shape. Type-checking tests
1189+ // land in the follow-up that wires schema validation in.
1190+ // ---------------------------------------------------------------------------
1191+ #[ cfg( test) ]
1192+ mod rules_tests {
1193+ use super :: * ;
1194+ use quote:: quote;
1195+
1196+ #[ test]
1197+ fn has_top_level_arrow_distinguishes_bare_rules ( ) {
1198+ // Bare rule body: top-level `=>` is present.
1199+ let toks = quote ! { ( a) => ( b) } ;
1200+ assert ! ( has_top_level_arrow( & toks) ) ;
1201+ // `rule!((a) => (b))`: the `=>` is INSIDE the macro group, so
1202+ // it's not at top level. Must NOT be detected as a bare body.
1203+ let toks = quote ! { rule!( ( a) => ( b) ) } ;
1204+ assert ! ( !has_top_level_arrow( & toks) ) ;
1205+ // Helper call: no `=>` anywhere.
1206+ let toks = quote ! { make_rule( ) } ;
1207+ assert ! ( !has_top_level_arrow( & toks) ) ;
1208+ // Match expressions inside a block: `=>` is inside braces.
1209+ let toks = quote ! { { match x { 1 => 2 , _ => 3 } } } ;
1210+ assert ! ( !has_top_level_arrow( & toks) ) ;
1211+ // Bare shorthand form: top-level `=>` followed by a bare ident.
1212+ let toks = quote ! { ( a) => kind } ;
1213+ assert ! ( has_top_level_arrow( & toks) ) ;
1214+ }
1215+ }
0 commit comments