Skip to content

Commit a4de84a

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 c78bf07 commit a4de84a

9 files changed

Lines changed: 259 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: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use proc_macro2::{Ident, Literal};
2+
use yeast_schema::schema::Schema;
23

34
/// A complete rewrite rule such as `(input) where guard => (output)`.
45
pub(crate) struct Rule {
@@ -117,6 +118,8 @@ pub(crate) enum Content {
117118
Static(Literal),
118119
/// Computed leaf content such as `#{name}` in `(identifier #{name})`.
119120
Computed(syn::Block),
121+
/// Value inherited from the node matched by the enclosing rule.
122+
Inherited,
120123
}
121124

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

‎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_schema =
64+
yeast_schema::node_types_yaml::schema_from_yaml(&output_yaml).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_schema)?;
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: 78 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,69 @@ 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 dump = run_and_dump("x = 1", rules);
709+
710+
assert_dump_eq(
711+
&dump,
712+
r#"
713+
program
714+
assignment
715+
left: identifier "x"
716+
right: identifier "1"
717+
"#,
718+
);
719+
}
720+
721+
#[test]
722+
fn test_bare_leaf_rule_result_inherits_synthetic_content() {
723+
let ast = run_ast_with_rules(
724+
synthetic_integer_ast("synthetic"),
725+
yeast::rules! {
726+
input: "tests/input-types.yml",
727+
output: "tests/node-types.yml",
728+
[
729+
rule!((integer) => (identifier)),
730+
]
731+
},
732+
);
733+
let dump = dump_ast(&ast, ast.get_root(), "");
734+
735+
assert_dump_eq(
736+
&dump,
737+
r#"
738+
identifier "synthetic"
739+
"#,
740+
);
741+
}
742+
743+
#[test]
744+
fn test_bare_leaf_rule_result_inherits_empty_content() {
745+
let ast = run_ast_with_rules(
746+
synthetic_integer_ast(""),
747+
yeast::rules! {
748+
input: "tests/input-types.yml",
749+
output: "tests/node-types.yml",
750+
[
751+
(integer) => (identifier),
752+
]
753+
},
754+
);
755+
756+
assert!(ast.reachable_node_ids().into_iter().any(|id| {
757+
let node = ast.get_node(id).unwrap();
758+
node.kind_name() == "identifier" && node.opt_string_content().as_deref() == Some("")
759+
}));
760+
}
761+
684762
#[test]
685763
fn test_tree_builder() {
686764
let input = "x = 1";

0 commit comments

Comments
 (0)