Skip to content

Commit e3493c7

Browse files
committed
yeast: Support inherited leaf content in rules!
A bare leaf used as a rule result has an obvious value: the value of the node matched by the rule. Using the output schema we can now distinguish leaves from fieldless structural nodes and mark top-level bare leaves as inherited content. For nested bare leaves, there's no obvious input node from which to inherit their value, so these still need to be handled explicitly.
1 parent 0f4e162 commit e3493c7

9 files changed

Lines changed: 270 additions & 4 deletions

File tree

‎Cargo.lock‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎shared/yeast-macros/Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ proc-macro = true
1010
proc-macro2 = "1.0"
1111
quote = "1.0"
1212
syn = { version = "3.0", features = ["full"] }
13+
yeast-schema = { path = "../yeast-schema" }

‎shared/yeast-macros/src/ast.rs‎

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
use std::collections::BTreeSet;
2+
13
use proc_macro2::{Ident, Literal};
24

35
/// A complete rewrite rule such as `(input) where guard => (output)`.
@@ -117,6 +119,8 @@ pub(crate) enum Content {
117119
Static(Literal),
118120
/// Computed leaf content such as `#{name}` in `(identifier #{name})`.
119121
Computed(syn::Block),
122+
/// Value inherited from the node matched by the enclosing rule.
123+
Inherited,
120124
}
121125

122126
pub(crate) enum Field {
@@ -137,3 +141,117 @@ pub(crate) enum Field {
137141
block: syn::Block,
138142
},
139143
}
144+
145+
impl Rule {
146+
pub(crate) fn resolve_bare_leaf_content(
147+
&mut self,
148+
leaf_kinds: &BTreeSet<String>,
149+
) -> syn::Result<()> {
150+
let Replacement::Templates(templates) = &mut self.replacement else {
151+
return Ok(());
152+
};
153+
for template in templates {
154+
template.resolve_bare_leaf_content(leaf_kinds, true)?;
155+
}
156+
Ok(())
157+
}
158+
}
159+
160+
impl Template {
161+
fn resolve_bare_leaf_content(
162+
&mut self,
163+
leaf_kinds: &BTreeSet<String>,
164+
is_rule_result: bool,
165+
) -> syn::Result<()> {
166+
let Template::Node(node) = self else {
167+
return Ok(());
168+
};
169+
170+
for field in &mut node.fields {
171+
if let Field::Named { value, .. } = field {
172+
value.resolve_bare_leaf_content(leaf_kinds, false)?;
173+
}
174+
}
175+
176+
if node.content.is_none()
177+
&& node.fields.is_empty()
178+
&& leaf_kinds.contains(node.kind.to_string().as_str())
179+
{
180+
if !is_rule_result {
181+
return Err(syn::Error::new_spanned(
182+
&node.kind,
183+
format!(
184+
"bare leaf `{}` must be a top-level rule result or have explicit content",
185+
node.kind
186+
),
187+
));
188+
}
189+
node.content = Some(Content::Inherited);
190+
}
191+
192+
Ok(())
193+
}
194+
}
195+
196+
#[cfg(test)]
197+
mod tests {
198+
use super::*;
199+
use quote::quote;
200+
201+
fn output_leaf_kinds() -> BTreeSet<String> {
202+
yeast_schema::node_types_yaml::leaf_kinds_from_yaml(
203+
r#"
204+
named:
205+
assignment:
206+
left: identifier
207+
identifier:
208+
"#,
209+
)
210+
.unwrap()
211+
}
212+
213+
#[test]
214+
fn marks_top_level_bare_leaves_as_inherited() {
215+
let mut rule = crate::rule_parse::parse_rule(quote!((source) => (identifier))).unwrap();
216+
rule.resolve_bare_leaf_content(&output_leaf_kinds())
217+
.unwrap();
218+
219+
let Replacement::Templates(templates) = rule.replacement else {
220+
panic!("expected template replacement");
221+
};
222+
let [Template::Node(node)] = templates.as_slice() else {
223+
panic!("expected one output node");
224+
};
225+
assert!(matches!(node.content, Some(Content::Inherited)));
226+
}
227+
228+
#[test]
229+
fn rejects_nested_bare_leaves() {
230+
let mut rule =
231+
crate::rule_parse::parse_rule(quote!((source) => (assignment left: (identifier))))
232+
.unwrap();
233+
let error = rule
234+
.resolve_bare_leaf_content(&output_leaf_kinds())
235+
.unwrap_err();
236+
237+
assert_eq!(
238+
error.to_string(),
239+
"bare leaf `identifier` must be a top-level rule result or have explicit content"
240+
);
241+
}
242+
243+
#[test]
244+
fn leaves_fieldless_structural_nodes_unchanged() {
245+
let mut rule = crate::rule_parse::parse_rule(quote!((source) => (assignment))).unwrap();
246+
rule.resolve_bare_leaf_content(&output_leaf_kinds())
247+
.unwrap();
248+
249+
let Replacement::Templates(templates) = rule.replacement else {
250+
panic!("expected template replacement");
251+
};
252+
let [Template::Node(node)] = templates.as_slice() else {
253+
panic!("expected one output node");
254+
};
255+
assert!(node.content.is_none());
256+
}
257+
}

‎shared/yeast-macros/src/lib.rs‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,8 +201,18 @@ pub fn rule(input: TokenStream) -> TokenStream {
201201
/// Paths are resolved relative to the consuming crate's `CARGO_MANIFEST_DIR`
202202
/// (the same convention `include_str!` uses for relative paths). The
203203
/// resolved paths are also emitted as `include_str!` references so the
204-
/// consuming crate gets invalidated when a schema YAML changes, prepping
205-
/// the ground for compile-time type-checking against those schemas.
204+
/// consuming crate gets invalidated when a schema YAML changes.
205+
///
206+
/// A bare output leaf used as a top-level rule result inherits the matched
207+
/// input node's value:
208+
///
209+
/// ```text
210+
/// rule!((superExpr) => (super_expr))
211+
/// ```
212+
///
213+
/// Whether the output kind is a leaf is determined from the output schema.
214+
/// Nested bare leaves are rejected at compile time because they have no
215+
/// unambiguous input node from which to inherit a value.
206216
#[proc_macro]
207217
pub fn rules(input: TokenStream) -> TokenStream {
208218
let input2: TokenStream2 = input.into();

‎shared/yeast-macros/src/lower.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,7 @@ impl Node {
380380
}
381381
}
382382
}
383+
Content::Inherited => quote! { #ctx.inherited_literal(#kind_str) },
383384
};
384385
quote! { let __node: yeast::OutputId = #node; }
385386
} else {

‎shared/yeast-macros/src/parse.rs‎

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,25 @@ pub fn parse_rules_top(input: TokenStream) -> Result<TokenStream> {
5151
};
5252
let input_abs = resolve_path(&input_path.value);
5353
let output_abs = resolve_path(&output_path.value);
54+
let output_yaml = std::fs::read_to_string(&output_abs).map_err(|error| {
55+
syn::Error::new(
56+
output_path.literal_span,
57+
format!(
58+
"rules!: failed to read output schema `{}`: {error}",
59+
output_abs.display()
60+
),
61+
)
62+
})?;
63+
let output_leaf_kinds = yeast_schema::node_types_yaml::leaf_kinds_from_yaml(&output_yaml)
64+
.map_err(|error| {
65+
syn::Error::new(
66+
output_path.literal_span,
67+
format!(
68+
"rules!: failed to parse output schema `{}`: {error}",
69+
output_abs.display()
70+
),
71+
)
72+
})?;
5473

5574
let list = expect_group(&mut tokens, proc_macro2::Delimiter::Bracket)?;
5675
if let Some(token) = tokens.next() {
@@ -69,7 +88,9 @@ pub fn parse_rules_top(input: TokenStream) -> Result<TokenStream> {
6988
explicit_rule_body(&item)
7089
};
7190
if let Some(rule_body) = rule_body {
72-
Ok(crate::rule_parse::parse_rule(rule_body)?.lower())
91+
let mut rule = crate::rule_parse::parse_rule(rule_body)?;
92+
rule.resolve_bare_leaf_content(&output_leaf_kinds)?;
93+
Ok(rule.lower())
7394
} else {
7495
Ok(item)
7596
}
@@ -89,6 +110,7 @@ pub fn parse_rules_top(input: TokenStream) -> Result<TokenStream> {
89110

90111
struct NamedString {
91112
value: String,
113+
literal_span: Span,
92114
}
93115

94116
fn parse_named_string_arg(
@@ -114,7 +136,10 @@ fn parse_named_string_arg(
114136
format!("`{expected_name}` must be a string literal path"),
115137
)
116138
})?;
117-
Ok(NamedString { value })
139+
Ok(NamedString {
140+
value,
141+
literal_span: literal.span(),
142+
})
118143
}
119144

120145
fn string_literal_value(literal: &proc_macro2::Literal) -> Option<String> {

‎shared/yeast/src/build.rs‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,27 @@ impl<'a, C> BuildCtx<'a, C> {
236236
)
237237
}
238238

239+
/// Create a leaf whose value is inherited from the current rule's match.
240+
#[doc(hidden)]
241+
pub fn inherited_literal(&mut self, kind: &'static str) -> OutputId {
242+
let source = self
243+
.translator
244+
.as_ref()
245+
.expect("inherited leaf content requires a rule match")
246+
.matched_root();
247+
let node = self
248+
.ast
249+
.get_node(source)
250+
.expect("rule source node must exist");
251+
let stored = node.opt_string_content();
252+
let has_children = node.fields.values().any(|children| !children.is_empty());
253+
let content = match stored {
254+
Some(value) if !value.is_empty() || !has_children => value,
255+
_ => self.ast.source_text(source),
256+
};
257+
self.create_named_token_with_range(kind, content, None)
258+
}
259+
239260
/// Create a leaf node with a fixed string content.
240261
pub fn literal(&mut self, kind: &'static str, value: &str) -> OutputId {
241262
self.create_named_token_with_range(kind, value.to_string(), None)

‎shared/yeast/src/lib.rs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1117,6 +1117,12 @@ impl<C> Clone for TranslatorHandle<'_, C> {
11171117
}
11181118
}
11191119

1120+
impl<C> TranslatorHandle<'_, C> {
1121+
pub(crate) fn matched_root(&self) -> Id {
1122+
self.matched_root
1123+
}
1124+
}
1125+
11201126
impl<'a, C: Clone> TranslatorHandle<'a, C> {
11211127
/// Recursively apply the current phase's rules to `id` and return the
11221128
/// resulting node ids.

‎shared/yeast/tests/test.rs‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,21 @@ fn run_and_ast(input: &str, rules: Vec<Rule>) -> OutputAst {
109109
runner.run(input).unwrap()
110110
}
111111

112+
fn run_ast_with_rules(ast: Ast, rules: Vec<Rule>) -> OutputAst {
113+
let schema = yeast::node_types_yaml::schema_from_yaml(OUTPUT_SCHEMA_YAML).unwrap();
114+
let phases = [Phase::new("test", rules)];
115+
let runner: Runner = Runner::with_schema_no_language(&schema, &phases);
116+
runner.run_from_ast(ast).unwrap()
117+
}
118+
119+
fn synthetic_integer_ast(content: &str) -> Ast {
120+
let lang: tree_sitter::Language = tree_sitter_ruby::LANGUAGE.into();
121+
let mut ast = Ast::with_schema(yeast::schema::from_language(&lang));
122+
let root = ast.create_named_token("integer", content.to_owned());
123+
ast.set_root(root);
124+
ast
125+
}
126+
112127
/// Helper: translate Ruby source with multiple rule phases and return its dump.
113128
fn run_phased_and_dump(input: &str, phases: Vec<Phase>) -> String {
114129
let lang: tree_sitter::Language = tree_sitter_ruby::LANGUAGE.into();
@@ -681,6 +696,74 @@ fn test_forward_scan_preserves_order() {
681696

682697
// ---- Tree builder tests ----
683698

699+
#[test]
700+
fn test_bare_leaf_rule_result_inherits_parsed_source_text() {
701+
let rules: Vec<Rule> = yeast::rules! {
702+
input: "tests/input-types.yml",
703+
output: "tests/node-types.yml",
704+
[
705+
(integer) => (identifier),
706+
]
707+
};
708+
let ast = run_and_ast("x = 1", rules);
709+
let dump = dump_ast(&ast, ast.get_root(), "x = 1");
710+
711+
assert_dump_eq(
712+
&dump,
713+
r#"
714+
program
715+
assignment
716+
left: identifier "x"
717+
right: identifier "1"
718+
"#,
719+
);
720+
assert!(ast.reachable_node_ids().into_iter().any(|id| {
721+
let node = ast.get_node(id).unwrap();
722+
node.kind_name() == "identifier" && node.opt_string_content().as_deref() == Some("1")
723+
}));
724+
}
725+
726+
#[test]
727+
fn test_bare_leaf_rule_result_inherits_synthetic_content() {
728+
let ast = run_ast_with_rules(
729+
synthetic_integer_ast("synthetic"),
730+
yeast::rules! {
731+
input: "tests/input-types.yml",
732+
output: "tests/node-types.yml",
733+
[
734+
rule!((integer) => (identifier)),
735+
]
736+
},
737+
);
738+
let dump = dump_ast(&ast, ast.get_root(), "");
739+
740+
assert_dump_eq(
741+
&dump,
742+
r#"
743+
identifier "synthetic"
744+
"#,
745+
);
746+
}
747+
748+
#[test]
749+
fn test_bare_leaf_rule_result_inherits_empty_content() {
750+
let ast = run_ast_with_rules(
751+
synthetic_integer_ast(""),
752+
yeast::rules! {
753+
input: "tests/input-types.yml",
754+
output: "tests/node-types.yml",
755+
[
756+
(integer) => (identifier),
757+
]
758+
},
759+
);
760+
761+
assert!(ast.reachable_node_ids().into_iter().any(|id| {
762+
let node = ast.get_node(id).unwrap();
763+
node.kind_name() == "identifier" && node.opt_string_content().as_deref() == Some("")
764+
}));
765+
}
766+
684767
#[test]
685768
fn test_tree_builder() {
686769
let input = "x = 1";

0 commit comments

Comments
 (0)