Skip to content

Commit ea36f2d

Browse files
committed
yeast: add rules! macro
This macro allows the easy addition of multiple rules at the same time. In addition, it also accepts an input and output schema, which eventually will be used to check the validity of the rewrite rules.
1 parent 1fb4f9d commit ea36f2d

7 files changed

Lines changed: 510 additions & 1 deletion

File tree

shared/yeast-macros/src/lib.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,3 +113,43 @@ pub fn rule(input: TokenStream) -> TokenStream {
113113
Err(err) => err.to_compile_error().into(),
114114
}
115115
}
116+
117+
/// Bundle a list of YEAST rewrite rules with input/output node-types
118+
/// schema paths. Returns a `Vec<Rule>`; substitutable for
119+
/// `vec![rule!(...), ...]`.
120+
///
121+
/// Each comma-separated item in the bracketed list may be:
122+
///
123+
/// 1. A **bare rule body** `(query) => (template)` — the `rule!(...)`
124+
/// wrapper is implicit.
125+
/// 2. An explicit `rule!(...)` invocation, possibly chained as
126+
/// `rule!(...).repeated()` or path-prefixed as `yeast::rule!(...)`.
127+
/// 3. Any other expression returning a `Rule` (helper-function calls,
128+
/// conditionals).
129+
///
130+
/// ```ignore
131+
/// let translation_rules: Vec<yeast::Rule> = yeast::rules! {
132+
/// input: "tree-sitter-swift/node-types.yml",
133+
/// output: "ast_types.yml",
134+
/// [
135+
/// (source_file (_)* @cs) => (top_level body: {..cs}),
136+
/// (simple_identifier) @id => (name_expr identifier: (identifier #{id})),
137+
/// rule!((integer_literal) @lit => (int_literal #{lit})).repeated(),
138+
/// helper_fn(),
139+
/// ]
140+
/// };
141+
/// ```
142+
///
143+
/// Paths are resolved relative to the consuming crate's `CARGO_MANIFEST_DIR`
144+
/// (the same convention `include_str!` uses for relative paths). The
145+
/// resolved paths are also emitted as `include_str!` references so the
146+
/// consuming crate gets invalidated when a schema YAML changes, prepping
147+
/// the ground for compile-time type-checking against those schemas.
148+
#[proc_macro]
149+
pub fn rules(input: TokenStream) -> TokenStream {
150+
let input2: TokenStream2 = input.into();
151+
match parse::parse_rules_top(input2) {
152+
Ok(output) => output.into(),
153+
Err(err) => err.to_compile_error().into(),
154+
}
155+
}

shared/yeast-macros/src/parse.rs

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
9001113
fn 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+
}

shared/yeast/doc/yeast.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,3 +437,44 @@ For the dbscheme/QL code generator, set `Language::desugar` to a
437437
`DesugaringConfig` carrying the same YAML; the generator converts it to
438438
JSON for downstream code generation. The `phases` field of the config is
439439
unused at code-generation time.
440+
441+
## The `rules!` macro
442+
443+
The [`rules!`] macro bundles a list of rewrite rules with the input and
444+
output node-types schema paths. It's a drop-in replacement for the
445+
hand-written `vec![rule!(...), rule!(...), ...]` form and accepts a
446+
slightly looser syntax: bare rule bodies don't need an explicit
447+
`rule!(...)` wrapper.
448+
449+
```rust
450+
let translation_rules: Vec<yeast::Rule> = yeast::rules! {
451+
input: "tree-sitter-swift/node-types.yml",
452+
output: "ast_types.yml",
453+
[
454+
(simple_identifier) @name
455+
=>
456+
(name_expr identifier: (identifier #{name})),
457+
458+
(integer_literal) @lit
459+
=>
460+
(int_literal #{lit}),
461+
]
462+
};
463+
```
464+
465+
Each comma-separated item in the bracketed list may be:
466+
467+
- A **bare rule body** `(query) => (template)` — no `rule!(...)` wrapper.
468+
- An explicit `rule!(...)` invocation, with optional postfix calls such
469+
as `rule!(...).repeated()`.
470+
- Any other expression returning a `Rule` (helper functions, etc.).
471+
472+
Schema paths are resolved relative to the consuming crate's
473+
`CARGO_MANIFEST_DIR` (the same convention `include_str!` uses for
474+
relative paths). The resolved paths are emitted as `include_str!`
475+
references in the expansion so the consuming crate's incremental cache
476+
invalidates when a schema YAML changes — laying the groundwork for
477+
schema-aware compile-time checks on the rule bodies.
478+
479+
The `Vec<Rule>` produced by `rules!` flows into `add_phase` exactly as
480+
before.

shared/yeast/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ pub mod schema;
1515
pub mod tree_builder;
1616
mod visitor;
1717

18-
pub use yeast_macros::{query, rule, tree, trees};
18+
pub use yeast_macros::{query, rule, rules, tree, trees};
1919

2020
use captures::Captures;
2121
use query::QueryNode;

shared/yeast/tests/input-types.yml

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# Test input schema for yeast rules! macro tests. Covers a small subset of
2+
# tree-sitter-ruby kinds used by the test rules. Kept deliberately small so
3+
# the macro's compile-time loader can be exercised over a known surface.
4+
5+
named:
6+
program:
7+
$children*: [assignment, call, identifier, for]
8+
9+
assignment:
10+
left: [identifier, left_assignment_list]
11+
right: [identifier, integer, call]
12+
13+
left_assignment_list:
14+
$children*: identifier
15+
16+
for:
17+
pattern: [identifier, left_assignment_list]
18+
value: in
19+
body: do
20+
21+
in:
22+
$children: [identifier, call]
23+
24+
do:
25+
$children*: [identifier, assignment, call]
26+
27+
call:
28+
receiver: [identifier, call]
29+
method: identifier
30+
31+
identifier:
32+
integer:
33+
34+
unnamed:
35+
- "="
36+
- ","
37+
- "for"
38+
- "in"
39+
- "do"
40+
- "end"

0 commit comments

Comments
 (0)