Skip to content

Commit 75faf4c

Browse files
authored
Merge pull request #22945 from github/tausbn/yeast-emit-tokens-for-leaf-nodes-on-ast
yeast: Emit tokens for leaf nodes on AST
2 parents c9bbcf9 + 6f93249 commit 75faf4c

13 files changed

Lines changed: 376 additions & 48 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/BUILD.bazel‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,7 @@ rust_proc_macro(
88
srcs = glob(["src/**/*.rs"]),
99
aliases = aliases(),
1010
visibility = ["//visibility:public"],
11-
deps = all_crate_deps(),
11+
deps = all_crate_deps() + [
12+
"//shared/yeast-schema",
13+
],
1214
)

‎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)`.
@@ -126,6 +128,8 @@ pub(crate) enum Content {
126128
Static(Literal),
127129
/// Computed leaf content such as `#{name}` in `(identifier #{name})`.
128130
Computed(syn::Block),
131+
/// Value inherited from the node matched by the enclosing rule.
132+
Inherited,
129133
}
130134

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

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

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -210,8 +210,18 @@ pub fn rule(input: TokenStream) -> TokenStream {
210210
/// Paths are resolved relative to the consuming crate's `CARGO_MANIFEST_DIR`
211211
/// (the same convention `include_str!` uses for relative paths). The
212212
/// resolved paths are also emitted as `include_str!` references so the
213-
/// consuming crate gets invalidated when a schema YAML changes, prepping
214-
/// the ground for compile-time type-checking against those schemas.
213+
/// consuming crate gets invalidated when a schema YAML changes.
214+
///
215+
/// A bare output leaf used as a top-level rule result inherits the matched
216+
/// input node's value:
217+
///
218+
/// ```text
219+
/// rule!((superExpr) => (super_expr))
220+
/// ```
221+
///
222+
/// Whether the output kind is a leaf is determined from the output schema.
223+
/// Nested bare leaves are rejected at compile time because they have no
224+
/// unambiguous input node from which to inherit a value.
215225
#[proc_macro]
216226
pub fn rules(input: TokenStream) -> TokenStream {
217227
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
@@ -508,6 +508,7 @@ impl Node {
508508
}
509509
}
510510
}
511+
Content::Inherited => quote! { #ctx.inherited_literal(#kind_str) },
511512
};
512513
quote! { let __node: yeast::OutputId = #node; }
513514
} else {

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

Lines changed: 52 additions & 3 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() {
@@ -63,8 +82,15 @@ pub fn parse_rules_top(input: TokenStream) -> Result<TokenStream> {
6382
let emitted_items = split_top_level_commas(list.stream())
6483
.into_iter()
6584
.map(|item| {
66-
if has_top_level_arrow(&item) {
67-
Ok(crate::rule_parse::parse_rule(item)?.lower())
85+
let rule_body = if has_top_level_arrow(&item) {
86+
Some(item.clone())
87+
} else {
88+
explicit_rule_body(&item)
89+
};
90+
if let Some(rule_body) = rule_body {
91+
let mut rule = crate::rule_parse::parse_rule(rule_body)?;
92+
rule.resolve_bare_leaf_content(&output_leaf_kinds)?;
93+
Ok(rule.lower())
6894
} else {
6995
Ok(item)
7096
}
@@ -84,6 +110,7 @@ pub fn parse_rules_top(input: TokenStream) -> Result<TokenStream> {
84110

85111
struct NamedString {
86112
value: String,
113+
literal_span: Span,
87114
}
88115

89116
fn parse_named_string_arg(
@@ -109,7 +136,10 @@ fn parse_named_string_arg(
109136
format!("`{expected_name}` must be a string literal path"),
110137
)
111138
})?;
112-
Ok(NamedString { value })
139+
Ok(NamedString {
140+
value,
141+
literal_span: literal.span(),
142+
})
113143
}
114144

115145
fn string_literal_value(literal: &proc_macro2::Literal) -> Option<String> {
@@ -152,6 +182,17 @@ fn has_top_level_arrow(stream: &TokenStream) -> bool {
152182
})
153183
}
154184

185+
fn explicit_rule_body(stream: &TokenStream) -> Option<TokenStream> {
186+
let expression = syn::parse2::<syn::ExprMacro>(stream.clone()).ok()?;
187+
let is_rule = expression
188+
.mac
189+
.path
190+
.segments
191+
.last()
192+
.is_some_and(|segment| segment.ident == "rule");
193+
is_rule.then_some(expression.mac.tokens)
194+
}
195+
155196
type Tokens = std::iter::Peekable<proc_macro2::token_stream::IntoIter>;
156197

157198
fn expect_ident(tokens: &mut Tokens, message: &str) -> Result<syn::Ident> {
@@ -212,4 +253,12 @@ mod tests {
212253
&quote! { { match x { 1 => 2, _ => 3 } } }
213254
));
214255
}
256+
257+
#[test]
258+
fn recognizes_explicit_rule_macros() {
259+
assert!(explicit_rule_body(&quote! { rule!((a) => (b)) }).is_some());
260+
assert!(explicit_rule_body(&quote! { yeast::rule!((a) => (b)) }).is_some());
261+
assert!(explicit_rule_body(&quote! { other!((a) => (b)) }).is_none());
262+
assert!(explicit_rule_body(&quote! { make_rule() }).is_none());
263+
}
215264
}

‎shared/yeast-schema/src/node_types_yaml.rs‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,22 @@ pub fn schema_from_yaml(yaml_input: &str) -> Result<crate::schema::Schema, Strin
469469
Ok(schema)
470470
}
471471

472+
/// Return the named node kinds that declare no fields.
473+
pub fn leaf_kinds_from_yaml(yaml_input: &str) -> Result<BTreeSet<String>, String> {
474+
let yaml: YamlNodeTypes =
475+
serde_yaml::from_str(yaml_input).map_err(|e| format!("Failed to parse YAML: {e}"))?;
476+
Ok(yaml
477+
.named
478+
.into_iter()
479+
.filter_map(|(name, fields)| {
480+
fields
481+
.as_ref()
482+
.map_or(true, BTreeMap::is_empty)
483+
.then_some(name)
484+
})
485+
.collect())
486+
}
487+
472488
// ---------------------------------------------------------------------------
473489
// JSON → YAML conversion
474490
// ---------------------------------------------------------------------------
@@ -810,6 +826,24 @@ unnamed:
810826
assert_eq!(end["named"], false);
811827
}
812828

829+
#[test]
830+
fn test_leaf_kinds_from_yaml() {
831+
let leaf_kinds = leaf_kinds_from_yaml(
832+
r#"
833+
named:
834+
identifier:
835+
empty_fields: {}
836+
call:
837+
callee: identifier
838+
"#,
839+
)
840+
.unwrap();
841+
842+
assert!(leaf_kinds.contains("identifier"));
843+
assert!(leaf_kinds.contains("empty_fields"));
844+
assert!(!leaf_kinds.contains("call"));
845+
}
846+
813847
#[test]
814848
fn test_explicit_unnamed_disambiguation() {
815849
let yaml = r#"

‎shared/yeast-schema/src/schema.rs‎

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -277,13 +277,8 @@ impl Schema {
277277
.insert((parent_kind.to_string(), field_id), node_types);
278278
}
279279

280-
pub fn field_types(
281-
&self,
282-
parent_kind: &str,
283-
field_id: FieldId,
284-
) -> Option<&Vec<NodeType>> {
285-
self.field_types
286-
.get(&(parent_kind.to_string(), field_id))
280+
pub fn field_types(&self, parent_kind: &str, field_id: FieldId) -> Option<&Vec<NodeType>> {
281+
self.field_types.get(&(parent_kind.to_string(), field_id))
287282
}
288283

289284
/// Record the declared (named) field order for a node kind, as authored in

‎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)

0 commit comments

Comments
 (0)