From ceecde84633fd1fb02a588df73ad0002380d5da9 Mon Sep 17 00:00:00 2001 From: oliver Date: Mon, 13 Jul 2026 19:17:16 +0000 Subject: [PATCH 01/10] Add egglog-quote quasiquote macros (rebased onto main) Token-tree quasiquotes that route egglog written as Rust tokens through the parser: `expr!`, `fact!`, `facts!`, `action!`, `actions!`, `command!`, `egglog!`, `rule!`, `sexp!`, `sexps!`. Adds: - the `egglog-quote` proc-macro crate, - `parse.rs` helpers: `atom_to_sexp`, `keyword_to_sexp`, the `ToSexp` trait (+ impls for Sexp/&str/String/i64/Expr), `expr_to_sexp`, and an iterative `Display for Sexp` (heap work-stack, so deep list chains can't overflow), - re-exports from the crate root and `prelude`. The proc-macros replace the old infallible `macro_rules!` `expr!`/`fact!`/ `facts!`/`action!`/`actions!`: they now go through the parser (so `?x`, `:field`, `...`, `#`-splices, and every registered parser macro work) and return `Result`. Ported the call sites in tests/benches accordingly. Upstream's `query`/`rule`/`rust_rule` API is kept; `rule!` is reachable as `egglog::rule!` but not re-exported into `prelude` (it would collide with the `rule()` helper fn). `#(expr)` splices unwrap the parens so the generated `to_sexp(expr, ..)` is `unused_parens`-clean under `-D warnings`. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 9 + Cargo.toml | 4 + benches/rust_api_benchmarking.rs | 11 +- quote-macro/Cargo.toml | 16 ++ quote-macro/src/lib.rs | 464 +++++++++++++++++++++++++++++++ src/ast/parse.rs | 129 +++++++++ src/lib.rs | 5 +- src/prelude.rs | 126 +++------ tests/api_const_fold.rs | 3 +- tests/api_proofs.rs | 6 +- tests/api_query.rs | 14 +- tests/extraction_proof_mode.rs | 8 +- 12 files changed, 685 insertions(+), 110 deletions(-) create mode 100644 quote-macro/Cargo.toml create mode 100644 quote-macro/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 58ef578b5..4c5f16066 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -471,6 +471,7 @@ dependencies = [ "egglog-bridge", "egglog-core-relations", "egglog-numeric-id", + "egglog-quote", "egglog-reports", "egraph-serialize", "enum-map", @@ -582,6 +583,14 @@ dependencies = [ name = "egglog-numeric-id" version = "2.0.0" +[[package]] +name = "egglog-quote" +version = "2.0.0" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "egglog-reports" version = "2.0.0" diff --git a/Cargo.toml b/Cargo.toml index 7df6396e5..619fa6fac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "numeric-id", "union-find", "src/sort/add_primitive", + "quote-macro", "wasm-example" ] @@ -39,6 +40,7 @@ rayon = "1.10.0" mimalloc = "0.1" quote = "1.0" syn = "2.0" +proc-macro2 = "1.0" arc-swap = "1.7.1" fixedbitset = "0.5" smallvec = "1.10" @@ -77,6 +79,7 @@ egglog-concurrency = { path = "concurrency", version = "2.0.0" } egglog-numeric-id = { path = "numeric-id", version = "2.0.0" } egglog-union-find = { path = "union-find", version = "2.0.0" } egglog-add-primitive = { path = "src/sort/add_primitive", version = "2.0.0" } +egglog-quote = { path = "quote-macro", version = "2.0.0" } egglog-reports = { path = "egglog-reports", version = "2.0.0" } egglog = { path = ".", default-features = false, version = "2.0.0" } @@ -154,6 +157,7 @@ egglog-ast = { workspace = true } egglog-bridge = { workspace = true } egglog-numeric-id = { workspace = true } egglog-add-primitive = { workspace = true } +egglog-quote = { workspace = true } egglog-reports = { workspace = true } serde_json = { workspace = true } diff --git a/benches/rust_api_benchmarking.rs b/benches/rust_api_benchmarking.rs index b05293a2b..c533f55fb 100644 --- a/benches/rust_api_benchmarking.rs +++ b/benches/rust_api_benchmarking.rs @@ -43,7 +43,7 @@ fn match_only_rust_rule_setup(case: RustRuleBenchCase) -> RustRuleBenchInput { "rust_rule_bench", ruleset, vars![x: i64], - facts![(R x)], + facts![(R x)].unwrap(), |_ctx, _values| Some(()), ) .unwrap(); @@ -136,7 +136,7 @@ fn insert_loop_setup(case: RustRuleInsertLoopBenchCase) -> RustRuleBenchInput { "rust_rule_insert_loop", ruleset, vars![x: i64], - facts![(R x)], + facts![(R x)].unwrap(), // insert f(x) = x + 1, f(x+1) = x + 2, ..., f(x+n_ops-1) = x + n_ops in one rule run move |mut ctx, _values| { for i in 0..case.n_ops { @@ -185,7 +185,7 @@ fn tableaction_hot_path_setup(case: RustRuleTableActionBenchCase) -> RustRuleBen "rust_rule_tableaction_hot_path_fill", fill_ruleset, vars![x: i64], - facts![(R x)], + facts![(R x)].unwrap(), move |mut ctx, values| { let [x] = values else { unreachable!() }; let x = ctx.value_to_base::(*x); @@ -208,7 +208,7 @@ fn tableaction_hot_path_setup(case: RustRuleTableActionBenchCase) -> RustRuleBen "rust_rule_tableaction_hot_path_read", read_ruleset, vars![x: i64], - facts![(R x)], + facts![(R x)].unwrap(), move |ctx, values| { let [x] = values else { unreachable!() }; let _ = ctx.lookup("f", *x).ok().flatten()?; @@ -347,7 +347,8 @@ fn fib_setup() -> RustRuleBenchInput { facts![ (= f0 (fib x)) (= f1 (fib (+ x 1))) - ], + ] + .unwrap(), move |mut ctx, values| { let [x, f0, f1] = values else { unreachable!() }; let x = ctx.value_to_base::(*x); diff --git a/quote-macro/Cargo.toml b/quote-macro/Cargo.toml new file mode 100644 index 000000000..4807ca4ef --- /dev/null +++ b/quote-macro/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "egglog-quote" +version = { workspace = true } +edition = { workspace = true } +description = { workspace = true } +repository = { workspace = true } +keywords = { workspace = true } +license = { workspace = true } +readme = { workspace = true } + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = { workspace = true, features = ["span-locations"] } +quote = { workspace = true } diff --git a/quote-macro/src/lib.rs b/quote-macro/src/lib.rs new file mode 100644 index 000000000..0790b54f1 --- /dev/null +++ b/quote-macro/src/lib.rs @@ -0,0 +1,464 @@ +//! Token-tree quasiquotes for writing egglog inline in Rust. +//! +//! Write egglog as ordinary Rust tokens (no quotes); these macros rebuild the +//! s-expression and run it through the real parser, so every registered parser +//! macro fires (named args, `for`, `...`) and `?x`, `:field`, negative +//! literals, dashed atoms (`:no-merge`), and operators all work. They are +//! re-exported from `egglog` and `egglog::prelude`. +//! +//! # Macros +//! +//! Each parses one grammar category and returns a `Result` (except `sexp!` / +//! `sexps!`, which don't parse): +//! +//! | Macro | Produces | +//! |---|---| +//! | `expr!` | one `Expr` | +//! | `fact!` / `facts!` | one `Fact` / a `Facts` | +//! | `action!` / `actions!` | a `Vec` / an `Actions` | +//! | `command!` | a `Vec` (one command) | +//! | `egglog!` | a `Vec` (a whole program — run with `egraph.run_program`) | +//! | `rule!` | a `Vec` (sugar for `(rule () ())`) | +//! | `sexp!` / `sexps!` | a raw `Sexp` / `Vec`, un-parsed (for splicing) | +//! +//! A leading `parser,` is optional: with it, parsing uses your `Parser` (and +//! its registered macros/sorts); without it, a fresh default `Parser`. +//! (`sexp!` / `sexps!` never take one.) +//! +//! # Splices +//! +//! | Form | Meaning | +//! |---|---| +//! | `#x` / `#(expr)` | one value implementing `egglog::ast::ToSexp` | +//! | `#..xs` | spread (Racket's `,@`): extend the list with each element of `xs: IntoIterator` | +//! | `:#field` / `:#(expr)` | a runtime keyword — the value becomes the atom `:` | +//! +//! `#` also works in head position, so the head can be a runtime value: +//! `expr!((#head 5))`. `ToSexp` covers `Sexp` (identity), `Expr` (structural), +//! `&str`/`String` (atom), and `i64`; build a `Sexp` fragment with `sexp!` to +//! `#`-splice it into a typed quasiquote later. +//! +//! ```ignore +//! let a = 2; +//! let e = expr!((Add (Num #a) ?x))?; // (Add (Num 2) ?x) +//! let fields = vec!["?a", "?b"]; +//! let prog = egglog!(p, (rule ((= l (#kind #..fields))) ((union l r))))?; +//! ``` + +use proc_macro::TokenStream; +use proc_macro2::{Delimiter, TokenStream as TokenStream2, TokenTree}; +use quote::quote; +use std::iter::Peekable; + +/// Parse one egglog expression: `expr!([parser,] )` → `Result`. +/// +/// ```ignore +/// let x = expr!((+ ?a 1))?; // default parser +/// let y = expr!(my_parser, (Mul :shape ?s ...))?; +/// ``` +/// See the module-level docs for the parser argument and the `#` / `#..` / `:#` splices. +#[proc_macro] +pub fn expr(input: TokenStream) -> TokenStream { + build(input, Method::Expr) +} + +/// Parse one egglog fact (a query atom): `fact!([parser,] )` → `Result`. +#[proc_macro] +pub fn fact(input: TokenStream) -> TokenStream { + build(input, Method::Fact) +} + +/// Parse a sequence of facts: `facts!([parser,] *)` → `Result`. +#[proc_macro] +pub fn facts(input: TokenStream) -> TokenStream { + build(input, Method::Facts) +} + +/// Parse one egglog action: `action!([parser,] )` → `Result, ParseError>`. +#[proc_macro] +pub fn action(input: TokenStream) -> TokenStream { + build(input, Method::Action) +} + +/// Parse a sequence of actions: `actions!([parser,] *)` → `Result`. +#[proc_macro] +pub fn actions(input: TokenStream) -> TokenStream { + build(input, Method::Actions) +} + +/// Parse one egglog command: `command!([parser,] )` → `Result, ParseError>`. +/// +/// Returns a `Vec` because a single surface command may desugar into several. +#[proc_macro] +pub fn command(input: TokenStream) -> TokenStream { + build(input, Method::Command) +} + +/// Parse a whole program: `egglog!([parser,] *)` → `Result, ParseError>`. +/// +/// The flagship macro — a sequence of commands, ready to run with +/// `egraph.run_program(egglog!(..)?)`. +/// +/// ```ignore +/// egraph.run_program(egglog!( +/// (datatype Math (Num i64) (Add Math Math)) +/// (rule ((= e (Add (Num a) (Num b)))) ((union e (Num (+ a b))))) +/// )?)?; +/// ``` +/// See the module-level docs for the parser argument and the `#` / `#..` / `:#` splices. +#[proc_macro] +pub fn egglog(input: TokenStream) -> TokenStream { + build(input, Method::Program) +} + +/// Build one rule command: `rule!([parser,] () ())` → `Result, ParseError>`. +/// +/// Sugar for the `(rule () ())` command — the two groups are +/// the body (facts) and head (actions). +#[proc_macro] +pub fn rule(input: TokenStream) -> TokenStream { + build(input, Method::Rule) +} + +/// Build an *un-parsed* s-expression: `sexp!()` → `Sexp` (no parser +/// argument). Supports `#` / `#..` / `:#` splices, and is meant to be +/// `#`-spliced into a typed quasiquote later (a `Sexp` splices as identity). +/// +/// ```ignore +/// let kind = "MyOp"; +/// let fields = vec!["?a", "?b"]; +/// let frag = sexp!((#kind #..fields)); // Sexp `(MyOp ?a ?b)` +/// let e = expr!((wrap #frag 7))?; // Expr `(wrap (MyOp ?a ?b) 7)` +/// ``` +#[proc_macro] +pub fn sexp(input: TokenStream) -> TokenStream { + build(input, Method::Sexp) +} + +/// Build a sequence of un-parsed s-expressions: `sexps!(*)` → `Vec`. +/// +/// Like [`sexp`] but for many forms at once — handy as the operand of a `#..` +/// spread. +#[proc_macro] +pub fn sexps(input: TokenStream) -> TokenStream { + build(input, Method::Sexps) +} + +#[derive(Clone, Copy)] +enum Method { + Expr, + Fact, + Command, + Action, + Facts, + Actions, + Program, + Rule, + /// Build a `Sexp` (or `Vec`) without parsing — for splicing. + Sexp, + Sexps, +} + +fn build(input: TokenStream, method: Method) -> TokenStream { + // `sexp!`/`sexps!` build a `Sexp` without parsing, so they take no parser + // and just return the assembled value(s). + if let Method::Sexp | Method::Sexps = method { + let items = build_items(&sexp_seq(input.into())); + let out = match method { + Method::Sexp => quote! {{ + let __span = ::egglog::span!(); + let __sexps: ::std::vec::Vec<::egglog::ast::Sexp> = #items; + assert_eq!(__sexps.len(), 1, "sexp! expects exactly one form (use sexps! for many)"); + __sexps.into_iter().next().unwrap() + }}, + _ => quote! {{ + let __span = ::egglog::span!(); + let __sexps: ::std::vec::Vec<::egglog::ast::Sexp> = #items; + __sexps + }}, + }; + return out.into(); + } + + let (parser_opt, body) = split_parser(input.into()); + // With an explicit parser (which may have registered macros — named args, + // `for`, …) use it; otherwise a fresh default parser handles built-in syntax. + let parser_decl = match parser_opt { + Some(p) => quote!(let __parser = &mut (#p);), + None => quote!( + #[allow(unused_mut)] + let mut __parser = ::egglog::ast::Parser::default(); + ), + }; + let items = build_items(&sexp_seq(body)); + + // Single-form quasiquotes parse `__sexps[0]`; the plural ones map over all. + let single = matches!( + method, + Method::Expr | Method::Fact | Method::Command | Method::Action + ); + let result = match method { + Method::Expr => quote!(__parser.parse_expr(&__sexps[0])), + Method::Fact => quote!(__parser.parse_fact(&__sexps[0])), + Method::Command => quote!(__parser.parse_command(&__sexps[0])), + Method::Action => quote!(__parser.parse_action(&__sexps[0])), + Method::Program => quote! { + __sexps + .iter() + .map(|__s| __parser.parse_command(__s)) + .collect::<::std::result::Result<::std::vec::Vec<::std::vec::Vec<_>>, _>>() + .map(|__vs| __vs.into_iter().flatten().collect::<::std::vec::Vec<_>>()) + }, + Method::Rule => quote! {{ + assert_eq!(__sexps.len(), 2, "rule! expects `() ()`"); + let __r = ::egglog::ast::Sexp::List( + ::std::vec![ + ::egglog::ast::Sexp::Atom("rule".to_string(), __span.clone()), + __sexps[0].clone(), + __sexps[1].clone(), + ], + __span.clone(), + ); + __parser.parse_command(&__r) + }}, + Method::Facts => quote! { + __sexps + .iter() + .map(|__s| __parser.parse_fact(__s)) + .collect::<::std::result::Result<::std::vec::Vec<_>, _>>() + .map(::egglog::ast::Facts) + }, + Method::Actions => quote! { + __sexps + .iter() + .map(|__s| __parser.parse_action(__s)) + .collect::<::std::result::Result<::std::vec::Vec<::std::vec::Vec<_>>, _>>() + .map(|__vs| ::egglog::ast::GenericActions(__vs.into_iter().flatten().collect())) + }, + Method::Sexp | Method::Sexps => unreachable!("handled before parsing"), + }; + let check = if single { + quote!(assert_eq!(__sexps.len(), 1, "this egglog quasiquote expects exactly one form");) + } else { + quote!() + }; + quote! {{ + let __span = ::egglog::span!(); + let __sexps: ::std::vec::Vec<::egglog::ast::Sexp> = #items; + #check + #parser_decl + #result + }} + .into() +} + +/// Split off an optional `,` prefix. egglog has no top-level commas, so +/// a depth-0 comma unambiguously separates a caller-supplied parser from the +/// egglog body; with no such comma the whole input is the body (default parser). +fn split_parser(ts: TokenStream2) -> (Option, TokenStream2) { + let has_comma = ts + .clone() + .into_iter() + .any(|tt| matches!(&tt, TokenTree::Punct(p) if p.as_char() == ',')); + if !has_comma { + return (None, ts); + } + let mut parser = TokenStream2::new(); + let mut body = TokenStream2::new(); + let mut seen = false; + for tt in ts { + if !seen { + if let TokenTree::Punct(ref p) = tt + && p.as_char() == ',' + { + seen = true; + continue; + } + parser.extend(std::iter::once(tt)); + } else { + body.extend(std::iter::once(tt)); + } + } + (Some(parser), body) +} + +/// A child of a list being built: either one `Sexp` expression, or a spread +/// (`#..xs`) that extends the enclosing list with every element of `xs`. +enum Child { + Single(TokenStream2), + Spread(TokenStream2), +} + +/// Emit an expression that builds a `Vec` from a child sequence: `push` +/// for single elements, `extend` for `#..` spreads. +fn build_items(children: &[Child]) -> TokenStream2 { + let stmts = children.iter().map(|c| match c { + Child::Single(e) => quote!(__items.push(#e);), + Child::Spread(e) => quote! { + __items.extend( + ::std::iter::IntoIterator::into_iter(#e) + .map(|__e| ::egglog::ast::ToSexp::to_sexp(__e, __span.clone())), + ); + }, + }); + quote! {{ + let mut __items: ::std::vec::Vec<::egglog::ast::Sexp> = ::std::vec::Vec::new(); + #(#stmts)* + __items + }} +} + +/// Unwrap a parenthesized splice target — `#(expr)`, `#..(expr)`, `:#(expr)` — +/// to its inner tokens, so the generated `to_sexp(expr, ..)` carries no +/// redundant parentheses (which would trip the `unused_parens` lint under +/// `-D warnings`). A bare `#ident` target passes through unchanged. +fn splice_target(tt: TokenTree) -> TokenStream2 { + if let TokenTree::Group(g) = &tt + && g.delimiter() == Delimiter::Parenthesis + { + return g.stream(); + } + TokenStream2::from(tt) +} + +/// Turn a token stream into a sequence of list children (each builds one `Sexp`, +/// or splices many via `#..`). +fn sexp_seq(ts: TokenStream2) -> Vec { + let mut out = Vec::new(); + let mut it = ts.into_iter().peekable(); + while let Some(tt) = it.next() { + match tt { + // `:#field` / `:#(rust expr)` — splice a runtime keyword. A `:` + // directly followed by `#value` yields the atom `:` (e.g. a + // constructor field name), so named-arg patterns can carry runtime + // field names: `(#kind :#field ?v ...)`. Must precede the atom_run + // fallback, which would otherwise glue `:` `#` into one dead atom. + TokenTree::Punct(ref colon) + if colon.as_char() == ':' + && matches!( + it.peek(), + Some(TokenTree::Punct(h)) + if h.as_char() == '#' && h.span().start() == colon.span().end() + ) => + { + it.next(); // consume the `#` + match it.next() { + Some(target) => out.push(Child::Single({ + let target = splice_target(target); + quote! { + ::egglog::ast::keyword_to_sexp(#target, __span.clone()) + } + })), + None => out.push(Child::Single(quote!(compile_error!( + "`:#` must be followed by a keyword value" + )))), + } + } + // `#..xs` — unquote-splicing: extend the enclosing list with each + // element of `xs` (an `IntoIterator`), like Racket's + // `,@`. `#x` — splice a single value. The token(s) after are a Rust + // expression, not egglog. + TokenTree::Punct(ref p) if p.as_char() == '#' => { + // `..` is two adjacent `.` puncts; look ahead without consuming. + let is_spread = { + let mut ahead = it.clone(); + matches!(ahead.next(), Some(TokenTree::Punct(a)) if a.as_char() == '.') + && matches!(ahead.next(), Some(TokenTree::Punct(b)) if b.as_char() == '.') + }; + if is_spread { + it.next(); + it.next(); // consume the two dots of `..` + match it.next() { + Some(target) => out.push(Child::Spread({ + let target = splice_target(target); + quote!(#target) + })), + None => out.push(Child::Single(quote!(compile_error!( + "`#..` must be followed by a value to splice" + )))), + } + } else { + match it.next() { + Some(target) => out.push(Child::Single({ + let target = splice_target(target); + quote! { + ::egglog::ast::ToSexp::to_sexp(#target, __span.clone()) + } + })), + None => out.push(Child::Single(quote!(compile_error!( + "`#` must be followed by a value to splice" + )))), + } + } + } + TokenTree::Group(g) => match g.delimiter() { + Delimiter::None => out.extend(sexp_seq(g.stream())), + _ => { + let items = build_items(&sexp_seq(g.stream())); + out.push(Child::Single(quote! { + ::egglog::ast::Sexp::List(#items, __span.clone()) + })); + } + }, + // A double-quoted string is its own literal. + TokenTree::Literal(ref lit) if lit.to_string().starts_with('"') => { + out.push(Child::Single(quote! { + ::egglog::ast::Sexp::Literal( + ::egglog::ast::Literal::String((#lit).to_string()), + __span.clone(), + ) + })); + } + // Anything else (ident, punct, number) begins an egglog atom: greedily + // absorb following tokens that are directly adjacent (no whitespace), + // so `?x`, `:no-merge`, `-1.0`, `>=` become single atoms while + // space-separated tokens like `(+ 6 87)` stay separate. + first => { + let atom = atom_run(first.clone(), &mut it); + out.push(Child::Single( + quote!(::egglog::ast::atom_to_sexp(#atom, __span.clone())), + )); + } + } + } + out +} + +/// The source text of a single token (for atoms): a punct's char, an +/// identifier's name, or a literal's text. +fn tt_str(tt: &TokenTree) -> String { + match tt { + TokenTree::Punct(p) => p.as_char().to_string(), + TokenTree::Ident(i) => i.to_string(), + TokenTree::Literal(l) => l.to_string(), + TokenTree::Group(g) => g.to_string(), + } +} + +/// Reassemble one egglog atom starting at `first` by absorbing every following +/// token that begins exactly where the previous one ended — i.e. with no +/// whitespace between them. This mirrors egglog's tokenizer (an atom is a +/// maximal run of non-space, non-paren characters): `?x`, `:no-merge`, `-1.0`, +/// `>=`, `my-ruleset` all become single atoms, while `(+ 6 87)` stays three +/// tokens because the spaces break the run. +fn atom_run(first: TokenTree, it: &mut Peekable>) -> String { + let mut s = tt_str(&first); + let mut end = first.span().end(); + while let Some(next) = it.peek() { + if matches!(next, TokenTree::Group(_)) { + break; + } + if let TokenTree::Literal(l) = next + && l.to_string().starts_with('"') + { + break; // a string literal is its own token, never glued + } + if next.span().start() != end { + break; // whitespace between tokens ends the atom + } + s.push_str(&tt_str(next)); + end = next.span().end(); + it.next(); + } + s +} diff --git a/src/ast/parse.rs b/src/ast/parse.rs index e73a80734..f5b9b22eb 100644 --- a/src/ast/parse.rs +++ b/src/ast/parse.rs @@ -99,6 +99,45 @@ impl Sexp { } } +impl std::fmt::Display for Sexp { + /// Render an `Sexp` back to egglog source text (the inverse of parsing an + /// atom/list; literals use [`Literal`]'s own `Display`, so it round-trips). + /// + /// Deliberately **iterative** with an explicit work stack rather than + /// recursive: a built term can be a very deep left-nested chain (e.g. + /// `(ICons a (ICons b (ICons c …)))` for a long list), and per-level + /// recursion would risk a stack overflow. The work stack lives on the heap + /// and grows with the term's size instead. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + enum Work<'a> { + Node(&'a Sexp), + Text(&'static str), + } + let mut stack = vec![Work::Node(self)]; + while let Some(work) = stack.pop() { + match work { + Work::Text(s) => f.write_str(s)?, + Work::Node(Sexp::Literal(lit, _)) => write!(f, "{lit}")?, + Work::Node(Sexp::Atom(atom, _)) => f.write_str(atom)?, + Work::Node(Sexp::List(items, _)) => { + f.write_str("(")?; + // Push in reverse emission order (LIFO): the closing paren + // first, then children interleaved with separating spaces, + // so the first child is popped (emitted) first. + stack.push(Work::Text(")")); + for (i, child) in items.iter().enumerate().rev() { + stack.push(Work::Node(child)); + if i != 0 { + stack.push(Work::Text(" ")); + } + } + } + } + } + Ok(()) + } +} + // helper for mapping a function that returns `Result` fn map_fallible( slice: &[Sexp], @@ -111,6 +150,96 @@ fn map_fallible( .collect::>() } +/// Classify an atom's text into a [`Sexp`], matching the lexer used by +/// [`Parser`]: `true`/`false` → bool, integer → int, `NaN`/`inf`/`-inf` and +/// finite floats → float, everything else → an atom. Exposed so token-based +/// builders (the `expr!`/`fact!` quasiquotes) classify atoms identically to +/// parsing the same text. +pub fn atom_to_sexp(s: &str, span: Span) -> Sexp { + if s == "true" { + Sexp::Literal(Literal::Bool(true), span) + } else if s == "false" { + Sexp::Literal(Literal::Bool(false), span) + } else if let Ok(int) = s.parse::() { + Sexp::Literal(Literal::Int(int), span) + } else if s == "NaN" { + Sexp::Literal(Literal::Float(OrderedFloat(f64::NAN)), span) + } else if s == "inf" { + Sexp::Literal(Literal::Float(OrderedFloat(f64::INFINITY)), span) + } else if s == "-inf" { + Sexp::Literal(Literal::Float(OrderedFloat(f64::NEG_INFINITY)), span) + } else if let Ok(float) = s.parse::() { + if float.is_finite() { + Sexp::Literal(Literal::Float(OrderedFloat(float)), span) + } else { + Sexp::Atom(s.to_owned(), span) + } + } else { + Sexp::Atom(s.to_owned(), span) + } +} + +/// Splice a runtime keyword atom: `keyword_to_sexp("dtype", span)` yields the +/// atom `:dtype`. Backs the `:#field` quasiquote form, where the keyword name is +/// a runtime value (e.g. a constructor's field name) rather than literal text. +/// Kept distinct from [`atom_to_sexp`] because a keyword is always an atom — it +/// must never be reclassified as an int/float/bool literal. +pub fn keyword_to_sexp(name: impl std::fmt::Display, span: Span) -> Sexp { + Sexp::Atom(format!(":{name}"), span) +} + +/// Values that can be spliced into any quasiquote (`expr!`, `egglog!`, `sexp!`, +/// …) via `#x` / `#(expr)`, or spread into a list with `#..xs` where each +/// element is `ToSexp`. Implemented for [`Sexp`] (identity — no round-trip), a +/// built [`Expr`] (structural), `&str`/`String` (an atom), and `i64` (an int +/// literal). +pub trait ToSexp { + fn to_sexp(self, span: Span) -> Sexp; +} + +impl ToSexp for Sexp { + fn to_sexp(self, _span: Span) -> Sexp { + self + } +} +impl ToSexp for &str { + fn to_sexp(self, span: Span) -> Sexp { + atom_to_sexp(self, span) + } +} +impl ToSexp for String { + fn to_sexp(self, span: Span) -> Sexp { + atom_to_sexp(&self, span) + } +} +impl ToSexp for i64 { + fn to_sexp(self, span: Span) -> Sexp { + Sexp::Literal(Literal::Int(self), span) + } +} +impl ToSexp for Expr { + fn to_sexp(self, span: Span) -> Sexp { + expr_to_sexp(&self, span) + } +} + +/// Structural (not textual) conversion of an already-built [`Expr`] into a +/// [`Sexp`], so it can be spliced into a quasiquote without a display round-trip. +fn expr_to_sexp(e: &Expr, span: Span) -> Sexp { + match e { + Expr::Lit(_, lit) => Sexp::Literal(lit.clone(), span), + Expr::Var(_, v) => Sexp::Atom(v.clone(), span), + Expr::Call(_, f, args) => { + let mut items = Vec::with_capacity(args.len() + 1); + items.push(Sexp::Atom(f.clone(), span.clone())); + for a in args { + items.push(expr_to_sexp(a, span.clone())); + } + Sexp::List(items, span) + } + } +} + /// Parse the `:internal-container-rebuild` annotation value (see /// [`ContainerRebuildSpec`]). The dual of its `Display`. fn parse_container_rebuild_spec(sexp: &Sexp) -> Result { diff --git a/src/lib.rs b/src/lib.rs index aee7bd30b..a19e977e4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -36,12 +36,15 @@ use csv::Writer; pub use egglog_add_primitive::add_literal_prim; pub use egglog_add_primitive::add_primitive; pub use egglog_add_primitive::add_primitive_with_validator; +// Token-tree quasiquotes that route through the parser (`?x`/`:field`/`...` and +// all parser macros work). Defined in the `egglog-quote` proc-macro crate. use egglog_ast::generic_ast::{Change, GenericExpr, Literal}; use egglog_ast::span::Span; use egglog_ast::util::ListDisplay; use egglog_bridge::{ColumnTy, QueryEntry}; use egglog_core_relations as core_relations; use egglog_numeric_id as numeric_id; +pub use egglog_quote::{action, actions, command, egglog, expr, fact, facts, rule, sexp, sexps}; use egglog_reports::{ReportLevel, RunReport}; pub use exec_state::{ Context, Core, Enode, FullState, FunctionEntry, PureState, Read, ReadState, Write, WriteState, @@ -2362,7 +2365,7 @@ impl EGraph { } /// Run a pattern query: bind the variables in `vars` against - /// `facts` and return one [`HashMap`] per match, keyed by variable + /// `facts` and return one `HashMap` per match, keyed by variable /// name. Values stay raw — convert via [`EGraph::value_to_base`]. /// /// With zero vars, returns at most one empty map (so `.len()` is 1 diff --git a/src/prelude.rs b/src/prelude.rs index 30194d0b3..502aa5c21 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -161,7 +161,12 @@ pub use egglog::sort::{BigIntSort, BigRatSort, BoolSort, F64Sort, I64Sort, Strin pub use egglog::{CommandMacro, CommandMacroRegistry}; pub use egglog::{Core, FullState, PureState, Read, ReadState, Write, WriteState}; pub use egglog::{EGraph, span}; -pub use egglog::{action, actions, datatype, expr, fact, facts, sort, vars}; +// NOTE: the `rule!` quasiquote is intentionally *not* re-exported here — it +// would collide with the `rule()` helper fn below (macro vs value). Reach it as +// `egglog::rule!` if needed; `command!`/`egglog!` can express rules too. +pub use egglog::{ + action, actions, command, datatype, egglog, expr, fact, facts, sexp, sexps, sort, vars, +}; /// Trait for types that can be converted to/from Literal for use in validated primitives. /// This enables automatic validator generation for literal primitives. @@ -332,54 +337,11 @@ macro_rules! vars { }; } -#[macro_export] -macro_rules! expr { - ((unquote $unquoted:expr)) => { $unquoted }; - (($func:tt $($arg:tt)*)) => { exprs::call(stringify!($func), vec![$(expr!($arg)),*]) }; - ($value:literal) => { exprs::int($value) }; - ($quoted:tt) => { exprs::var(stringify!($quoted)) }; -} - -#[macro_export] -macro_rules! fact { - ((= $($arg:tt)*)) => { Fact::Eq(span!(), $(expr!($arg)),*) }; - ($a:tt) => { Fact::Fact(expr!($a)) }; -} - -#[macro_export] -macro_rules! facts { - ($($tree:tt)*) => { Facts(vec![$(fact!($tree)),*]) }; -} - -#[macro_export] -macro_rules! action { - ((let $name:ident $value:tt)) => { - Action::Let(span!(), String::from(stringify!($name)), expr!($value)) - }; - ((set ($f:ident $($x:tt)*) $value:tt)) => { - Action::Set(span!(), String::from(stringify!($f)), vec![$(expr!($x)),*], expr!($value)) - }; - ((delete ($f:ident $($x:tt)*))) => { - Action::Change(span!(), Change::Delete, String::from(stringify!($f)), vec![$(expr!($x)),*]) - }; - ((subsume ($f:ident $($x:tt)*))) => { - Action::Change(span!(), Change::Subsume, String::from(stringify!($f)), vec![$(expr!($x)),*]) - }; - ((union $x:tt $y:tt)) => { - Action::Union(span!(), expr!($x), expr!($y)) - }; - ((panic $message:literal)) => { - Action::Panic(span!(), $message.to_owned()) - }; - ($x:tt) => { - Action::Expr(span!(), expr!($x)) - }; -} - -#[macro_export] -macro_rules! actions { - ($($tree:tt)*) => { GenericActions(vec![$(action!($tree)),*]) }; -} +// `expr!`/`fact!`/`facts!`/`action!`/`actions!` — together with `command!`, +// `egglog!`, `rule!`, `sexp!`, and `sexps!` — are token-tree quasiquotes that +// route through the parser, so `?x`, `:field`, `...`, `#`-splices, and every +// registered parser macro work. They live in the `egglog-quote` proc-macro +// crate and are re-exported from the crate root; see there for the definitions. /// Add a rule to the e-graph whose right-hand side is made up of actions. /// ``` @@ -407,7 +369,7 @@ macro_rules! actions { /// // check that `(fib 20)` is not in the e-graph /// let results = egraph.query( /// vars![f: i64], -/// facts![(= (fib (unquote exprs::int(big_number))) f)], +/// facts!((= (fib #big_number) f))?, /// )?; /// /// assert!(results.is_empty()); @@ -419,13 +381,13 @@ macro_rules! actions { /// rule( /// &mut egraph, /// ruleset, -/// facts![ +/// facts!( /// (= f0 (fib x)) /// (= f1 (fib (+ x 1))) -/// ], -/// actions![ +/// )?, +/// actions!( /// (set (fib (+ x 2)) (+ f0 f1)) -/// ], +/// )?, /// )?; /// /// // run that rule 10 times @@ -436,7 +398,7 @@ macro_rules! actions { /// // check that `(fib 20)` is now in the e-graph /// let results = egraph.query( /// vars![f: i64], -/// facts![(= (fib (unquote exprs::int(big_number))) f)], +/// facts!((= (fib #big_number) f))?, /// )?; /// /// let f: Vec = results.iter().map(|m| egraph.value_to_base::(m["f"])).collect(); @@ -542,7 +504,7 @@ where /// // check that `(fib 20)` is not in the e-graph /// let results = egraph.query( /// vars![f: i64], -/// facts![(= (fib (unquote exprs::int(big_number))) f)], +/// facts!((= (fib #big_number) f))?, /// )?; /// /// assert!(results.is_empty()); @@ -556,10 +518,10 @@ where /// "fib_rule", /// ruleset, /// vars![x: i64, f0: i64, f1: i64], -/// facts![ +/// facts!( /// (= f0 (fib x)) /// (= f1 (fib (+ x 1))) -/// ], +/// )?, /// move |mut ctx, values| { /// let [x, f0, f1] = values else { unreachable!() }; /// let x = ctx.value_to_base::(*x); @@ -582,7 +544,7 @@ where /// // check that `(fib 20)` is now in the e-graph /// let results = egraph.query( /// vars![f: i64], -/// facts![(= (fib (unquote exprs::int(big_number))) f)], +/// facts!((= (fib #big_number) f))?, /// )?; /// /// let f: Vec = results.iter().map(|m| egraph.value_to_base::(m["f"])).collect(); @@ -1046,10 +1008,10 @@ mod tests { let results = egraph.query( vars![x: i64, y: i64], - facts![ + facts!( (= (fib x) y) (= y 13) - ], + )?, )?; assert_eq!(results.len(), 1); @@ -1066,10 +1028,7 @@ mod tests { let big_number = 20; // check that `(fib 20)` is not in the e-graph - let results = egraph.query( - vars![f: i64], - facts![(= (fib (unquote exprs::int(big_number))) f)], - )?; + let results = egraph.query(vars![f: i64], facts!((= (fib #big_number) f))?)?; assert!(results.is_empty()); @@ -1080,13 +1039,11 @@ mod tests { rule( &mut egraph, ruleset, - facts![ + facts!( (= f0 (fib x)) (= f1 (fib (+ x 1))) - ], - actions![ - (set (fib (+ x 2)) (+ f0 f1)) - ], + )?, + actions!((set (fib (+ x 2)) (+ f0 f1)))?, )?; // run that rule 10 times @@ -1095,10 +1052,7 @@ mod tests { } // check that `(fib 20)` is now in the e-graph - let results = egraph.query( - vars![f: i64], - facts![(= (fib (unquote exprs::int(big_number))) f)], - )?; + let results = egraph.query(vars![f: i64], facts!((= (fib #big_number) f))?)?; assert_eq!(results.len(), 1); assert_eq!(egraph.value_to_base::(results[0]["f"]), 6765); @@ -1118,13 +1072,13 @@ mod tests { rule( &mut egraph, ruleset, - facts![ + facts!( (fib 5) (fib x) (= f1 (fib (+ x 1))) - (= 3 (unquote exprs::int(1 + 2))) - ], - actions![ + (= 3 #(1 + 2)) + )?, + actions!( (let y (+ x 2)) (set (fib (+ x 2)) (+ f1 f1)) (delete (fib 0)) @@ -1132,7 +1086,7 @@ mod tests { (union (One) (Two (One) (One))) (panic "message") (+ 6 87) - ], + )?, )?; Ok(()) @@ -1145,10 +1099,7 @@ mod tests { let big_number = 20; // check that `(fib 20)` is not in the e-graph - let results = egraph.query( - vars![f: i64], - facts![(= (fib (unquote exprs::int(big_number))) f)], - )?; + let results = egraph.query(vars![f: i64], facts!((= (fib #big_number) f))?)?; assert!(results.is_empty()); @@ -1161,10 +1112,10 @@ mod tests { "demo_rule", ruleset, vars![x: i64, f0: i64, f1: i64], - facts![ + facts!( (= f0 (fib x)) (= f1 (fib (+ x 1))) - ], + )?, move |mut ctx, values| { let [x, f0, f1] = values else { unreachable!() }; let x = ctx.value_to_base::(*x); @@ -1183,10 +1134,7 @@ mod tests { } // check that `(fib 20)` is now in the e-graph - let results = egraph.query( - vars![f: i64], - facts![(= (fib (unquote exprs::int(big_number))) f)], - )?; + let results = egraph.query(vars![f: i64], facts!((= (fib #big_number) f))?)?; assert_eq!(results.len(), 1); assert_eq!(egraph.value_to_base::(results[0]["f"]), 6765); diff --git a/tests/api_const_fold.rs b/tests/api_const_fold.rs index bfeac22ce..e6226fb4c 100644 --- a/tests/api_const_fold.rs +++ b/tests/api_const_fold.rs @@ -47,7 +47,8 @@ fn install_const_fold_rule(eg: &mut EGraph) -> Result<(), Error> { (= sum (Add lhs rhs)) (= lhs (Num a)) (= rhs (Num b)) - ], + ] + .unwrap(), move |mut ctx, vals| { let [sum, _lhs, _rhs, a, b] = vals else { unreachable!() diff --git a/tests/api_proofs.rs b/tests/api_proofs.rs index c982f4ecb..bf8cbaa5e 100644 --- a/tests/api_proofs.rs +++ b/tests/api_proofs.rs @@ -19,7 +19,7 @@ fn rust_rule_with_proofs_enabled_errors() { "test_rule", "r", vars![x: i64], - facts![(= y (f x))], + facts![(= y (f x))].unwrap(), |_, _| Some(()), ); @@ -42,7 +42,7 @@ fn rust_rule_full_with_proofs_enabled_errors() { "test_rule", "r", vars![x: i64], - facts![(= y (f x))], + facts![(= y (f x))].unwrap(), |_, _| Some(()), ); @@ -77,7 +77,7 @@ fn query_with_proofs_enabled_errors_with_query_api_name() { eg.parse_and_run_program(None, "(function f (i64) i64 :merge new)") .unwrap(); - let result = eg.query(vars![x: i64], facts![(= y (f x))]); + let result = eg.query(vars![x: i64], facts![(= y (f x))].unwrap()); let err = result.expect_err("EGraph::query should fail under proofs"); assert!( diff --git a/tests/api_query.rs b/tests/api_query.rs index 5af5a5da7..1dafd6bbe 100644 --- a/tests/api_query.rs +++ b/tests/api_query.rs @@ -49,7 +49,7 @@ fn function_entries_on_constructor_errors() -> Result<(), Error> { Ok(()) } -/// `egraph.query(vars![x: i64], facts![(R x)])` matches and binds `x` +/// `egraph.query(vars![x: i64], facts![(R x)].unwrap())` matches and binds `x` /// for every row in the relation. Each match is a /// `HashMap` keyed by variable name. #[test] @@ -66,7 +66,7 @@ fn query_pattern_relation_one_var() -> Result<(), Error> { )?; let mut results: Vec = egraph - .query(vars![x: i64], facts![(R x)])? + .query(vars![x: i64], facts![(R x)].unwrap())? .into_iter() .map(|m| egraph.value_to_base::(m["x"])) .collect(); @@ -75,7 +75,7 @@ fn query_pattern_relation_one_var() -> Result<(), Error> { Ok(()) } -/// `egraph.query(vars![], facts![(R 1 2)])` — zero-var case. +/// `egraph.query(vars![], facts![(R 1 2)].unwrap())` — zero-var case. /// /// With zero `vars`, every match still produces a `HashMap` (which /// will be empty since there are no variables to bind), so `.len()` @@ -91,10 +91,10 @@ fn query_pattern_zero_vars_match() -> Result<(), Error> { ", )?; - let hits = egraph.query(vars![], facts![(R 1 2)])?; + let hits = egraph.query(vars![], facts![(R 1 2)].unwrap())?; assert_eq!(hits.len(), 1); - let misses = egraph.query(vars![], facts![(R 5 5)])?; + let misses = egraph.query(vars![], facts![(R 5 5)].unwrap())?; assert_eq!(misses.len(), 0); Ok(()) @@ -161,7 +161,7 @@ fn constructor_enodes_relation() -> Result<(), Error> { // To get just the inputs, use the pattern-query form. let mut inputs: Vec = egraph - .query(vars![x: i64], facts![(R x)])? + .query(vars![x: i64], facts![(R x)].unwrap())? .into_iter() .map(|m| egraph.value_to_base::(m["x"])) .collect(); @@ -185,7 +185,7 @@ fn query_pattern_two_vars() -> Result<(), Error> { )?; let mut rows: Vec<(i64, i64)> = egraph - .query(vars![x: i64, y: i64], facts![(= (f x) y)])? + .query(vars![x: i64, y: i64], facts![(= (f x) y)].unwrap())? .into_iter() .map(|m| { ( diff --git a/tests/extraction_proof_mode.rs b/tests/extraction_proof_mode.rs index 4ee376985..4b6070dea 100644 --- a/tests/extraction_proof_mode.rs +++ b/tests/extraction_proof_mode.rs @@ -95,8 +95,8 @@ fn test_extraction_same_with_proof_mode_using_rule_macro() { rule( &mut egraph_normal, "my_rules", - facts![(= (Add a b) e)], - actions![(union e (Add b a))], + facts![(= (Add a b) e)].unwrap(), + actions![(union e (Add b a))].unwrap(), ) .unwrap(); @@ -118,8 +118,8 @@ fn test_extraction_same_with_proof_mode_using_rule_macro() { rule( &mut egraph_proofs, "my_rules", - facts![(= (Add a b) e)], - actions![(union e (Add b a))], + facts![(= (Add a b) e)].unwrap(), + actions![(union e (Add b a))].unwrap(), ) .unwrap(); From fda2a26cf5bc2ef7fb207f531d177585b18c6f86 Mon Sep 17 00:00:00 2001 From: oliver Date: Mon, 13 Jul 2026 19:17:16 +0000 Subject: [PATCH 02/10] Docs: crate-root quasiquote guide + fix broken doc links - Add a "Writing egglog inline from Rust" section (with a runnable, tested example) to the crate-root docs (lib.md). - Fix pre-existing broken intra-doc links in proofs::proof_format: the private `RawProof`/`RawProof::Rule` links become code spans, `Fact` points at `crate::ast::Fact`, and the `Propostion` typo is fixed. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib.md | 22 ++++++++++++++++++++++ src/proofs/proof_format.rs | 8 ++++---- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/lib.md b/src/lib.md index b9797ccf5..69796659e 100644 --- a/src/lib.md +++ b/src/lib.md @@ -40,3 +40,25 @@ global name to it (`(let $root ...)`), resolve the global with [`EGraph::extract_value_with_cost_model`] / a custom [`extract::CostModel`] when you want non-default costs. The [`extract`] module has the full extractor API. + +# Writing egglog inline from Rust +The [`expr!`], [`fact!`], [`action!`], [`command!`], [`egglog!`], and [`rule!`] +macros let you write egglog as Rust tokens and get back parsed `Command` / +`Expr` / `Fact` values; splice Rust values in with `#x`, `#..xs`, and +`:#field`, and build un-parsed fragments with [`sexp!`]. See [`egglog!`] for +the syntax (all re-exported from [`prelude`]). + +``` +use egglog::prelude::*; +let mut egraph = EGraph::default(); +egraph.run_program(egglog!( + (datatype Math (Num i64) (Add Math Math)) + (rule ((= e (Add (Num a) (Num b)))) ((union e (Num (+ a b))))) + (let start (Add (Num 1) (Num 2))) + (run 1) + (check (= start (Num 3))) +)?)?; +let n = 2; +assert_eq!(expr!((Num #n))?.to_string(), "(Num 2)"); +# Ok::<(), egglog::Error>(()) +``` diff --git a/src/proofs/proof_format.rs b/src/proofs/proof_format.rs index 9861de45e..b33a14257 100644 --- a/src/proofs/proof_format.rs +++ b/src/proofs/proof_format.rs @@ -147,9 +147,9 @@ pub struct Proof { /// Some justifications are axioms of egglog, like Sym, Trans, and Congr. /// Other justifications are based on user input, like Fiat, Rule, and MergeFn. /// -/// Compared to [`RawProof`], a [`Justification`] is always paired with the [`Proposition`] being proven (in a [`Proof`]). +/// Compared to `RawProof`, a [`Justification`] is always paired with the [`Proposition`] being proven (in a [`Proof`]). /// Additionally, [`Justification::Rule`] includes the explicit substitution mapping variable names to terms, -/// while [`RawProof::Rule`] leaves this implicit. +/// while `RawProof::Rule` leaves this implicit. #[derive(Clone, Debug)] pub enum Justification { /// Equalities added at the top level are justified by fiat. @@ -158,8 +158,8 @@ pub enum Justification { Fiat, /// Proves a grounded equality `t1 = t2` which appears /// in the body of a rule given a substitution given proofs - /// for each premise ([`Fact`]) of the rule. - /// If the [`Propostion`] proven is a term like `t = t`, + /// for each premise ([`Fact`](crate::ast::Fact)) of the rule. + /// If the [`Proposition`] proven is a term like `t = t`, /// t may be a subexpression of the body of the rule under the substitution. /// /// A proof for a premise is an equality t1 = t2 that matches the premise under some substitution. From e5e5346fd2ff0f4ec87244557a6db0b2c18405b7 Mon Sep 17 00:00:00 2001 From: oliver Date: Tue, 14 Jul 2026 03:17:11 +0000 Subject: [PATCH 03/10] =?UTF-8?q?Add=20resolve=5F*!/run=5F*!=20e-graph=20m?= =?UTF-8?q?acro=20variants;=20rename=20facts!=E2=86=92query!?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Give every parsing quasiquote three flavors: - `x!` — parse only (optional parser) -> unresolved AST - `resolve_x!(eg, …)` — typecheck against the e-graph -> Resolved… (no run) - `run_x!(eg, …)` — resolve + execute -> execution result Covers expression / query / action(s) / command / program (egglog) / rule. Returns: `run_egglog!`/`run_command!`/`run_rule!`/`run_action(s)!` -> `Vec`; `run_expr!` -> `(ArcSort, Value)`; `run_query!` -> `Vec>` (matches, with vars derived from the facts so no explicit `vars` needed). `resolve_*` return the matching `Resolved…` AST. Naming: since each variant has a distinct name, no parser-vs-e-graph type dispatch is needed. `run_` returns the *execution result*, not the resolved AST, so `run` and `resolve` stay distinct. Hard-renamed `facts!` -> `query!` (and dropped the singular `fact!`): "running facts" is a query, so `run_query!` returning matches is the natural fit. Supporting e-graph methods (thin wrappers over existing internals): `resolve_commands`, `resolve_expr`, `resolve_facts`, `query_all`. Also derived `Clone` for `Sexp` (needed to build `(rule …)` from spliced parts). Co-Authored-By: Claude Opus 4.8 (1M context) --- benches/rust_api_benchmarking.rs | 10 +- quote-macro/src/lib.rs | 317 ++++++++++++++++++++++++++++--- src/ast/parse.rs | 3 +- src/lib.md | 14 +- src/lib.rs | 85 ++++++++- src/prelude.rs | 183 ++++++++++++++---- tests/api_const_fold.rs | 2 +- tests/api_proofs.rs | 6 +- tests/api_query.rs | 14 +- tests/extraction_proof_mode.rs | 4 +- 10 files changed, 550 insertions(+), 88 deletions(-) diff --git a/benches/rust_api_benchmarking.rs b/benches/rust_api_benchmarking.rs index c533f55fb..63ff3ae0d 100644 --- a/benches/rust_api_benchmarking.rs +++ b/benches/rust_api_benchmarking.rs @@ -43,7 +43,7 @@ fn match_only_rust_rule_setup(case: RustRuleBenchCase) -> RustRuleBenchInput { "rust_rule_bench", ruleset, vars![x: i64], - facts![(R x)].unwrap(), + query![(R x)].unwrap(), |_ctx, _values| Some(()), ) .unwrap(); @@ -136,7 +136,7 @@ fn insert_loop_setup(case: RustRuleInsertLoopBenchCase) -> RustRuleBenchInput { "rust_rule_insert_loop", ruleset, vars![x: i64], - facts![(R x)].unwrap(), + query![(R x)].unwrap(), // insert f(x) = x + 1, f(x+1) = x + 2, ..., f(x+n_ops-1) = x + n_ops in one rule run move |mut ctx, _values| { for i in 0..case.n_ops { @@ -185,7 +185,7 @@ fn tableaction_hot_path_setup(case: RustRuleTableActionBenchCase) -> RustRuleBen "rust_rule_tableaction_hot_path_fill", fill_ruleset, vars![x: i64], - facts![(R x)].unwrap(), + query![(R x)].unwrap(), move |mut ctx, values| { let [x] = values else { unreachable!() }; let x = ctx.value_to_base::(*x); @@ -208,7 +208,7 @@ fn tableaction_hot_path_setup(case: RustRuleTableActionBenchCase) -> RustRuleBen "rust_rule_tableaction_hot_path_read", read_ruleset, vars![x: i64], - facts![(R x)].unwrap(), + query![(R x)].unwrap(), move |ctx, values| { let [x] = values else { unreachable!() }; let _ = ctx.lookup("f", *x).ok().flatten()?; @@ -344,7 +344,7 @@ fn fib_setup() -> RustRuleBenchInput { "fib_rule", ruleset, vars![x: i64, f0: i64, f1: i64], - facts![ + query![ (= f0 (fib x)) (= f1 (fib (+ x 1))) ] diff --git a/quote-macro/src/lib.rs b/quote-macro/src/lib.rs index 0790b54f1..b57b61151 100644 --- a/quote-macro/src/lib.rs +++ b/quote-macro/src/lib.rs @@ -6,24 +6,30 @@ //! literals, dashed atoms (`:no-merge`), and operators all work. They are //! re-exported from `egglog` and `egglog::prelude`. //! -//! # Macros +//! # The three variants //! -//! Each parses one grammar category and returns a `Result` (except `sexp!` / -//! `sexps!`, which don't parse): +//! Every category comes in three flavors. The **parse** macro takes an +//! *optional* leading `parser,` (a fresh default `Parser` if omitted) and +//! returns unresolved AST. The **`resolve_*`** and **`run_*`** macros each take +//! an e-graph as a required first argument: `resolve_*` typechecks the body +//! against the e-graph (no execution), `run_*` executes it. //! -//! | Macro | Produces | -//! |---|---| -//! | `expr!` | one `Expr` | -//! | `fact!` / `facts!` | one `Fact` / a `Facts` | -//! | `action!` / `actions!` | a `Vec` / an `Actions` | -//! | `command!` | a `Vec` (one command) | -//! | `egglog!` | a `Vec` (a whole program — run with `egraph.run_program`) | -//! | `rule!` | a `Vec` (sugar for `(rule () ())`) | -//! | `sexp!` / `sexps!` | a raw `Sexp` / `Vec`, un-parsed (for splicing) | +//! | category | parse (`[parser,] …`) | `resolve_…!(eg, …)` | `run_…!(eg, …)` | +//! |---|---|---|---| +//! | expression | `expr!` → `Expr` | `resolve_expr!` → `ResolvedExpr` | `run_expr!` → `(ArcSort, Value)` | +//! | query | `query!` → `Facts` | `resolve_query!` → `Vec` | `run_query!` → `Vec>` (matches) | +//! | action(s) | `action!` / `actions!` → `Vec` / `Actions` | `resolve_action(s)!` → `Vec` | `run_action(s)!` → `Vec` | +//! | command | `command!` → `Vec` | `resolve_command!` → `Vec` | `run_command!` → `Vec` | +//! | program | `egglog!` → `Vec` | `resolve_egglog!` → `Vec` | `run_egglog!` → `Vec` | +//! | rule | `rule!` → `Vec` | `resolve_rule!` → `Vec` | `run_rule!` → `Vec` | +//! +//! Parse macros return `Result<_, ParseError>`; the `resolve_*` / `run_*` +//! macros return `Result<_, egglog::Error>`. `run_query!` derives the query +//! variables (and their sorts) from the facts, so it needs no explicit `vars`. //! -//! A leading `parser,` is optional: with it, parsing uses your `Parser` (and -//! its registered macros/sorts); without it, a fresh default `Parser`. -//! (`sexp!` / `sexps!` never take one.) +//! `sexp!` / `sexps!` are separate: they build a raw `Sexp` / `Vec` +//! *without* parsing (no parser, no e-graph) — for `#`-splicing into another +//! quasiquote. //! //! # Splices //! @@ -62,18 +68,43 @@ pub fn expr(input: TokenStream) -> TokenStream { build(input, Method::Expr) } -/// Parse one egglog fact (a query atom): `fact!([parser,] )` → `Result`. +/// Resolve one expression against an e-graph: `resolve_expr!(egraph, )` → +/// `Result` (typecheck, free names = globals, no eval). +#[proc_macro] +pub fn resolve_expr(input: TokenStream) -> TokenStream { + build(input, Method::ResolveExpr) +} + +/// Evaluate one expression against an e-graph: `run_expr!(egraph, )` → +/// `Result<(ArcSort, Value), Error>` (via `eval_expr`). #[proc_macro] -pub fn fact(input: TokenStream) -> TokenStream { - build(input, Method::Fact) +pub fn run_expr(input: TokenStream) -> TokenStream { + build(input, Method::RunExpr) } -/// Parse a sequence of facts: `facts!([parser,] *)` → `Result`. +/// Parse a query — a sequence of facts (query atoms): +/// `query!([parser,] *)` → `Result`. #[proc_macro] -pub fn facts(input: TokenStream) -> TokenStream { +pub fn query(input: TokenStream) -> TokenStream { build(input, Method::Facts) } +/// Resolve a query against an e-graph: `resolve_query!(egraph, *)` → +/// `Result, Error>` (typecheck the query body, no run). +#[proc_macro] +pub fn resolve_query(input: TokenStream) -> TokenStream { + build(input, Method::ResolveQuery) +} + +/// Run a query against an e-graph: `run_query!(egraph, *)` → +/// `Result>, Error>` — one map (var name → value) +/// per match. Query variables (and their sorts) are derived from the facts, so +/// no explicit `vars` list is needed. +#[proc_macro] +pub fn run_query(input: TokenStream) -> TokenStream { + build(input, Method::RunQuery) +} + /// Parse one egglog action: `action!([parser,] )` → `Result, ParseError>`. #[proc_macro] pub fn action(input: TokenStream) -> TokenStream { @@ -86,6 +117,34 @@ pub fn actions(input: TokenStream) -> TokenStream { build(input, Method::Actions) } +/// Resolve one action against an e-graph, as a top-level action command: +/// `resolve_action!(egraph, )` → `Result, Error>`. +#[proc_macro] +pub fn resolve_action(input: TokenStream) -> TokenStream { + build(input, Method::ResolveCommand) +} + +/// Run one action against an e-graph, as a top-level action command: +/// `run_action!(egraph, )` → `Result, Error>`. +#[proc_macro] +pub fn run_action(input: TokenStream) -> TokenStream { + build(input, Method::RunCommand) +} + +/// Resolve a sequence of actions against an e-graph, as top-level action +/// commands: `resolve_actions!(egraph, *)` → `Result, Error>`. +#[proc_macro] +pub fn resolve_actions(input: TokenStream) -> TokenStream { + build(input, Method::ResolveProgram) +} + +/// Run a sequence of actions against an e-graph, as top-level action commands: +/// `run_actions!(egraph, *)` → `Result, Error>`. +#[proc_macro] +pub fn run_actions(input: TokenStream) -> TokenStream { + build(input, Method::RunProgram) +} + /// Parse one egglog command: `command!([parser,] )` → `Result, ParseError>`. /// /// Returns a `Vec` because a single surface command may desugar into several. @@ -94,6 +153,20 @@ pub fn command(input: TokenStream) -> TokenStream { build(input, Method::Command) } +/// Resolve one command against an e-graph: `resolve_command!(egraph, )` +/// → `Result, Error>` (typecheck, no execution). +#[proc_macro] +pub fn resolve_command(input: TokenStream) -> TokenStream { + build(input, Method::ResolveCommand) +} + +/// Run one command against an e-graph: `run_command!(egraph, )` → +/// `Result, Error>`. +#[proc_macro] +pub fn run_command(input: TokenStream) -> TokenStream { + build(input, Method::RunCommand) +} + /// Parse a whole program: `egglog!([parser,] *)` → `Result, ParseError>`. /// /// The flagship macro — a sequence of commands, ready to run with @@ -111,6 +184,31 @@ pub fn egglog(input: TokenStream) -> TokenStream { build(input, Method::Program) } +/// Resolve a program against an e-graph: `resolve_egglog!(egraph, )` +/// → `Result, Error>`. Parses with the e-graph's parser +/// and typechecks (so you get type errors here), but **does not run** anything. +/// +/// ```ignore +/// let resolved = resolve_egglog!(egraph, (rule ((= x (Foo)))((delete x))))?; +/// ``` +#[proc_macro] +pub fn resolve_egglog(input: TokenStream) -> TokenStream { + build(input, Method::ResolveProgram) +} + +/// Run a program against an e-graph: `run_egglog!(egraph, )` → +/// `Result, Error>`. Parses with the e-graph's parser, +/// resolves, and executes — i.e. `egraph.run_program(egglog!(..)?)` with the +/// parse using the e-graph's own parser. +/// +/// ```ignore +/// run_egglog!(egraph, (datatype Math (Num i64) (Add Math Math)))?; +/// ``` +#[proc_macro] +pub fn run_egglog(input: TokenStream) -> TokenStream { + build(input, Method::RunProgram) +} + /// Build one rule command: `rule!([parser,] () ())` → `Result, ParseError>`. /// /// Sugar for the `(rule () ())` command — the two groups are @@ -120,6 +218,20 @@ pub fn rule(input: TokenStream) -> TokenStream { build(input, Method::Rule) } +/// Resolve a rule against an e-graph: `resolve_rule!(egraph, () ())` +/// → `Result, Error>` (typecheck, no execution). +#[proc_macro] +pub fn resolve_rule(input: TokenStream) -> TokenStream { + build(input, Method::ResolveRule) +} + +/// Run a rule against an e-graph: `run_rule!(egraph, () ())` → +/// `Result, Error>`. +#[proc_macro] +pub fn run_rule(input: TokenStream) -> TokenStream { + build(input, Method::RunRule) +} + /// Build an *un-parsed* s-expression: `sexp!()` → `Sexp` (no parser /// argument). Supports `#` / `#..` / `:#` splices, and is meant to be /// `#`-spliced into a typed quasiquote later (a `Sexp` splices as identity). @@ -147,7 +259,6 @@ pub fn sexps(input: TokenStream) -> TokenStream { #[derive(Clone, Copy)] enum Method { Expr, - Fact, Command, Action, Facts, @@ -157,6 +268,23 @@ enum Method { /// Build a `Sexp` (or `Vec`) without parsing — for splicing. Sexp, Sexps, + /// Egraph-context command-family variants: parse the body with the + /// e-graph's own parser into a `Vec`, then resolve (→ + /// `Vec`) or run (→ `Vec`) against it. + ResolveProgram, + RunProgram, + ResolveCommand, + RunCommand, + ResolveRule, + RunRule, + /// Egraph-context expression variants: parse one expression, then resolve + /// (→ `ResolvedExpr`, no eval) or run (→ `(ArcSort, Value)` via `eval_expr`). + ResolveExpr, + RunExpr, + /// Egraph-context query variants: parse the body as facts, then resolve (→ + /// `Vec`) or run (→ query matches) against the e-graph. + ResolveQuery, + RunQuery, } fn build(input: TokenStream, method: Method) -> TokenStream { @@ -180,6 +308,132 @@ fn build(input: TokenStream, method: Method) -> TokenStream { return out.into(); } + // Command-family egraph-context variants (`resolve_egglog!`/`run_egglog!`, + // `resolve_command!`/`run_command!`, `resolve_rule!`/`run_rule!`): a + // REQUIRED e-graph, then the egglog body. Parse the body with the e-graph's + // own parser (so its registered sorts/macros are in scope) into a + // `Vec`, then resolve (→ `Vec`, no execution) or + // run (→ `Vec`) against it. + if let Method::ResolveProgram + | Method::RunProgram + | Method::ResolveCommand + | Method::RunCommand + | Method::ResolveRule + | Method::RunRule = method + { + let (ctx, body) = split_parser(input.into()); + let Some(ctx) = ctx else { + return quote!(compile_error!( + "this macro needs an e-graph first, e.g. `run_egglog!(egraph, ..)` / `resolve_egglog!(egraph, ..)`" + )) + .into(); + }; + let items = build_items(&sexp_seq(body)); + // How the parsed forms become a `Vec` (mirrors the parse-only + // variants): a whole program, a single command, or a `(rule …)` wrap. + let cmds_build = match method { + Method::ResolveCommand | Method::RunCommand => quote! { + assert_eq!(__sexps.len(), 1, "this macro expects exactly one command"); + __cmds.extend(__eg.parser.parse_command(&__sexps[0])?); + }, + Method::ResolveRule | Method::RunRule => quote! { + assert_eq!(__sexps.len(), 2, "rule macros expect `() ()`"); + let __r = ::egglog::ast::Sexp::List( + ::std::vec![ + ::egglog::ast::Sexp::Atom("rule".to_string(), __span.clone()), + __sexps[0].clone(), + __sexps[1].clone(), + ], + __span.clone(), + ); + __cmds.extend(__eg.parser.parse_command(&__r)?); + }, + // Program (`egglog!`): every form is a command. + _ => quote! { + for __s in &__sexps { + __cmds.extend(__eg.parser.parse_command(__s)?); + } + }, + }; + let finish = match method { + Method::ResolveProgram | Method::ResolveCommand | Method::ResolveRule => { + quote!(__eg.resolve_commands(__cmds)) + } + _ => quote!(__eg.run_program(__cmds)), + }; + return quote! {{ + let __eg = &mut (#ctx); + let __span = ::egglog::span!(); + let __sexps: ::std::vec::Vec<::egglog::ast::Sexp> = #items; + (|| -> ::std::result::Result<_, ::egglog::Error> { + let mut __cmds: ::std::vec::Vec<::egglog::ast::Command> = ::std::vec::Vec::new(); + #cmds_build + #finish + })() + }} + .into(); + } + + // Egraph-context expression variants (`resolve_expr!` / `run_expr!`): parse + // one expression with the e-graph's parser, then resolve it to a + // `ResolvedExpr` (no eval) or evaluate it to `(ArcSort, Value)`. + if let Method::ResolveExpr | Method::RunExpr = method { + let (ctx, body) = split_parser(input.into()); + let Some(ctx) = ctx else { + return quote!(compile_error!( + "this macro needs an e-graph first, e.g. `run_expr!(egraph, ..)` / `resolve_expr!(egraph, ..)`" + )) + .into(); + }; + let items = build_items(&sexp_seq(body)); + let op = match method { + Method::ResolveExpr => quote!(__eg.resolve_expr(&__e)), + _ => quote!(__eg.eval_expr(&__e)), + }; + return quote! {{ + let __eg = &mut (#ctx); + let __span = ::egglog::span!(); + let __sexps: ::std::vec::Vec<::egglog::ast::Sexp> = #items; + (|| -> ::std::result::Result<_, ::egglog::Error> { + assert_eq!(__sexps.len(), 1, "expr macros expect exactly one expression"); + let __e = __eg.parser.parse_expr(&__sexps[0])?; + #op + })() + }} + .into(); + } + + // Egraph-context query variants (`resolve_query!` / `run_query!`): parse the + // body as facts with the e-graph's parser, then resolve them to + // `Vec` (no run) or run the query and return the matches. + if let Method::ResolveQuery | Method::RunQuery = method { + let (ctx, body) = split_parser(input.into()); + let Some(ctx) = ctx else { + return quote!(compile_error!( + "this macro needs an e-graph first, e.g. `run_query!(egraph, ..)` / `resolve_query!(egraph, ..)`" + )) + .into(); + }; + let items = build_items(&sexp_seq(body)); + let op = match method { + Method::ResolveQuery => quote!(__eg.resolve_facts(&__facts)), + _ => quote!(__eg.query_all(::egglog::ast::Facts(__facts))), + }; + return quote! {{ + let __eg = &mut (#ctx); + let __span = ::egglog::span!(); + let __sexps: ::std::vec::Vec<::egglog::ast::Sexp> = #items; + (|| -> ::std::result::Result<_, ::egglog::Error> { + let mut __facts: ::std::vec::Vec<::egglog::ast::Fact> = ::std::vec::Vec::new(); + for __s in &__sexps { + __facts.push(__eg.parser.parse_fact(__s)?); + } + #op + })() + }} + .into(); + } + let (parser_opt, body) = split_parser(input.into()); // With an explicit parser (which may have registered macros — named args, // `for`, …) use it; otherwise a fresh default parser handles built-in syntax. @@ -193,13 +447,9 @@ fn build(input: TokenStream, method: Method) -> TokenStream { let items = build_items(&sexp_seq(body)); // Single-form quasiquotes parse `__sexps[0]`; the plural ones map over all. - let single = matches!( - method, - Method::Expr | Method::Fact | Method::Command | Method::Action - ); + let single = matches!(method, Method::Expr | Method::Command | Method::Action); let result = match method { Method::Expr => quote!(__parser.parse_expr(&__sexps[0])), - Method::Fact => quote!(__parser.parse_fact(&__sexps[0])), Method::Command => quote!(__parser.parse_command(&__sexps[0])), Method::Action => quote!(__parser.parse_action(&__sexps[0])), Method::Program => quote! { @@ -235,7 +485,20 @@ fn build(input: TokenStream, method: Method) -> TokenStream { .collect::<::std::result::Result<::std::vec::Vec<::std::vec::Vec<_>>, _>>() .map(|__vs| ::egglog::ast::GenericActions(__vs.into_iter().flatten().collect())) }, - Method::Sexp | Method::Sexps => unreachable!("handled before parsing"), + Method::Sexp + | Method::Sexps + | Method::ResolveProgram + | Method::RunProgram + | Method::ResolveCommand + | Method::RunCommand + | Method::ResolveRule + | Method::RunRule + | Method::ResolveExpr + | Method::RunExpr + | Method::ResolveQuery + | Method::RunQuery => { + unreachable!("handled before parsing") + } }; let check = if single { quote!(assert_eq!(__sexps.len(), 1, "this egglog quasiquote expects exactly one form");) diff --git a/src/ast/parse.rs b/src/ast/parse.rs index f5b9b22eb..a55cb9be0 100644 --- a/src/ast/parse.rs +++ b/src/ast/parse.rs @@ -38,6 +38,7 @@ macro_rules! error { }; } +#[derive(Clone)] pub enum Sexp { // Will never contain `Literal::Unit`, as this // will be parsed as an empty `Sexp::List`. @@ -153,7 +154,7 @@ fn map_fallible( /// Classify an atom's text into a [`Sexp`], matching the lexer used by /// [`Parser`]: `true`/`false` → bool, integer → int, `NaN`/`inf`/`-inf` and /// finite floats → float, everything else → an atom. Exposed so token-based -/// builders (the `expr!`/`fact!` quasiquotes) classify atoms identically to +/// builders (the `expr!`/`query!` quasiquotes) classify atoms identically to /// parsing the same text. pub fn atom_to_sexp(s: &str, span: Span) -> Sexp { if s == "true" { diff --git a/src/lib.md b/src/lib.md index 69796659e..8e9397c60 100644 --- a/src/lib.md +++ b/src/lib.md @@ -42,11 +42,15 @@ global name to it (`(let $root ...)`), resolve the global with [`extract`] module has the full extractor API. # Writing egglog inline from Rust -The [`expr!`], [`fact!`], [`action!`], [`command!`], [`egglog!`], and [`rule!`] -macros let you write egglog as Rust tokens and get back parsed `Command` / -`Expr` / `Fact` values; splice Rust values in with `#x`, `#..xs`, and -`:#field`, and build un-parsed fragments with [`sexp!`]. See [`egglog!`] for -the syntax (all re-exported from [`prelude`]). +Write egglog as ordinary Rust tokens with the quasiquote macros — [`expr!`], +[`query!`], [`action!`]/[`actions!`], [`command!`], [`egglog!`], and [`rule!`] +parse to AST (`Expr` / `Facts` / `Command` / …), splicing Rust values in with +`#x`, `#..xs`, and `:#field`; [`sexp!`] builds an un-parsed fragment for +splicing. Each parsing macro also has two e-graph-aware variants: `resolve_*!` +typechecks against an e-graph without running (e.g. [`resolve_egglog!`] → +`Vec`), and `run_*!` runs it (e.g. [`run_egglog!`] → +`Vec`, [`run_query!`] → query matches, [`run_expr!`] → +`(sort, value)`). All are re-exported from [`prelude`]. ``` use egglog::prelude::*; diff --git a/src/lib.rs b/src/lib.rs index a19e977e4..50e461c5d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -38,13 +38,17 @@ pub use egglog_add_primitive::add_primitive; pub use egglog_add_primitive::add_primitive_with_validator; // Token-tree quasiquotes that route through the parser (`?x`/`:field`/`...` and // all parser macros work). Defined in the `egglog-quote` proc-macro crate. -use egglog_ast::generic_ast::{Change, GenericExpr, Literal}; +use egglog_ast::generic_ast::{Change, GenericExpr, GenericFact, Literal}; use egglog_ast::span::Span; use egglog_ast::util::ListDisplay; use egglog_bridge::{ColumnTy, QueryEntry}; use egglog_core_relations as core_relations; use egglog_numeric_id as numeric_id; -pub use egglog_quote::{action, actions, command, egglog, expr, fact, facts, rule, sexp, sexps}; +pub use egglog_quote::{ + action, actions, command, egglog, expr, query, resolve_action, resolve_actions, + resolve_command, resolve_egglog, resolve_expr, resolve_query, resolve_rule, rule, run_action, + run_actions, run_command, run_egglog, run_expr, run_query, run_rule, sexp, sexps, +}; use egglog_reports::{ReportLevel, RunReport}; pub use exec_state::{ Context, Core, Enode, FullState, FunctionEntry, PureState, Read, ReadState, Write, WriteState, @@ -1318,6 +1322,67 @@ impl EGraph { Ok((sort, value)) } + /// Resolve (typecheck) query facts against this e-graph, returning + /// [`ResolvedFact`]s. Backs `resolve_query!`. + pub fn resolve_facts(&mut self, facts: &[ast::Fact]) -> Result, Error> { + // `typecheck_facts` borrows the type env immutably; give it its own + // fresh-name generator (a clone) so there's no aliasing with `self`. + let mut symbol_gen = self.parser.symbol_gen.clone(); + Ok(self.type_info.typecheck_facts(&mut symbol_gen, facts)?) + } + + /// Run a query given only its facts: resolve them to discover the query + /// variables (with their sorts), then return one match per binding as a + /// `var name -> Value` map. Backs `run_query!` — the ergonomic form of + /// [`EGraph::query`] that needs no explicit `vars`. + pub fn query_all( + &mut self, + facts: ast::Facts, + ) -> Result>, Error> { + let resolved = self.resolve_facts(&facts.0)?; + // Collect every variable in the resolved body (with its sort), in + // first-seen order. + fn walk(e: &ast::ResolvedExpr, out: &mut IndexMap) { + match e { + GenericExpr::Var(_, v) => { + out.entry(v.name.clone()).or_insert_with(|| v.sort.clone()); + } + GenericExpr::Call(_, _, args) => args.iter().for_each(|a| walk(a, out)), + GenericExpr::Lit(..) => {} + } + } + let mut vars: IndexMap = IndexMap::default(); + for fact in &resolved { + match fact { + GenericFact::Eq(_, a, b) => { + walk(a, &mut vars); + walk(b, &mut vars); + } + GenericFact::Fact(e) => walk(e, &mut vars), + } + } + let var_refs: Vec<(&str, ArcSort)> = + vars.iter().map(|(n, s)| (n.as_str(), s.clone())).collect(); + self.query(&var_refs, facts) + } + + /// Resolve (typecheck) an expression against this e-graph **without + /// evaluating** it, returning the [`ResolvedExpr`]. Free names are treated + /// as global references (as in [`EGraph::eval_expr`]). Backs `resolve_expr!`. + pub fn resolve_expr(&mut self, expr: &Expr) -> Result { + let span = expr.span(); + let command = Command::Action(Action::Expr(span, expr.clone())); + let resolved = self.resolve_command(command)?; + let resolved_commands = resolved.desugared; + assert_eq!(resolved_commands.len(), 1); + match resolved_commands.into_iter().next().unwrap() { + ResolvedNCommand::CoreAction(ResolvedAction::Expr(_, resolved_expr)) => { + Ok(resolved_expr) + } + _ => unreachable!(), + } + } + /// Typecheck an expression under explicit local bindings, an expected /// output sort, and a primitive call context. /// @@ -2121,6 +2186,22 @@ impl EGraph { Ok(res.outputs) } + /// Resolve (typecheck + desugar) already-parsed [`Command`]s into + /// [`ResolvedCommand`]s **without executing** them — the `Vec` + /// twin of [`EGraph::resolve_program`], backing the `resolve_*!` + /// quasiquotes. + /// + /// Resolving a declaration still registers it in the type environment (so + /// later commands typecheck against it), but no rules or actions run. To + /// execute, use [`EGraph::run_program`] on the unresolved commands. + pub fn resolve_commands( + &mut self, + program: Vec, + ) -> Result, Error> { + let res = self.process_program_internal(program, false)?; + Ok(res.resolved.into_iter().map(|c| c.to_command()).collect()) + } + /// Resolves an egglog program by parsing, typechecking, and desugaring each command. /// Outputs a new egglog program without any syntactic sugar, either user provided ([`CommandMacro`]) or built-in (e.g., `rewrite` commands). /// Also removes globals from the program by replacing with new constructors. diff --git a/src/prelude.rs b/src/prelude.rs index 502aa5c21..cf23b8015 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -117,7 +117,7 @@ //! //! ## Rules //! -//! - [`rule`] — add a rule whose RHS is egglog code. +//! - [`rule()`] — add a rule whose RHS is egglog code. //! - [`rust_rule`] — add a rule whose RHS is a Rust closure //! `Fn(WriteState, &[Value]) -> Option<()>`. Seminaive-safe. //! - [`rust_rule_full`] — same but the closure receives a @@ -165,7 +165,9 @@ pub use egglog::{EGraph, span}; // would collide with the `rule()` helper fn below (macro vs value). Reach it as // `egglog::rule!` if needed; `command!`/`egglog!` can express rules too. pub use egglog::{ - action, actions, command, datatype, egglog, expr, fact, facts, sexp, sexps, sort, vars, + action, actions, command, egglog, expr, query, resolve_action, resolve_actions, + resolve_command, resolve_egglog, resolve_expr, resolve_query, resolve_rule, run_action, + run_actions, run_command, run_egglog, run_expr, run_query, run_rule, sexp, sexps, sort, vars, }; /// Trait for types that can be converted to/from Literal for use in validated primitives. @@ -337,7 +339,7 @@ macro_rules! vars { }; } -// `expr!`/`fact!`/`facts!`/`action!`/`actions!` — together with `command!`, +// `expr!`/`fact!`/`query!`/`action!`/`actions!` — together with `command!`, // `egglog!`, `rule!`, `sexp!`, and `sexps!` — are token-tree quasiquotes that // route through the parser, so `?x`, `:field`, `...`, `#`-splices, and every // registered parser macro work. They live in the `egglog-quote` proc-macro @@ -369,7 +371,7 @@ macro_rules! vars { /// // check that `(fib 20)` is not in the e-graph /// let results = egraph.query( /// vars![f: i64], -/// facts!((= (fib #big_number) f))?, +/// query!((= (fib #big_number) f))?, /// )?; /// /// assert!(results.is_empty()); @@ -381,7 +383,7 @@ macro_rules! vars { /// rule( /// &mut egraph, /// ruleset, -/// facts!( +/// query!( /// (= f0 (fib x)) /// (= f1 (fib (+ x 1))) /// )?, @@ -398,7 +400,7 @@ macro_rules! vars { /// // check that `(fib 20)` is now in the e-graph /// let results = egraph.query( /// vars![f: i64], -/// facts!((= (fib #big_number) f))?, +/// query!((= (fib #big_number) f))?, /// )?; /// /// let f: Vec = results.iter().map(|m| egraph.value_to_base::(m["f"])).collect(); @@ -504,7 +506,7 @@ where /// // check that `(fib 20)` is not in the e-graph /// let results = egraph.query( /// vars![f: i64], -/// facts!((= (fib #big_number) f))?, +/// query!((= (fib #big_number) f))?, /// )?; /// /// assert!(results.is_empty()); @@ -518,7 +520,7 @@ where /// "fib_rule", /// ruleset, /// vars![x: i64, f0: i64, f1: i64], -/// facts!( +/// query!( /// (= f0 (fib x)) /// (= f1 (fib (+ x 1))) /// )?, @@ -544,7 +546,7 @@ where /// // check that `(fib 20)` is now in the e-graph /// let results = egraph.query( /// vars![f: i64], -/// facts!((= (fib #big_number) f))?, +/// query!((= (fib #big_number) f))?, /// )?; /// /// let f: Vec = results.iter().map(|m| egraph.value_to_base::(m["f"])).collect(); @@ -778,23 +780,10 @@ pub fn add_relation( }]) } -/// Adds sorts and constructor tables to the database. -#[macro_export] -macro_rules! datatype { - ($egraph:expr, (datatype $sort:ident $(($name:ident $($args:ident)* $(:cost $cost:expr)?))*)) => { - add_sort($egraph, stringify!($sort))?; - $(add_constructor( - $egraph, - stringify!($name), - Schema { - input: vec![$(stringify!($args).to_owned()),*], - output: stringify!($sort).to_owned(), - }, - [$($cost)*].first().copied(), - false, - )?;)* - }; -} +// (The old `datatype!` macro was removed — it's subsumed by +// `run_egglog!(egraph, (datatype ...))`, which parses, typechecks, and +// registers the datatype the same way. Use `egglog!((datatype ...))` for the +// un-run commands.) /// A "default" implementation of [`Sort`] for simple types /// which just want to put some data in the e-graph. If you @@ -1008,7 +997,7 @@ mod tests { let results = egraph.query( vars![x: i64, y: i64], - facts!( + query!( (= (fib x) y) (= y 13) )?, @@ -1028,7 +1017,7 @@ mod tests { let big_number = 20; // check that `(fib 20)` is not in the e-graph - let results = egraph.query(vars![f: i64], facts!((= (fib #big_number) f))?)?; + let results = egraph.query(vars![f: i64], query!((= (fib #big_number) f))?)?; assert!(results.is_empty()); @@ -1039,7 +1028,7 @@ mod tests { rule( &mut egraph, ruleset, - facts!( + query!( (= f0 (fib x)) (= f1 (fib (+ x 1))) )?, @@ -1052,7 +1041,7 @@ mod tests { } // check that `(fib 20)` is now in the e-graph - let results = egraph.query(vars![f: i64], facts!((= (fib #big_number) f))?)?; + let results = egraph.query(vars![f: i64], query!((= (fib #big_number) f))?)?; assert_eq!(results.len(), 1); assert_eq!(egraph.value_to_base::(results[0]["f"]), 6765); @@ -1064,7 +1053,7 @@ mod tests { fn rust_api_macros() -> Result<(), Error> { let mut egraph = build_test_database()?; - datatype!(&mut egraph, (datatype Expr (One) (Two Expr Expr :cost 10))); + run_egglog!(&mut egraph, (datatype Expr (One) (Two Expr Expr :cost 10)))?; let ruleset = "custom_ruleset"; add_ruleset(&mut egraph, ruleset)?; @@ -1072,7 +1061,7 @@ mod tests { rule( &mut egraph, ruleset, - facts!( + query!( (fib 5) (fib x) (= f1 (fib (+ x 1))) @@ -1099,7 +1088,7 @@ mod tests { let big_number = 20; // check that `(fib 20)` is not in the e-graph - let results = egraph.query(vars![f: i64], facts!((= (fib #big_number) f))?)?; + let results = egraph.query(vars![f: i64], query!((= (fib #big_number) f))?)?; assert!(results.is_empty()); @@ -1112,7 +1101,7 @@ mod tests { "demo_rule", ruleset, vars![x: i64, f0: i64, f1: i64], - facts!( + query!( (= f0 (fib x)) (= f1 (fib (+ x 1))) )?, @@ -1134,11 +1123,135 @@ mod tests { } // check that `(fib 20)` is now in the e-graph - let results = egraph.query(vars![f: i64], facts!((= (fib #big_number) f))?)?; + let results = egraph.query(vars![f: i64], query!((= (fib #big_number) f))?)?; assert_eq!(results.len(), 1); assert_eq!(egraph.value_to_base::(results[0]["f"]), 6765); Ok(()) } + + // The `egglog!` triple: `egglog!` parses (no e-graph), `resolve_egglog!` + // typechecks against the e-graph without running, `run_egglog!` runs. + #[test] + fn rust_api_egglog_triple() -> Result<(), Error> { + let mut egraph = EGraph::default(); + + // parse-only: no e-graph, unresolved commands. + let parsed = egglog!((datatype Math (Num i64) (Add Math Math)))?; + assert_eq!(parsed.len(), 1); + + // run: register the datatype + a constant-fold rule (subsumes the old + // `datatype!` macro). + run_egglog!( + &mut egraph, + (datatype Math (Num i64) (Add Math Math)) + (rule ((= e (Add (Num a) (Num b)))) ((union e (Num (+ a b))))) + )?; + + // resolve: typecheck a command against the e-graph, without running it. + let resolved = + resolve_egglog!(&mut egraph, (rule ((= e (Add a b))) ((union e (Add b a)))))?; + assert_eq!(resolved.len(), 1); + + // run for real: the fold rule fires and `start` becomes `(Num 3)`. + run_egglog!( + &mut egraph, + (let start (Add (Num 1) (Num 2))) + (run 1) + (check (= start (Num 3))) + )?; + + Ok(()) + } + + // `command!` and `rule!` share the command pipeline; check their + // resolve/run variants build and typecheck. + #[test] + fn rust_api_command_and_rule_variants() -> Result<(), Error> { + let mut egraph = EGraph::default(); + run_egglog!(&mut egraph, (datatype Math (Num i64) (Add Math Math)))?; + + // command! family: one command. + let c = resolve_command!(&mut egraph, (rule ((= e (Add a b))) ((union e (Add b a)))))?; + assert_eq!(c.len(), 1); + run_command!(&mut egraph, (rule ((= e (Add a b))) ((union e (Add b a)))))?; + + // rule! family: `() ()`, wrapped into `(rule …)`. + let r = resolve_rule!(&mut egraph, ((= e (Num a))) ((union e (Num a))))?; + assert_eq!(r.len(), 1); + run_rule!(&mut egraph, ((= e (Add (Num 1) (Num 2)))) ((union e (Num 3))))?; + + Ok(()) + } + + // The `expr!` triple: parse (no e-graph), resolve against the e-graph + // (typecheck, no eval), run (eval to `(sort, value)`). + #[test] + fn rust_api_expr_variants() -> Result<(), Error> { + let mut egraph = EGraph::default(); + run_egglog!(&mut egraph, (datatype Math (Num i64) (Add Math Math)))?; + + // parse-only. + assert_eq!( + expr!((Add (Num 1) (Num 2)))?.to_string(), + "(Add (Num 1) (Num 2))" + ); + + // resolve against the e-graph (typecheck), no eval. + let _resolved = resolve_expr!(&mut egraph, (Add (Num 1) (Num 2)))?; + + // run: evaluate to `(sort, value)`. + let (_sort, _val) = run_expr!(&mut egraph, (Add (Num 1) (Num 2)))?; + + Ok(()) + } + + // The `query!` triple (renamed from `facts!`): parse to `Facts`, resolve to + // `ResolvedFact`s, and run — `run_query!` returns matches, deriving the + // query variables (and sorts) from the facts. + #[test] + fn rust_api_query_variants() -> Result<(), Error> { + let mut egraph = EGraph::default(); + run_egglog!( + &mut egraph, + (function fib (i64) i64 :no-merge) + (set (fib 1) 1) + (set (fib 2) 1) + )?; + + // parse-only: a `Facts` value (no e-graph). + let _facts = query!((= (fib x) y))?; + + // resolve against the e-graph -> ResolvedFacts. + let resolved = resolve_query!(&mut egraph, (= (fib x) y))?; + assert!(!resolved.is_empty()); + + // run: matches, auto-deriving the vars `x`/`y` from the facts. + let matches = run_query!(&mut egraph, (= (fib x) y) (= y 1))?; + assert_eq!(matches.len(), 2); // (fib 1)=1 and (fib 2)=1 + for m in &matches { + assert_eq!(egraph.value_to_base::(m["y"]), 1); + } + + Ok(()) + } + + // `action!`/`actions!` egraph variants run the action(s) as top-level + // action commands. + #[test] + fn rust_api_action_variants() -> Result<(), Error> { + let mut egraph = EGraph::default(); + run_egglog!(&mut egraph, (function g (i64) i64 :no-merge))?; + + let _r = resolve_action!(&mut egraph, (set (g 1) 10))?; + run_action!(&mut egraph, (set (g 1) 10))?; + run_actions!(&mut egraph, (set (g 2) 20) (set (g 3) 30))?; + + assert_eq!( + egraph.eval_expr(&exprs::call("g", vec![exprs::int(2)]))?.1, + { egraph.base_to_value::(20) } + ); + Ok(()) + } } diff --git a/tests/api_const_fold.rs b/tests/api_const_fold.rs index e6226fb4c..f9675f980 100644 --- a/tests/api_const_fold.rs +++ b/tests/api_const_fold.rs @@ -43,7 +43,7 @@ fn install_const_fold_rule(eg: &mut EGraph) -> Result<(), Error> { "fold_add", ruleset, vars![sum: (expr_sort.clone()), lhs: (expr_sort.clone()), rhs: (expr_sort), a: i64, b: i64], - facts![ + query![ (= sum (Add lhs rhs)) (= lhs (Num a)) (= rhs (Num b)) diff --git a/tests/api_proofs.rs b/tests/api_proofs.rs index bf8cbaa5e..49fae1abe 100644 --- a/tests/api_proofs.rs +++ b/tests/api_proofs.rs @@ -19,7 +19,7 @@ fn rust_rule_with_proofs_enabled_errors() { "test_rule", "r", vars![x: i64], - facts![(= y (f x))].unwrap(), + query![(= y (f x))].unwrap(), |_, _| Some(()), ); @@ -42,7 +42,7 @@ fn rust_rule_full_with_proofs_enabled_errors() { "test_rule", "r", vars![x: i64], - facts![(= y (f x))].unwrap(), + query![(= y (f x))].unwrap(), |_, _| Some(()), ); @@ -77,7 +77,7 @@ fn query_with_proofs_enabled_errors_with_query_api_name() { eg.parse_and_run_program(None, "(function f (i64) i64 :merge new)") .unwrap(); - let result = eg.query(vars![x: i64], facts![(= y (f x))].unwrap()); + let result = eg.query(vars![x: i64], query![(= y (f x))].unwrap()); let err = result.expect_err("EGraph::query should fail under proofs"); assert!( diff --git a/tests/api_query.rs b/tests/api_query.rs index 1dafd6bbe..7463a2bdf 100644 --- a/tests/api_query.rs +++ b/tests/api_query.rs @@ -49,7 +49,7 @@ fn function_entries_on_constructor_errors() -> Result<(), Error> { Ok(()) } -/// `egraph.query(vars![x: i64], facts![(R x)].unwrap())` matches and binds `x` +/// `egraph.query(vars![x: i64], query![(R x)].unwrap())` matches and binds `x` /// for every row in the relation. Each match is a /// `HashMap` keyed by variable name. #[test] @@ -66,7 +66,7 @@ fn query_pattern_relation_one_var() -> Result<(), Error> { )?; let mut results: Vec = egraph - .query(vars![x: i64], facts![(R x)].unwrap())? + .query(vars![x: i64], query![(R x)].unwrap())? .into_iter() .map(|m| egraph.value_to_base::(m["x"])) .collect(); @@ -75,7 +75,7 @@ fn query_pattern_relation_one_var() -> Result<(), Error> { Ok(()) } -/// `egraph.query(vars![], facts![(R 1 2)].unwrap())` — zero-var case. +/// `egraph.query(vars![], query![(R 1 2)].unwrap())` — zero-var case. /// /// With zero `vars`, every match still produces a `HashMap` (which /// will be empty since there are no variables to bind), so `.len()` @@ -91,10 +91,10 @@ fn query_pattern_zero_vars_match() -> Result<(), Error> { ", )?; - let hits = egraph.query(vars![], facts![(R 1 2)].unwrap())?; + let hits = egraph.query(vars![], query![(R 1 2)].unwrap())?; assert_eq!(hits.len(), 1); - let misses = egraph.query(vars![], facts![(R 5 5)].unwrap())?; + let misses = egraph.query(vars![], query![(R 5 5)].unwrap())?; assert_eq!(misses.len(), 0); Ok(()) @@ -161,7 +161,7 @@ fn constructor_enodes_relation() -> Result<(), Error> { // To get just the inputs, use the pattern-query form. let mut inputs: Vec = egraph - .query(vars![x: i64], facts![(R x)].unwrap())? + .query(vars![x: i64], query![(R x)].unwrap())? .into_iter() .map(|m| egraph.value_to_base::(m["x"])) .collect(); @@ -185,7 +185,7 @@ fn query_pattern_two_vars() -> Result<(), Error> { )?; let mut rows: Vec<(i64, i64)> = egraph - .query(vars![x: i64, y: i64], facts![(= (f x) y)].unwrap())? + .query(vars![x: i64, y: i64], query![(= (f x) y)].unwrap())? .into_iter() .map(|m| { ( diff --git a/tests/extraction_proof_mode.rs b/tests/extraction_proof_mode.rs index 4b6070dea..1bf4e4774 100644 --- a/tests/extraction_proof_mode.rs +++ b/tests/extraction_proof_mode.rs @@ -95,7 +95,7 @@ fn test_extraction_same_with_proof_mode_using_rule_macro() { rule( &mut egraph_normal, "my_rules", - facts![(= (Add a b) e)].unwrap(), + query![(= (Add a b) e)].unwrap(), actions![(union e (Add b a))].unwrap(), ) .unwrap(); @@ -118,7 +118,7 @@ fn test_extraction_same_with_proof_mode_using_rule_macro() { rule( &mut egraph_proofs, "my_rules", - facts![(= (Add a b) e)].unwrap(), + query![(= (Add a b) e)].unwrap(), actions![(union e (Add b a))].unwrap(), ) .unwrap(); From c126c3e76736ae515ca9a4682fc076554f25c43c Mon Sep 17 00:00:00 2001 From: oliver Date: Tue, 14 Jul 2026 20:47:22 +0000 Subject: [PATCH 04/10] Polish quasiquote docs - Drop the "no quotes" / "real parser" framing (it implied an alternative the reader has no context for); describe the macros in their own terms. - Trim the lib.md section to a short intro + a small runnable example, and point to the `egglog_quote` crate for the full reference. - Soften jargon ("round-trip" / "splices as identity") in the parse.rs and sexp! docs. Co-Authored-By: Claude Opus 4.8 (1M context) --- quote-macro/src/lib.rs | 13 +++++++------ src/ast/parse.rs | 10 +++++----- src/lib.md | 27 +++++++++++++-------------- 3 files changed, 25 insertions(+), 25 deletions(-) diff --git a/quote-macro/src/lib.rs b/quote-macro/src/lib.rs index b57b61151..c8261c379 100644 --- a/quote-macro/src/lib.rs +++ b/quote-macro/src/lib.rs @@ -1,10 +1,11 @@ //! Token-tree quasiquotes for writing egglog inline in Rust. //! -//! Write egglog as ordinary Rust tokens (no quotes); these macros rebuild the -//! s-expression and run it through the real parser, so every registered parser -//! macro fires (named args, `for`, `...`) and `?x`, `:field`, negative -//! literals, dashed atoms (`:no-merge`), and operators all work. They are -//! re-exported from `egglog` and `egglog::prelude`. +//! Write egglog directly in your Rust source — the same syntax you'd put in a +//! `.egg` file — and these macros parse it into `Command` / `Expr` / `Fact` +//! values. Parsing goes through egglog's own parser, so `?x`, `:field`, `...`, +//! negative literals, dashed atoms (`:no-merge`), operators, and every +//! registered parser macro all behave just as they do in egglog text. The +//! macros are re-exported from `egglog` and `egglog::prelude`. //! //! # The three variants //! @@ -234,7 +235,7 @@ pub fn run_rule(input: TokenStream) -> TokenStream { /// Build an *un-parsed* s-expression: `sexp!()` → `Sexp` (no parser /// argument). Supports `#` / `#..` / `:#` splices, and is meant to be -/// `#`-spliced into a typed quasiquote later (a `Sexp` splices as identity). +/// `#`-spliced into another quasiquote later (where a `Sexp` is used as-is). /// /// ```ignore /// let kind = "MyOp"; diff --git a/src/ast/parse.rs b/src/ast/parse.rs index a55cb9be0..65b4d2247 100644 --- a/src/ast/parse.rs +++ b/src/ast/parse.rs @@ -191,9 +191,9 @@ pub fn keyword_to_sexp(name: impl std::fmt::Display, span: Span) -> Sexp { /// Values that can be spliced into any quasiquote (`expr!`, `egglog!`, `sexp!`, /// …) via `#x` / `#(expr)`, or spread into a list with `#..xs` where each -/// element is `ToSexp`. Implemented for [`Sexp`] (identity — no round-trip), a -/// built [`Expr`] (structural), `&str`/`String` (an atom), and `i64` (an int -/// literal). +/// element is `ToSexp`. Implemented for [`Sexp`] (used as-is), a built [`Expr`] +/// (converted structurally, not through its printed form), `&str`/`String` (an +/// atom), and `i64` (an int literal). pub trait ToSexp { fn to_sexp(self, span: Span) -> Sexp; } @@ -224,8 +224,8 @@ impl ToSexp for Expr { } } -/// Structural (not textual) conversion of an already-built [`Expr`] into a -/// [`Sexp`], so it can be spliced into a quasiquote without a display round-trip. +/// Convert an already-built [`Expr`] into a [`Sexp`] structurally, so it can be +/// spliced into a quasiquote directly rather than printed to text and reparsed. fn expr_to_sexp(e: &Expr, span: Span) -> Sexp { match e { Expr::Lit(_, lit) => Sexp::Literal(lit.clone(), span), diff --git a/src/lib.md b/src/lib.md index 8e9397c60..382dfda9a 100644 --- a/src/lib.md +++ b/src/lib.md @@ -42,27 +42,26 @@ global name to it (`(let $root ...)`), resolve the global with [`extract`] module has the full extractor API. # Writing egglog inline from Rust -Write egglog as ordinary Rust tokens with the quasiquote macros — [`expr!`], -[`query!`], [`action!`]/[`actions!`], [`command!`], [`egglog!`], and [`rule!`] -parse to AST (`Expr` / `Facts` / `Command` / …), splicing Rust values in with -`#x`, `#..xs`, and `:#field`; [`sexp!`] builds an un-parsed fragment for -splicing. Each parsing macro also has two e-graph-aware variants: `resolve_*!` -typechecks against an e-graph without running (e.g. [`resolve_egglog!`] → -`Vec`), and `run_*!` runs it (e.g. [`run_egglog!`] → -`Vec`, [`run_query!`] → query matches, [`run_expr!`] → -`(sort, value)`). All are re-exported from [`prelude`]. +The quasiquote macros let you write egglog right in your Rust source: +[`egglog!`] for a whole program, [`expr!`] / [`query!`] / [`command!`] / +[`rule!`] / [`action!`] for a single form. Splice Rust values in with `#`, and +reach for the `resolve_*!` / `run_*!` variants to typecheck or execute against +an e-graph. ``` use egglog::prelude::*; let mut egraph = EGraph::default(); -egraph.run_program(egglog!( +run_egglog!( + &mut egraph, (datatype Math (Num i64) (Add Math Math)) (rule ((= e (Add (Num a) (Num b)))) ((union e (Num (+ a b))))) - (let start (Add (Num 1) (Num 2))) - (run 1) - (check (= start (Num 3))) -)?)?; +)?; + let n = 2; assert_eq!(expr!((Num #n))?.to_string(), "(Num 2)"); # Ok::<(), egglog::Error>(()) ``` + +For the full reference — every macro, the parse / `resolve_*` / `run_*` +variants, and the splice forms (`#x`, `#..xs`, `:#field`) — see the +[`egglog_quote`] crate. From 2fc3db1a06e70c07440c26ea73787f4615cdd202 Mon Sep 17 00:00:00 2001 From: oliver Date: Tue, 14 Jul 2026 20:55:45 +0000 Subject: [PATCH 05/10] Remove the rule() prelude helper; re-export rule! into the prelude MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `rule(egraph, ruleset, facts, actions)` is subsumed by `run_egglog!(egraph, (rule () () :ruleset r))` (and `run_rule!` for the default ruleset) now that the run macros exist — it had no library callers, only tests/doctests. Removing it also frees the name: `rule!` is now re-exported into `prelude` alongside `resolve_rule!`/`run_rule!` (it was held out only to avoid colliding with the fn), so the rule triple is complete and consistent with the other categories. Migrated the test/doc call sites to `run_egglog!((rule … :ruleset …))`. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/prelude.rs | 146 +++++++-------------------------- tests/extraction_proof_mode.rs | 12 +-- 2 files changed, 33 insertions(+), 125 deletions(-) diff --git a/src/prelude.rs b/src/prelude.rs index cf23b8015..6ad63ff3c 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -117,7 +117,8 @@ //! //! ## Rules //! -//! - [`rule()`] — add a rule whose RHS is egglog code. +//! - [`run_rule!`] / [`run_egglog!`] — add a rule whose RHS is egglog code +//! (write it inline; `run_egglog!` also takes `:ruleset`). //! - [`rust_rule`] — add a rule whose RHS is a Rust closure //! `Fn(WriteState, &[Value]) -> Option<()>`. Seminaive-safe. //! - [`rust_rule_full`] — same but the closure receives a @@ -161,12 +162,9 @@ pub use egglog::sort::{BigIntSort, BigRatSort, BoolSort, F64Sort, I64Sort, Strin pub use egglog::{CommandMacro, CommandMacroRegistry}; pub use egglog::{Core, FullState, PureState, Read, ReadState, Write, WriteState}; pub use egglog::{EGraph, span}; -// NOTE: the `rule!` quasiquote is intentionally *not* re-exported here — it -// would collide with the `rule()` helper fn below (macro vs value). Reach it as -// `egglog::rule!` if needed; `command!`/`egglog!` can express rules too. pub use egglog::{ action, actions, command, egglog, expr, query, resolve_action, resolve_actions, - resolve_command, resolve_egglog, resolve_expr, resolve_query, resolve_rule, run_action, + resolve_command, resolve_egglog, resolve_expr, resolve_query, resolve_rule, rule, run_action, run_actions, run_command, run_egglog, run_expr, run_query, run_rule, sexp, sexps, sort, vars, }; @@ -339,94 +337,13 @@ macro_rules! vars { }; } -// `expr!`/`fact!`/`query!`/`action!`/`actions!` — together with `command!`, -// `egglog!`, `rule!`, `sexp!`, and `sexps!` — are token-tree quasiquotes that -// route through the parser, so `?x`, `:field`, `...`, `#`-splices, and every -// registered parser macro work. They live in the `egglog-quote` proc-macro -// crate and are re-exported from the crate root; see there for the definitions. - -/// Add a rule to the e-graph whose right-hand side is made up of actions. -/// ``` -/// use egglog::prelude::*; -/// -/// let mut egraph = EGraph::default(); -/// egraph.parse_and_run_program( -/// None, -/// " -/// (function fib (i64) i64 :no-merge) -/// (set (fib 0) 0) -/// (set (fib 1) 1) -/// (rule ( -/// (= f0 (fib x)) -/// (= f1 (fib (+ x 1))) -/// ) ( -/// (set (fib (+ x 2)) (+ f0 f1)) -/// )) -/// (run 10) -/// ", -/// )?; -/// -/// let big_number = 20; -/// -/// // check that `(fib 20)` is not in the e-graph -/// let results = egraph.query( -/// vars![f: i64], -/// query!((= (fib #big_number) f))?, -/// )?; -/// -/// assert!(results.is_empty()); -/// -/// let ruleset = "custom_ruleset"; -/// add_ruleset(&mut egraph, ruleset)?; -/// -/// // add the rule from `build_test_database` to the egraph -/// rule( -/// &mut egraph, -/// ruleset, -/// query!( -/// (= f0 (fib x)) -/// (= f1 (fib (+ x 1))) -/// )?, -/// actions!( -/// (set (fib (+ x 2)) (+ f0 f1)) -/// )?, -/// )?; -/// -/// // run that rule 10 times -/// for _ in 0..10 { -/// run_ruleset(&mut egraph, ruleset)?; -/// } -/// -/// // check that `(fib 20)` is now in the e-graph -/// let results = egraph.query( -/// vars![f: i64], -/// query!((= (fib #big_number) f))?, -/// )?; -/// -/// let f: Vec = results.iter().map(|m| egraph.value_to_base::(m["f"])).collect(); -/// assert_eq!(f, [6765]); -/// -/// # Ok::<(), egglog::Error>(()) -/// ``` -pub fn rule( - egraph: &mut EGraph, - ruleset: &str, - facts: Facts, - actions: Actions, -) -> Result, Error> { - let rule = Rule { - span: span!(), - head: actions, - body: facts.0, - name: "".into(), - ruleset: ruleset.into(), - eval_mode: RuleEvalMode::Seminaive, - no_decomp: false, - include_subsumed: false, - }; - - egraph.run_program(vec![Command::Rule { rule }]) -} +// `expr!`/`query!`/`action!`/`actions!`, `command!`/`egglog!`/`rule!`, and +// `sexp!`/`sexps!` are token-tree quasiquotes that route through the parser, so +// `?x`, `:field`, `...`, `#`-splices, and every registered parser macro work. +// Each parsing macro also has `resolve_*!` / `run_*!` e-graph variants — e.g. +// add a rule with `run_rule!(egraph, () ())` or +// `run_egglog!(egraph, (rule … :ruleset r))`. They live in the `egglog-quote` +// crate and are re-exported from the crate root. #[derive(Clone)] struct RustRuleRhs @@ -1025,14 +942,12 @@ mod tests { add_ruleset(&mut egraph, ruleset)?; // add the rule from `build_test_database` to the egraph - rule( + run_egglog!( &mut egraph, - ruleset, - query!( - (= f0 (fib x)) - (= f1 (fib (+ x 1))) - )?, - actions!((set (fib (+ x 2)) (+ f0 f1)))?, + (rule + ((= f0 (fib x)) (= f1 (fib (+ x 1)))) + ((set (fib (+ x 2)) (+ f0 f1))) + :ruleset #ruleset) )?; // run that rule 10 times @@ -1058,24 +973,21 @@ mod tests { let ruleset = "custom_ruleset"; add_ruleset(&mut egraph, ruleset)?; - rule( + run_egglog!( &mut egraph, - ruleset, - query!( - (fib 5) - (fib x) - (= f1 (fib (+ x 1))) - (= 3 #(1 + 2)) - )?, - actions!( - (let y (+ x 2)) - (set (fib (+ x 2)) (+ f1 f1)) - (delete (fib 0)) - (subsume (Two (One) (One))) - (union (One) (Two (One) (One))) - (panic "message") - (+ 6 87) - )?, + (rule + ((fib 5) + (fib x) + (= f1 (fib (+ x 1))) + (= 3 #(1 + 2))) + ((let y (+ x 2)) + (set (fib (+ x 2)) (+ f1 f1)) + (delete (fib 0)) + (subsume (Two (One) (One))) + (union (One) (Two (One) (One))) + (panic "message") + (+ 6 87)) + :ruleset #ruleset) )?; Ok(()) diff --git a/tests/extraction_proof_mode.rs b/tests/extraction_proof_mode.rs index 1bf4e4774..95d55fe6a 100644 --- a/tests/extraction_proof_mode.rs +++ b/tests/extraction_proof_mode.rs @@ -92,11 +92,9 @@ fn test_extraction_same_with_proof_mode_using_rule_macro() { egraph_normal.parse_and_run_program(None, setup).unwrap(); add_ruleset(&mut egraph_normal, "my_rules").unwrap(); - rule( + run_egglog!( &mut egraph_normal, - "my_rules", - query![(= (Add a b) e)].unwrap(), - actions![(union e (Add b a))].unwrap(), + (rule ((= (Add a b) e)) ((union e (Add b a))) :ruleset my_rules) ) .unwrap(); @@ -115,11 +113,9 @@ fn test_extraction_same_with_proof_mode_using_rule_macro() { // Add the same rule add_ruleset(&mut egraph_proofs, "my_rules").unwrap(); - rule( + run_egglog!( &mut egraph_proofs, - "my_rules", - query![(= (Add a b) e)].unwrap(), - actions![(union e (Add b a))].unwrap(), + (rule ((= (Add a b) e)) ((union e (Add b a))) :ruleset my_rules) ) .unwrap(); From 2c5737e522d75b31907fd91900cc933989ac18d4 Mon Sep 17 00:00:00 2001 From: oliver Date: Tue, 14 Jul 2026 21:03:42 +0000 Subject: [PATCH 06/10] Prune redundant prelude helpers; drop the add_*_sort span param MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove `add_sort` / `add_function` / `add_constructor` / `add_relation`: 0 callers, and each is just `run_egglog!(eg, (sort …))` / `(function …)` / `(constructor …)` / `(relation …)` with a `Schema` struct instead of egglog syntax. (Two were only ever used by the removed `datatype!`.) - `add_base_sort` / `add_container_sort` register a Rust sort *type* (no egglog text equivalent), so they stay — but drop their redundant explicit `span: Span` parameter (every caller passed `span!()`) and use `span!()` internally, matching how the rest of the API hides spans. The remaining Rust-side helpers already fit the new API: `rust_rule` / `rust_rule_full` / `EGraph::query` take `Facts` (what `query!` produces); `add_ruleset` / `run_ruleset` are thin typed conveniences used internally by `query`/`rust_rule`; `exprs` / `sort!` / `vars!` are the low-level builders that back `rust_rule`. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib.rs | 14 ++++----- src/prelude.rs | 77 ++------------------------------------------------ 2 files changed, 10 insertions(+), 81 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 50e461c5d..016585f0e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -424,13 +424,13 @@ impl Default for EGraph { proof_state, proof_check_program: vec![], }; - add_base_sort(&mut eg, UnitSort, span!()).unwrap(); - add_base_sort(&mut eg, StringSort, span!()).unwrap(); - add_base_sort(&mut eg, BoolSort, span!()).unwrap(); - add_base_sort(&mut eg, I64Sort, span!()).unwrap(); - add_base_sort(&mut eg, F64Sort, span!()).unwrap(); - add_base_sort(&mut eg, BigIntSort, span!()).unwrap(); - add_base_sort(&mut eg, BigRatSort, span!()).unwrap(); + add_base_sort(&mut eg, UnitSort).unwrap(); + add_base_sort(&mut eg, StringSort).unwrap(); + add_base_sort(&mut eg, BoolSort).unwrap(); + add_base_sort(&mut eg, I64Sort).unwrap(); + add_base_sort(&mut eg, F64Sort).unwrap(); + add_base_sort(&mut eg, BigIntSort).unwrap(); + add_base_sort(&mut eg, BigRatSort).unwrap(); eg.type_info.add_presort::(span!()).unwrap(); eg.type_info.add_presort::(span!()).unwrap(); eg.type_info.add_presort::(span!()).unwrap(); diff --git a/src/prelude.rs b/src/prelude.rs index 6ad63ff3c..cac3290d5 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -631,72 +631,6 @@ pub fn rust_rule_full( egraph.run_program(vec![Command::Rule { rule }]) } -/// Declare a new sort. -pub fn add_sort(egraph: &mut EGraph, name: &str) -> Result, Error> { - egraph.run_program(vec![Command::Sort { - span: span!(), - name: name.to_owned(), - presort_and_args: None, - uf: None, - proof_func: None, - container_rebuild: None, - proof_constructors: None, - unionable: true, - }]) -} - -/// Declare a new function table. -pub fn add_function( - egraph: &mut EGraph, - name: &str, - schema: Schema, - merge: Option>, -) -> Result, Error> { - egraph.run_program(vec![Command::Function { - span: span!(), - name: name.to_owned(), - schema, - merge, - hidden: false, - let_binding: false, - term_constructor: None, - unextractable: false, - }]) -} - -/// Declare a new constructor table. -pub fn add_constructor( - egraph: &mut EGraph, - name: &str, - schema: Schema, - cost: Option, - unextractable: bool, -) -> Result, Error> { - egraph.run_program(vec![Command::Constructor { - span: span!(), - name: name.to_owned(), - schema, - cost, - unextractable, - hidden: false, - let_binding: false, - term_constructor: None, - }]) -} - -/// Declare a new relation table. -pub fn add_relation( - egraph: &mut EGraph, - name: &str, - inputs: Vec, -) -> Result, Error> { - egraph.run_program(vec![Command::Relation { - span: span!(), - name: name.to_owned(), - inputs, - }]) -} - // (The old `datatype!` macro was removed — it's subsumed by // `run_egglog!(egraph, (datatype ...))`, which parses, typechecks, and // registers the datatype the same way. Use `egglog!((datatype ...))` for the @@ -868,20 +802,15 @@ impl Sort for ContainerSortImpl { } /// Add a [`BaseSort`] to the e-graph -pub fn add_base_sort( - egraph: &mut EGraph, - base_sort: impl BaseSort, - span: Span, -) -> Result<(), TypeError> { - egraph.add_sort(BaseSortImpl(base_sort), span) +pub fn add_base_sort(egraph: &mut EGraph, base_sort: impl BaseSort) -> Result<(), TypeError> { + egraph.add_sort(BaseSortImpl(base_sort), span!()) } pub fn add_container_sort( egraph: &mut EGraph, container_sort: impl ContainerSort, - span: Span, ) -> Result<(), TypeError> { - egraph.add_sort(ContainerSortImpl(container_sort), span) + egraph.add_sort(ContainerSortImpl(container_sort), span!()) } #[cfg(test)] From ef9e6816415ba8569d176fc07241dda5bbf186d3 Mon Sep 17 00:00:00 2001 From: oliver Date: Tue, 14 Jul 2026 21:35:00 +0000 Subject: [PATCH 07/10] Consolidate the quasiquote guide into egglog::prelude MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Put the full, user-facing macro guide — the parse / resolve_* / run_* table, the splice forms, and an example — in `egglog::prelude`, alongside the rest of the Rust API (custom rules, primitives, sorts). It leads the module now, since writing egglog inline is the primary way to use egglog from Rust. - `egglog-quote`'s module doc shrinks to a one-line pointer (it's an implementation crate; users read `egglog::prelude`). - `lib.md` keeps a short intro + example and points at [`prelude`]. - Per-macro docs point to `egglog::prelude` instead of "the module docs". - Dropped implementation framing ("token-tree", "routes through the parser", `ToSexp`/`Sexp` internals) in favor of what the macros let you do. Co-Authored-By: Claude Opus 4.8 (1M context) --- quote-macro/src/lib.rs | 62 ++++++------------------------------------ src/lib.md | 6 ++-- src/prelude.rs | 50 +++++++++++++++++++++++++++------- 3 files changed, 51 insertions(+), 67 deletions(-) diff --git a/quote-macro/src/lib.rs b/quote-macro/src/lib.rs index c8261c379..7f0cf2026 100644 --- a/quote-macro/src/lib.rs +++ b/quote-macro/src/lib.rs @@ -1,56 +1,10 @@ -//! Token-tree quasiquotes for writing egglog inline in Rust. +//! Procedural macros backing egglog's quasiquotes — `expr!`, `query!`, +//! `command!`, `egglog!`, `rule!`, `action!`/`actions!`, `sexp!`/`sexps!`, and +//! their `resolve_*!` / `run_*!` variants. //! -//! Write egglog directly in your Rust source — the same syntax you'd put in a -//! `.egg` file — and these macros parse it into `Command` / `Expr` / `Fact` -//! values. Parsing goes through egglog's own parser, so `?x`, `:field`, `...`, -//! negative literals, dashed atoms (`:no-merge`), operators, and every -//! registered parser macro all behave just as they do in egglog text. The -//! macros are re-exported from `egglog` and `egglog::prelude`. -//! -//! # The three variants -//! -//! Every category comes in three flavors. The **parse** macro takes an -//! *optional* leading `parser,` (a fresh default `Parser` if omitted) and -//! returns unresolved AST. The **`resolve_*`** and **`run_*`** macros each take -//! an e-graph as a required first argument: `resolve_*` typechecks the body -//! against the e-graph (no execution), `run_*` executes it. -//! -//! | category | parse (`[parser,] …`) | `resolve_…!(eg, …)` | `run_…!(eg, …)` | -//! |---|---|---|---| -//! | expression | `expr!` → `Expr` | `resolve_expr!` → `ResolvedExpr` | `run_expr!` → `(ArcSort, Value)` | -//! | query | `query!` → `Facts` | `resolve_query!` → `Vec` | `run_query!` → `Vec>` (matches) | -//! | action(s) | `action!` / `actions!` → `Vec` / `Actions` | `resolve_action(s)!` → `Vec` | `run_action(s)!` → `Vec` | -//! | command | `command!` → `Vec` | `resolve_command!` → `Vec` | `run_command!` → `Vec` | -//! | program | `egglog!` → `Vec` | `resolve_egglog!` → `Vec` | `run_egglog!` → `Vec` | -//! | rule | `rule!` → `Vec` | `resolve_rule!` → `Vec` | `run_rule!` → `Vec` | -//! -//! Parse macros return `Result<_, ParseError>`; the `resolve_*` / `run_*` -//! macros return `Result<_, egglog::Error>`. `run_query!` derives the query -//! variables (and their sorts) from the facts, so it needs no explicit `vars`. -//! -//! `sexp!` / `sexps!` are separate: they build a raw `Sexp` / `Vec` -//! *without* parsing (no parser, no e-graph) — for `#`-splicing into another -//! quasiquote. -//! -//! # Splices -//! -//! | Form | Meaning | -//! |---|---| -//! | `#x` / `#(expr)` | one value implementing `egglog::ast::ToSexp` | -//! | `#..xs` | spread (Racket's `,@`): extend the list with each element of `xs: IntoIterator` | -//! | `:#field` / `:#(expr)` | a runtime keyword — the value becomes the atom `:` | -//! -//! `#` also works in head position, so the head can be a runtime value: -//! `expr!((#head 5))`. `ToSexp` covers `Sexp` (identity), `Expr` (structural), -//! `&str`/`String` (atom), and `i64`; build a `Sexp` fragment with `sexp!` to -//! `#`-splice it into a typed quasiquote later. -//! -//! ```ignore -//! let a = 2; -//! let e = expr!((Add (Num #a) ?x))?; // (Add (Num 2) ?x) -//! let fields = vec!["?a", "?b"]; -//! let prog = egglog!(p, (rule ((= l (#kind #..fields))) ((union l r))))?; -//! ``` +//! These are re-exported from the `egglog` crate; the user-facing guide — what +//! each macro produces, the `#` / `#..` / `:#` splice forms, and examples — +//! lives in `egglog::prelude`. The doc on each macro below notes its signature. use proc_macro::TokenStream; use proc_macro2::{Delimiter, TokenStream as TokenStream2, TokenTree}; @@ -63,7 +17,7 @@ use std::iter::Peekable; /// let x = expr!((+ ?a 1))?; // default parser /// let y = expr!(my_parser, (Mul :shape ?s ...))?; /// ``` -/// See the module-level docs for the parser argument and the `#` / `#..` / `:#` splices. +/// See `egglog::prelude` for the parser argument, the `#` / `#..` / `:#` splices, and the `resolve_*!` / `run_*!` variants. #[proc_macro] pub fn expr(input: TokenStream) -> TokenStream { build(input, Method::Expr) @@ -179,7 +133,7 @@ pub fn run_command(input: TokenStream) -> TokenStream { /// (rule ((= e (Add (Num a) (Num b)))) ((union e (Num (+ a b))))) /// )?)?; /// ``` -/// See the module-level docs for the parser argument and the `#` / `#..` / `:#` splices. +/// See `egglog::prelude` for the parser argument, the `#` / `#..` / `:#` splices, and the `resolve_*!` / `run_*!` variants. #[proc_macro] pub fn egglog(input: TokenStream) -> TokenStream { build(input, Method::Program) diff --git a/src/lib.md b/src/lib.md index 382dfda9a..94aa83e02 100644 --- a/src/lib.md +++ b/src/lib.md @@ -62,6 +62,6 @@ assert_eq!(expr!((Num #n))?.to_string(), "(Num 2)"); # Ok::<(), egglog::Error>(()) ``` -For the full reference — every macro, the parse / `resolve_*` / `run_*` -variants, and the splice forms (`#x`, `#..xs`, `:#field`) — see the -[`egglog_quote`] crate. +See [`prelude`] for the full guide — every macro, the parse / `resolve_*` / +`run_*` variants, and the splice forms — alongside the rest of the Rust API +(custom rules, primitives, and sorts). diff --git a/src/prelude.rs b/src/prelude.rs index cac3290d5..2715af586 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -4,23 +4,53 @@ //! use egglog::prelude::*; //! ``` //! -//! Most workflows are best expressed as an egglog program parsed and -//! run via [`crate::EGraph::parse_and_run_program`]: declare your -//! sorts, functions, and rules once in egglog text, then call into -//! the database from Rust around it. +//! # Writing egglog inline +//! +//! The quasiquote macros let you write egglog directly in Rust rather than as +//! a string — with parse and type errors reported at the call site, and Rust +//! values spliced in. [`egglog!`] takes a whole program; [`expr!`], [`query!`], +//! [`command!`], [`rule!`], and [`action!`] each take a single form. `?x`, +//! `:field`, `...`, and any sorts or macros you've registered all work exactly +//! as they do in egglog text. +//! +//! Each parsing macro has three forms — parse it, or, against an e-graph, +//! `resolve_*!` it (typecheck, no run) or `run_*!` it (run): +//! +//! | | parse | `resolve_…!(egraph, …)` | `run_…!(egraph, …)` | +//! |---------|-------------|-------------------------|----------------------------| +//! | expr | [`expr!`] | [`resolve_expr!`] | [`run_expr!`] → `(sort, value)` | +//! | query | [`query!`] | [`resolve_query!`] | [`run_query!`] → matches | +//! | actions | [`action!`] / [`actions!`] | [`resolve_action!`] | [`run_action!`] → outputs | +//! | command | [`command!`] | [`resolve_command!`] | [`run_command!`] → outputs | +//! | program | [`egglog!`] | [`resolve_egglog!`] | [`run_egglog!`] → outputs | +//! | rule | [`rule!`] | [`resolve_rule!`] | [`run_rule!`] → outputs | +//! +//! Splice Rust values in with `#`: +//! +//! - `#x` / `#(expr)` — one value: a `&str`/`String` (becomes an atom), an +//! `i64`, a built [`Expr`], or a [`sexp!`] fragment. +//! - `#..xs` — spread each item of an iterator into the enclosing list. +//! - `:#field` — a runtime keyword: the value becomes `:`. +//! +//! [`sexp!`] builds a fragment to splice into a macro later. //! //! ``` //! use egglog::prelude::*; -//! let mut eg = EGraph::default(); -//! eg.parse_and_run_program( -//! None, -//! "(datatype Math (Num i64) (Add Math Math)) -//! (Add (Num 1) (Num 2))", +//! let mut egraph = EGraph::default(); +//! run_egglog!( +//! &mut egraph, +//! (datatype Math (Num i64) (Add Math Math)) +//! (rule ((= e (Add (Num a) (Num b)))) ((union e (Num (+ a b))))) //! )?; +//! +//! let n = 2; +//! assert_eq!(expr!((Num #n))?.to_string(), "(Num 2)"); //! # Ok::<(), egglog::Error>(()) //! ``` //! -//! Three cases need a Rust escape from the language: +//! For a whole program kept in a file or a large string, use +//! [`crate::EGraph::parse_and_run_program`]. The rest of this module is Rust +//! escape hatches for when the language isn't enough: //! //! 1. **Driving the e-graph database directly** — building rows, //! looking them up, iterating tables, running ad-hoc queries from From 22f0edb4dc30a4dbd2032cb74625fea9e0c93b0f Mon Sep 17 00:00:00 2001 From: oliver Date: Tue, 14 Jul 2026 16:25:43 -0700 Subject: [PATCH 08/10] egglog static --- Cargo.lock | 12 ++ Cargo.toml | 1 + egglog-ast/Cargo.toml | 6 + egglog-ast/src/lib.rs | 2 + egglog-ast/src/tokens.rs | 54 ++++++++ quote-macro/Cargo.toml | 3 + quote-macro/src/lib.rs | 43 +------ src/lib.rs | 26 ++++ src/typechecking.rs | 32 +++++ static-macro/Cargo.toml | 29 +++++ static-macro/src/lib.rs | 239 ++++++++++++++++++++++++++++++++++++ static-macro/tests/basic.rs | 49 ++++++++ 12 files changed, 455 insertions(+), 41 deletions(-) create mode 100644 egglog-ast/src/tokens.rs create mode 100644 static-macro/Cargo.toml create mode 100644 static-macro/src/lib.rs create mode 100644 static-macro/tests/basic.rs diff --git a/Cargo.lock b/Cargo.lock index 4c5f16066..d49d3eb7e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -508,6 +508,7 @@ name = "egglog-ast" version = "2.0.0" dependencies = [ "ordered-float", + "proc-macro2", ] [[package]] @@ -587,6 +588,7 @@ version = "2.0.0" name = "egglog-quote" version = "2.0.0" dependencies = [ + "egglog-ast", "proc-macro2", "quote", ] @@ -604,6 +606,16 @@ dependencies = [ "web-time", ] +[[package]] +name = "egglog-static" +version = "2.0.0" +dependencies = [ + "egglog", + "egglog-ast", + "proc-macro2", + "quote", +] + [[package]] name = "egglog-union-find" version = "2.0.0" diff --git a/Cargo.toml b/Cargo.toml index 619fa6fac..91da4ccf8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "union-find", "src/sort/add_primitive", "quote-macro", + "static-macro", "wasm-example" ] diff --git a/egglog-ast/Cargo.toml b/egglog-ast/Cargo.toml index 4b4744b2a..78dbe44cf 100644 --- a/egglog-ast/Cargo.toml +++ b/egglog-ast/Cargo.toml @@ -12,7 +12,13 @@ readme = { workspace = true } [dependencies] ordered-float = { workspace = true } +# Only for the `tokens` feature (token → egglog-source rendering shared by the +# quasiquote proc-macro crates); off by default, so normal builds don't pull it. +proc-macro2 = { workspace = true, optional = true, features = ["span-locations"] } [features] default = [] +# Helpers for turning Rust proc-macro tokens into egglog atoms. Enabled by the +# `egglog-quote` and `egglog-static` proc-macro crates. +tokens = ["dep:proc-macro2"] diff --git a/egglog-ast/src/lib.rs b/egglog-ast/src/lib.rs index 544982ba3..4aad6ddbc 100644 --- a/egglog-ast/src/lib.rs +++ b/egglog-ast/src/lib.rs @@ -1,4 +1,6 @@ pub mod generic_ast; pub mod generic_ast_helpers; pub mod span; +#[cfg(feature = "tokens")] +pub mod tokens; pub mod util; diff --git a/egglog-ast/src/tokens.rs b/egglog-ast/src/tokens.rs new file mode 100644 index 000000000..9d6b5e335 --- /dev/null +++ b/egglog-ast/src/tokens.rs @@ -0,0 +1,54 @@ +//! Turning Rust proc-macro tokens into egglog atoms. +//! +//! Shared by the quasiquote proc-macro crates (`egglog-quote`, `egglog-static`) +//! so they reconstruct egglog atoms from Rust tokens identically. Gated behind +//! the `tokens` feature so egglog-ast's normal build doesn't pull `proc-macro2`. + +use proc_macro2::{Span, TokenTree}; +use std::iter::Peekable; + +/// The source text of a single token: a punct's char, an identifier's name, or +/// a literal's / group's text. +pub fn tt_str(tt: &TokenTree) -> String { + match tt { + TokenTree::Punct(p) => p.as_char().to_string(), + TokenTree::Ident(i) => i.to_string(), + TokenTree::Literal(l) => l.to_string(), + TokenTree::Group(g) => g.to_string(), + } +} + +/// Reassemble one egglog atom starting at `first` by absorbing every following +/// token that begins exactly where the previous one ended — i.e. with no +/// whitespace between them. This mirrors egglog's tokenizer (an atom is a +/// maximal run of non-space, non-paren characters): `?x`, `:no-merge`, `-1.0`, +/// `>=`, `my-ruleset` all become single atoms, while `(+ 6 87)` stays three +/// tokens because the spaces break the run. +/// +/// Returns the atom text and the span of its first token, which anchors any +/// diagnostic reported inside the atom. +pub fn atom_run( + first: TokenTree, + it: &mut Peekable>, +) -> (String, Span) { + let span = first.span(); + let mut s = tt_str(&first); + let mut end = first.span().end(); + while let Some(next) = it.peek() { + if matches!(next, TokenTree::Group(_)) { + break; + } + if let TokenTree::Literal(l) = next + && l.to_string().starts_with('"') + { + break; // a string literal is its own token, never glued + } + if next.span().start() != end { + break; // whitespace between tokens ends the atom + } + s.push_str(&tt_str(next)); + end = next.span().end(); + it.next(); + } + (s, span) +} diff --git a/quote-macro/Cargo.toml b/quote-macro/Cargo.toml index 4807ca4ef..c97760165 100644 --- a/quote-macro/Cargo.toml +++ b/quote-macro/Cargo.toml @@ -14,3 +14,6 @@ proc-macro = true [dependencies] proc-macro2 = { workspace = true, features = ["span-locations"] } quote = { workspace = true } +# Shared token → egglog-atom rendering (also used by `egglog-static`), so both +# crates reconstruct atoms identically. Not `egglog` itself, so no cycle. +egglog-ast = { workspace = true, features = ["tokens"] } diff --git a/quote-macro/src/lib.rs b/quote-macro/src/lib.rs index 7f0cf2026..20862f39c 100644 --- a/quote-macro/src/lib.rs +++ b/quote-macro/src/lib.rs @@ -6,10 +6,10 @@ //! each macro produces, the `#` / `#..` / `:#` splice forms, and examples — //! lives in `egglog::prelude`. The doc on each macro below notes its signature. +use egglog_ast::tokens::atom_run; use proc_macro::TokenStream; use proc_macro2::{Delimiter, TokenStream as TokenStream2, TokenTree}; use quote::quote; -use std::iter::Peekable; /// Parse one egglog expression: `expr!([parser,] )` → `Result`. /// @@ -632,7 +632,7 @@ fn sexp_seq(ts: TokenStream2) -> Vec { // so `?x`, `:no-merge`, `-1.0`, `>=` become single atoms while // space-separated tokens like `(+ 6 87)` stay separate. first => { - let atom = atom_run(first.clone(), &mut it); + let atom = atom_run(first.clone(), &mut it).0; out.push(Child::Single( quote!(::egglog::ast::atom_to_sexp(#atom, __span.clone())), )); @@ -641,42 +641,3 @@ fn sexp_seq(ts: TokenStream2) -> Vec { } out } - -/// The source text of a single token (for atoms): a punct's char, an -/// identifier's name, or a literal's text. -fn tt_str(tt: &TokenTree) -> String { - match tt { - TokenTree::Punct(p) => p.as_char().to_string(), - TokenTree::Ident(i) => i.to_string(), - TokenTree::Literal(l) => l.to_string(), - TokenTree::Group(g) => g.to_string(), - } -} - -/// Reassemble one egglog atom starting at `first` by absorbing every following -/// token that begins exactly where the previous one ended — i.e. with no -/// whitespace between them. This mirrors egglog's tokenizer (an atom is a -/// maximal run of non-space, non-paren characters): `?x`, `:no-merge`, `-1.0`, -/// `>=`, `my-ruleset` all become single atoms, while `(+ 6 87)` stays three -/// tokens because the spaces break the run. -fn atom_run(first: TokenTree, it: &mut Peekable>) -> String { - let mut s = tt_str(&first); - let mut end = first.span().end(); - while let Some(next) = it.peek() { - if matches!(next, TokenTree::Group(_)) { - break; - } - if let TokenTree::Literal(l) = next - && l.to_string().starts_with('"') - { - break; // a string literal is its own token, never glued - } - if next.span().start() != end { - break; // whitespace between tokens ends the atom - } - s.push_str(&tt_str(next)); - end = next.span().end(); - it.next(); - } - s -} diff --git a/src/lib.rs b/src/lib.rs index 016585f0e..7d20c72f8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2942,6 +2942,32 @@ pub enum Error { }, } +impl Error { + /// The primary source span this error is anchored to, if one is available. + /// + /// Handy for mapping an error back to its location in the source — e.g. + /// pointing a compile-time diagnostic at the offending token. Errors with + /// no natural location (backend, extraction, I/O-format) return `None`. + pub fn span(&self) -> Option { + match self { + Error::ParseError(e) => Some(e.0.clone()), + Error::TypeError(e) => e.span(), + Error::TypeErrors(errors) => errors.iter().find_map(TypeError::span), + Error::CheckError(_, span) + | Error::NoSuchRuleset(_, span) + | Error::CombinedRulesetError(_, span) + | Error::Pop(span) + | Error::ExpectFail(span) + | Error::SubsumeMergeError(_, span) + | Error::CommandAlreadyExists(_, span) + | Error::Shadowing(_, span, _) => Some(span.clone()), + Error::IoError(_, _, span) => Some(span.clone()), + Error::ProofError { span, .. } => Some(span.clone()), + _ => None, + } + } +} + #[cfg(test)] mod tests { use crate::constraint::SimpleTypeConstraint; diff --git a/src/typechecking.rs b/src/typechecking.rs index 39f3749c4..eaf872046 100644 --- a/src/typechecking.rs +++ b/src/typechecking.rs @@ -1241,6 +1241,38 @@ pub enum TypeError { GlobalMissingPrefix { name: String, span: Span }, } +impl TypeError { + /// The source span this type error is anchored to, if the variant carries + /// one. + pub fn span(&self) -> Option { + match self { + TypeError::Arity { expr, .. } + | TypeError::Mismatch { expr, .. } + | TypeError::InferenceFailure(expr) => Some(expr.span()), + TypeError::Unbound(_, span) + | TypeError::Ungrounded(_, span) + | TypeError::UndefinedSort(_, span) + | TypeError::UnboundFunction(_, span) + | TypeError::ProveExistsRequiresConstructor(_, span) + | TypeError::FunctionAlreadyBound(_, span) + | TypeError::SortAlreadyBound(_, span) + | TypeError::PrimitiveAlreadyBound(_, span) + | TypeError::PresortNotFound(_, span) + | TypeError::AlreadyDefined(_, span) + | TypeError::ConstructorOutputNotSort(_, span) + | TypeError::LookupInRuleDisallowed(_, span) + | TypeError::SetConstructorDisallowed(_, span) + | TypeError::NonEqsortUnion(_, span) + | TypeError::NonUnionableSort(_, span) + | TypeError::TermConstructorNoInputs(_, span) => Some(span.clone()), + TypeError::NonGlobalPrefixed { span, .. } + | TypeError::GlobalMissingPrefix { span, .. } => Some(span.clone()), + TypeError::AllAlternativeFailed(errors) => errors.iter().find_map(TypeError::span), + TypeError::FunctionTypeMismatch(..) => None, + } + } +} + #[cfg(test)] mod test { use crate::{EGraph, Error, typechecking::TypeError}; diff --git a/static-macro/Cargo.toml b/static-macro/Cargo.toml new file mode 100644 index 000000000..95ead049b --- /dev/null +++ b/static-macro/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "egglog-static" +version = { workspace = true } +edition = { workspace = true } +description = { workspace = true } +repository = { workspace = true } +keywords = { workspace = true } +license = { workspace = true } +readme = { workspace = true } + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = { workspace = true, features = ["span-locations"] } +quote = { workspace = true } +# Shared token → egglog-atom rendering (also used by `egglog-quote`), so both +# crates reconstruct atoms identically. +egglog-ast = { workspace = true, features = ["tokens"] } +# Depends on egglog so `egglog_static!` / `run_egglog_static!` can run egglog's +# parser and typechecker at macro-expansion time (compile-time validation). +# No cycle: egglog does NOT depend on this crate — it is a leaf that downstream +# crates opt into. +egglog = { path = "..", default-features = false } + +[dev-dependencies] +# The macros' expansions reference `::egglog::…`, so the integration tests +# (separate crates) need egglog at run time too. +egglog = { path = "..", default-features = false } diff --git a/static-macro/src/lib.rs b/static-macro/src/lib.rs new file mode 100644 index 000000000..2b2102727 --- /dev/null +++ b/static-macro/src/lib.rs @@ -0,0 +1,239 @@ +//! Compile-time-checked egglog programs. +//! +//! Both macros write an egglog program directly in Rust and **check it while +//! your crate compiles** — a parse or type error in the program becomes a build +//! error at the macro call site, so you never ship a program that fails to +//! typecheck. They differ in what they hand back: +//! +//! - [`egglog_static!`] → `Result, egglog::Error>` — the checked +//! program as commands, for you to run into an e-graph of your choosing (or +//! several, or to inspect). +//! - [`run_egglog_static!`] → `Result` — a fresh +//! [`EGraph`](egglog::EGraph) with the program already run into it. +//! +//! ``` +//! use egglog::EGraph; +//! use egglog_static::run_egglog_static; +//! +//! let egraph: EGraph = run_egglog_static!( +//! (datatype Math (Num i64) (Add Math Math)) +//! (rule ((= e (Add (Num a) (Num b)))) ((union e (Num (+ a b))))) +//! (let start (Add (Num 1) (Num 2))) +//! (run 1) +//! (check (= start (Num 3))) +//! ) +//! .unwrap(); +//! # let _ = egraph; +//! ``` +//! +//! The program is fully known at compile time, so there are no `#` splices of +//! Rust values. For programs built from runtime values, or checked against an +//! existing e-graph, use the quasiquote macros in the `egglog-quote` crate +//! (`egglog!`, `run_egglog!`, `expr!`, …). +//! +//! Checking runs egglog's own parser and typechecker at macro-expansion time: +//! declarations are registered and every form is resolved, but rules are **not** +//! run, so a nonterminating `(run …)` costs nothing at build time. Because a +//! resolved program holds sort handles bound to the e-graph it was checked in, +//! the check is a build-time gate only — the commands handed back are +//! *unresolved* and re-typecheck against whatever e-graph you run them in. + +use egglog_ast::tokens::atom_run; +use proc_macro::TokenStream; +use proc_macro2::{Delimiter, Span, TokenStream as TokenStream2, TokenTree}; +use quote::{quote, quote_spanned}; + +/// Write an egglog program checked at compile time; expand to the program's +/// commands. +/// +/// Returns `Result, egglog::Error>` +/// ([`Command`](egglog::ast::Command)). Expansion fails the build on parse or +/// type errors, so at run time the commands are just handed back — run them +/// into any e-graph with [`EGraph::run_program`](egglog::EGraph::run_program), +/// or inspect them. See the [crate docs](crate) for details. +/// +/// ``` +/// use egglog::{ast::Command, EGraph}; +/// use egglog_static::egglog_static; +/// +/// let program: Vec = egglog_static!( +/// (datatype Math (Num i64) (Add Math Math)) +/// (rule ((= e (Add (Num a) (Num b)))) ((union e (Num (+ a b))))) +/// ) +/// .unwrap(); +/// +/// // Run the checked program into an e-graph you control. +/// let mut egraph = EGraph::default(); +/// egraph.run_program(program).unwrap(); +/// ``` +#[proc_macro] +pub fn egglog_static(input: TokenStream) -> TokenStream { + let src = match check(input) { + Ok(src) => src, + Err(err) => return err, + }; + // Runtime: re-parse the (already-checked) source into unresolved commands. + quote! {{ + let mut __parser = ::egglog::ast::Parser::default(); + ::egglog::ast::Parser::get_program_from_string( + &mut __parser, + ::core::option::Option::None, + #src, + ) + .map_err(::egglog::Error::from) + }} + .into() +} + +/// Write an egglog program checked at compile time; expand to a fresh +/// [`EGraph`](egglog::EGraph) with the program run into it. +/// +/// Returns `Result`: expansion fails the build on parse +/// or type errors, and the `Result` reports errors that only running can raise +/// (a failed `check`, `panic`, …). See the [crate docs](crate) for details. +#[proc_macro] +pub fn run_egglog_static(input: TokenStream) -> TokenStream { + let src = match check(input) { + Ok(src) => src, + Err(err) => return err, + }; + // Runtime: build a fresh e-graph and run the (already-checked) program, + // handing back the populated e-graph. + quote! {{ + let mut __egraph = ::egglog::EGraph::default(); + ::egglog::EGraph::parse_and_run_program( + &mut __egraph, + ::core::option::Option::None, + #src, + ) + .map(move |_| __egraph) + }} + .into() +} + +/// A rendered atom or list: the byte range it occupies in the source string, +/// and the `proc_macro2` span of the token(s) it came from. Used to point a +/// diagnostic at the offending token when egglog reports an error at a byte +/// offset. +struct Segment { + start: usize, + end: usize, + span: Span, +} + +/// Render the macro input to egglog source and typecheck it at expansion time. +/// On success returns the source (to embed in the expansion); on failure +/// returns a `compile_error!` token stream to emit in place. +fn check(input: TokenStream) -> Result { + let mut src = String::new(); + let mut segments = Vec::new(); + if let Err((span, msg)) = render(input.into(), &mut src, &mut segments) { + return Err(compile_error_at(span, &msg)); + } + // Parse + typecheck in a throwaway e-graph, registering declarations but + // running nothing. + let mut egraph = egglog::EGraph::default(); + if let Err(e) = egraph.resolve_program(None, &src) { + // Point the diagnostic at the offending token, falling back to the + // whole call site if the error carries no usable location. + let span = error_span(&e, &segments).unwrap_or_else(Span::call_site); + return Err(compile_error_at(span, &format!("egglog_static!: {e}"))); + } + Ok(src) +} + +/// Map an egglog error back to the `proc_macro2` span of the token it points +/// at, via the byte offsets it carries into the rendered source. +fn error_span(err: &egglog::Error, segments: &[Segment]) -> Option { + match err.span()? { + egglog::ast::Span::Egglog(s) => map_offset(segments, s.i, s.j), + _ => None, + } +} + +/// Find the token span for the source byte range `[i, j)`: the smallest segment +/// containing the start offset, else the smallest one overlapping the range. +fn map_offset(segments: &[Segment], i: usize, j: usize) -> Option { + segments + .iter() + .filter(|s| s.start <= i && i < s.end) + .min_by_key(|s| s.end - s.start) + .or_else(|| { + segments + .iter() + .filter(|s| s.start < j && i < s.end) + .min_by_key(|s| s.end - s.start) + }) + .map(|s| s.span) +} + +/// Emit a `compile_error!` pointing at `span`. +fn compile_error_at(span: Span, msg: &str) -> TokenStream { + quote_spanned! { span => compile_error!(#msg) }.into() +} + +/// Render a token stream as egglog source text. Lists become parenthesized; +/// every other run of directly-adjacent tokens becomes one atom (mirroring +/// egglog's tokenizer), so `my-ruleset`, `:no-merge`, and `-1.0` survive as +/// single atoms while space-separated tokens stay apart. A `#` (splice) is +/// rejected — a compile-time program has no runtime values to splice. +fn render( + ts: TokenStream2, + out: &mut String, + segments: &mut Vec, +) -> Result<(), (Span, String)> { + let mut it = ts.into_iter().peekable(); + let mut first = true; + while let Some(tt) = it.next() { + if !first { + out.push(' '); + } + first = false; + match tt { + TokenTree::Group(g) => match g.delimiter() { + // A transparent (macro-metavariable) group: render inline. + Delimiter::None => render(g.stream(), out, segments)?, + _ => { + let start = out.len(); + out.push('('); + render(g.stream(), out, segments)?; + out.push(')'); + segments.push(Segment { + start, + end: out.len(), + span: g.span(), + }); + } + }, + TokenTree::Punct(ref p) if p.as_char() == '#' => { + return Err(( + p.span(), + "egglog_static! does not support `#` splices — the program \ + must be fully known at compile time" + .to_string(), + )); + } + // A double-quoted string is its own atom. + TokenTree::Literal(ref lit) if lit.to_string().starts_with('"') => { + let start = out.len(); + out.push_str(&lit.to_string()); + segments.push(Segment { + start, + end: out.len(), + span: lit.span(), + }); + } + first_tt => { + let start = out.len(); + let (atom, span) = atom_run(first_tt, &mut it); + out.push_str(&atom); + segments.push(Segment { + start, + end: out.len(), + span, + }); + } + } + } + Ok(()) +} diff --git a/static-macro/tests/basic.rs b/static-macro/tests/basic.rs new file mode 100644 index 000000000..b9a80e573 --- /dev/null +++ b/static-macro/tests/basic.rs @@ -0,0 +1,49 @@ +use egglog::EGraph; +use egglog::ast::Command; +use egglog_static::{egglog_static, run_egglog_static}; + +#[test] +fn hands_back_checked_commands_to_run_elsewhere() { + // Checked at compile time, handed back as unresolved commands, then run + // into a separately-built e-graph (they re-typecheck against it). + let program: Vec = egglog_static!( + (datatype Math (Num i64) (Add Math Math)) + (rule ((= e (Add (Num a) (Num b)))) ((union e (Num (+ a b))))) + (let start (Add (Num 1) (Num 2))) + (run 1) + (check (= start (Num 3))) + ) + .unwrap(); + + let mut egraph = EGraph::default(); + egraph.run_program(program).unwrap(); +} + +#[test] +fn builds_and_runs_a_checked_program() { + // Compile-time-checked; at run time this builds a fresh e-graph, runs the + // fold rule once, and the embedded `(check …)` confirms `start` folded. + let egraph: EGraph = run_egglog_static!( + (datatype Math (Num i64) (Add Math Math)) + (rule ((= e (Add (Num a) (Num b)))) ((union e (Num (+ a b))))) + (let start (Add (Num 1) (Num 2))) + (run 1) + (check (= start (Num 3))) + ) + .unwrap(); + let _ = egraph; +} + +#[test] +fn hyphenated_and_keyword_atoms_survive() { + // `:no-merge`, `my-ruleset`, and negative literals must round-trip through + // the token renderer as single egglog atoms. + let egraph: EGraph = run_egglog_static!( + (function f (i64) i64 :no-merge) + (ruleset my-ruleset) + (set (f 0) -1) + (rule ((= v (f x))) ((set (f (+ x 1)) v)) :ruleset my-ruleset) + ) + .unwrap(); + let _ = egraph; +} From 4809dada32dc7a15f020e7280538ccdf1b71124f Mon Sep 17 00:00:00 2001 From: oliver Date: Tue, 14 Jul 2026 21:03:33 -0700 Subject: [PATCH 09/10] static macro tests --- src/typechecking.rs | 17 ++ static-macro/src/lib.rs | 411 ++++++++++++++++++++++++++++++++---- static-macro/tests/basic.rs | 43 +++- 3 files changed, 424 insertions(+), 47 deletions(-) diff --git a/src/typechecking.rs b/src/typechecking.rs index eaf872046..42494dcdf 100644 --- a/src/typechecking.rs +++ b/src/typechecking.rs @@ -1296,4 +1296,21 @@ mod test { _ => panic!("Expected arity mismatch, got: {res:?}"), } } + + #[test] + fn error_span_locates_the_offending_source() { + use crate::ast::Span; + let mut egraph = EGraph::default(); + // `Nonexistent` is not a declared sort. + let err = egraph + .resolve_program(None, "(relation r (Nonexistent))") + .unwrap_err(); + match err.span() { + Some(Span::Egglog(s)) => { + let quoted = &s.file.contents[s.i..s.j]; + assert!(quoted.contains("Nonexistent"), "span text: {quoted}"); + } + _ => panic!("expected an egglog span from {err:?}"), + } + } } diff --git a/static-macro/src/lib.rs b/static-macro/src/lib.rs index 2b2102727..2c781d9ef 100644 --- a/static-macro/src/lib.rs +++ b/static-macro/src/lib.rs @@ -1,6 +1,6 @@ //! Compile-time-checked egglog programs. //! -//! Both macros write an egglog program directly in Rust and **check it while +//! These macros write an egglog program directly in Rust and **check it while //! your crate compiles** — a parse or type error in the program becomes a build //! error at the macro call site, so you never ship a program that fails to //! typecheck. They differ in what they hand back: @@ -11,6 +11,11 @@ //! - [`run_egglog_static!`] → `Result` — a fresh //! [`EGraph`](egglog::EGraph) with the program already run into it. //! +//! For egglog split across crates, [`egglog_header!`] declares a reusable schema +//! (checked once, where it's declared) as an exported handle; then list the +//! schema names in [`egglog_static!`] — `egglog_static!(math, seen; )` +//! — to check a fragment against them. +//! //! ``` //! use egglog::EGraph; //! use egglog_static::run_egglog_static; @@ -31,16 +36,13 @@ //! existing e-graph, use the quasiquote macros in the `egglog-quote` crate //! (`egglog!`, `run_egglog!`, `expr!`, …). //! -//! Checking runs egglog's own parser and typechecker at macro-expansion time: -//! declarations are registered and every form is resolved, but rules are **not** -//! run, so a nonterminating `(run …)` costs nothing at build time. Because a -//! resolved program holds sort handles bound to the e-graph it was checked in, -//! the check is a build-time gate only — the commands handed back are -//! *unresolved* and re-typecheck against whatever e-graph you run them in. +//! Checking does not run rules, so a nonterminating `(run …)` costs nothing at +//! build time. The commands handed back are unresolved and re-typecheck against +//! whatever e-graph you run them in. use egglog_ast::tokens::atom_run; use proc_macro::TokenStream; -use proc_macro2::{Delimiter, Span, TokenStream as TokenStream2, TokenTree}; +use proc_macro2::{Delimiter, Ident, Span, TokenStream as TokenStream2, TokenTree}; use quote::{quote, quote_spanned}; /// Write an egglog program checked at compile time; expand to the program's @@ -66,23 +68,68 @@ use quote::{quote, quote_spanned}; /// let mut egraph = EGraph::default(); /// egraph.run_program(program).unwrap(); /// ``` +/// +/// # Against schemas from elsewhere +/// +/// Prefix the program with a list of [`egglog_header!`] schema names and a `;` +/// to check it with those declarations in scope (they're in scope but not +/// returned — only the program's commands come back). This is how egglog split +/// across crates typechecks: each schema is declared once with `egglog_header!`, +/// and every fragment lists the schemas it needs. +/// +/// ``` +/// # use egglog::ast::Command; +/// # use egglog_static::{egglog_header, egglog_static}; +/// egglog_header!(math (datatype Math (Num i64) (Add Math Math))); +/// egglog_header!(seen (relation seen (i64))); +/// +/// // The fragment uses `Add`/`Num` (from `math`) and `seen` (from `seen`); +/// // list the schemas in dependency order. +/// let fragment: Vec = egglog_static!(math, seen; +/// (rule ((= e (Add (Num a) (Num b)))) ((seen a))) +/// ) +/// .unwrap(); +/// # let _ = fragment; +/// ``` #[proc_macro] pub fn egglog_static(input: TokenStream) -> TokenStream { - let src = match check(input) { - Ok(src) => src, - Err(err) => return err, + let input = TokenStream2::from(input); + + // Internal form emitted by the schema machinery (`egglog_header!` + + // `egglog_static!(a, b; …)`): `@egglog_checked [ ] ` + // checks the program with the pooled declarations in scope but returns only + // the program's commands. Not written by hand — if you have declarations, + // just make them part of the program. + if let Some(rest) = strip_checked_marker(&input) { + let (declarations, program) = match split_declarations(rest) { + Ok(split) => split, + Err((span, msg)) => return compile_error_at(span, &msg), + }; + return match check_with(declarations, program) { + Ok(program_src) => emit_commands(&program_src), + Err((span, msg)) => compile_error_at(span, &msg), + }; + } + + let (headers, program) = match split_header_prefix(input) { + Ok(split) => split, + Err((span, msg)) => return compile_error_at(span, &msg), }; - // Runtime: re-parse the (already-checked) source into unresolved commands. - quote! {{ - let mut __parser = ::egglog::ast::Parser::default(); - ::egglog::ast::Parser::get_program_from_string( - &mut __parser, - ::core::option::Option::None, - #src, - ) - .map_err(::egglog::Error::from) - }} - .into() + + // `

,

, … ; `: hand off to the first header in compose mode; + // the header macros pool their declarations and end back here (inline form). + if let Some((first, rest)) = headers.split_first() { + return quote! { + #first!( @egglog_compose {} [ #(#rest)* ] { #program } ) + } + .into(); + } + + // ``: check the whole program on its own. + match check(program) { + Ok(src) => emit_commands(&src), + Err((span, msg)) => compile_error_at(span, &msg), + } } /// Write an egglog program checked at compile time; expand to a fresh @@ -93,9 +140,9 @@ pub fn egglog_static(input: TokenStream) -> TokenStream { /// (a failed `check`, `panic`, …). See the [crate docs](crate) for details. #[proc_macro] pub fn run_egglog_static(input: TokenStream) -> TokenStream { - let src = match check(input) { + let src = match check(input.into()) { Ok(src) => src, - Err(err) => return err, + Err((span, msg)) => return compile_error_at(span, &msg), }; // Runtime: build a fresh e-graph and run the (already-checked) program, // handing back the populated e-graph. @@ -111,6 +158,93 @@ pub fn run_egglog_static(input: TokenStream) -> TokenStream { .into() } +/// Define a reusable, compile-time-checked egglog *header* (schema). +/// +/// `egglog_header!( )` typechecks `` (sorts, +/// constructors, functions, …) **here** — so a broken schema is a build error at +/// this call, not at each use — then generates a schema *handle* macro ``. +/// Pass that name to [`egglog_static!`] to check a program against the schema: +/// `egglog_static!(; )`, or list several with +/// `egglog_static!(a, b; )`. You don't call `!` directly. +/// +/// The handle is `#[macro_export]`ed, so a schema can be declared in one crate +/// and used from others (`use my_schema_crate::math;`). Crates that use it need +/// `egglog-static` and `egglog` as dependencies. +/// +/// ``` +/// use egglog::ast::Command; +/// use egglog_static::{egglog_header, egglog_static}; +/// +/// // Declare the schema once (checked right here). +/// egglog_header!(math +/// (datatype Math (Num i64) (Add Math Math)) +/// (function lower (Math) i64 :no-merge) +/// ); +/// +/// // Check fragments against it via `egglog_static!`. +/// let fragment: Vec = egglog_static!(math; +/// (rule ((= e (Add (Num a) (Num b)))) ((union e (Num (+ a b))))) +/// ) +/// .unwrap(); +/// # let _ = fragment; +/// ``` +#[proc_macro] +pub fn egglog_header(input: TokenStream) -> TokenStream { + let mut it = TokenStream2::from(input).into_iter(); + let name = match it.next() { + Some(TokenTree::Ident(id)) => id, + other => { + let span = other.map(|t| t.span()).unwrap_or_else(Span::call_site); + return compile_error_at( + span, + "egglog_header! expects a macro name first, e.g. \ + `egglog_header!(math (datatype …))`", + ); + } + }; + let declarations: TokenStream2 = it.collect(); + + // Typecheck the declarations here, so a broken schema errors at the header. + let mut src = String::new(); + let mut segments = Vec::new(); + if let Err((span, msg)) = render(declarations.clone(), &mut src, &mut segments) { + return compile_error_at(span, &msg); + } + if let Err((span, msg)) = typecheck(&src, &segments) { + return compile_error_at(span, &msg); + } + + // The handle is driven by `egglog_static!(a, b, …; …)`: its `@egglog_compose` + // arms pool each schema's declarations into an accumulator, then hand off to + // the next schema, finally landing in `egglog_static!([ pooled ] program)`. + // Calling the handle directly is a mistake, so the last arm says so. (`$…` + // below is emitted verbatim into the generated `macro_rules!`.) + let doc = format!( + "egglog schema handle generated by `egglog_header!`. Pass it to \ + `egglog_static!({name}; )` to check a program against this schema." + ); + let direct_use = format!( + "`{name}!` is an egglog schema handle — use `egglog_static!({name}; )` \ + to check a program against it" + ); + quote! { + #[doc = #doc] + #[macro_export] + macro_rules! #name { + (@egglog_compose { $($__acc:tt)* } [] { $($__prog:tt)* }) => { + ::egglog_static::egglog_static!( @egglog_checked [ $($__acc)* #declarations ] $($__prog)* ) + }; + (@egglog_compose { $($__acc:tt)* } [ $__next:ident $($__rest:ident)* ] { $($__prog:tt)* }) => { + $__next!( @egglog_compose { $($__acc)* #declarations } [ $($__rest)* ] { $($__prog)* } ) + }; + ($($__other:tt)*) => { + ::core::compile_error!(#direct_use) + }; + } + } + .into() +} + /// A rendered atom or list: the byte range it occupies in the source string, /// and the `proc_macro2` span of the token(s) it came from. Used to point a /// diagnostic at the offending token when egglog reports an error at a byte @@ -121,25 +255,121 @@ struct Segment { span: Span, } -/// Render the macro input to egglog source and typecheck it at expansion time. -/// On success returns the source (to embed in the expansion); on failure -/// returns a `compile_error!` token stream to emit in place. -fn check(input: TokenStream) -> Result { +/// A diagnostic: a span to point at and a message. Converted to a +/// `compile_error!` only at the proc-macro boundary, so the checking logic +/// stays unit-testable. +type Fail = (Span, String); + +/// Render the macro input to egglog source and typecheck it at expansion time, +/// returning the source to embed in the expansion. +fn check(input: TokenStream2) -> Result { let mut src = String::new(); let mut segments = Vec::new(); - if let Err((span, msg)) = render(input.into(), &mut src, &mut segments) { - return Err(compile_error_at(span, &msg)); - } - // Parse + typecheck in a throwaway e-graph, registering declarations but - // running nothing. + render(input, &mut src, &mut segments)?; + typecheck(&src, &segments)?; + Ok(src) +} + +/// Like [`check`], but the program is typechecked with `declarations` (a schema +/// — sorts, constructors, functions, …) in scope. Only the program's source is +/// returned; the declarations aren't, so a fragment in one crate can be checked +/// against types declared in another without re-emitting them. +fn check_with(declarations: TokenStream2, program: TokenStream2) -> Result { + let mut src = String::new(); + let mut segments = Vec::new(); + render(declarations, &mut src, &mut segments)?; + src.push('\n'); + let program_start = src.len(); + render(program, &mut src, &mut segments)?; + typecheck(&src, &segments)?; + Ok(src[program_start..].to_string()) +} + +/// Parse + typecheck `src` in a throwaway e-graph (registering declarations but +/// running nothing). On failure, points at the offending token, falling back to +/// the call site if the error has no usable location. +fn typecheck(src: &str, segments: &[Segment]) -> Result<(), Fail> { let mut egraph = egglog::EGraph::default(); - if let Err(e) = egraph.resolve_program(None, &src) { - // Point the diagnostic at the offending token, falling back to the - // whole call site if the error carries no usable location. - let span = error_span(&e, &segments).unwrap_or_else(Span::call_site); - return Err(compile_error_at(span, &format!("egglog_static!: {e}"))); + if let Err(e) = egraph.resolve_program(None, src) { + let span = error_span(&e, segments).unwrap_or_else(Span::call_site); + return Err((span, format!("egglog_static!: {e}"))); + } + Ok(()) +} + +/// Split an optional leading `h1, h2, … ;` header-name list from the program +/// that follows. With no top-level `;`, there are no headers and the whole +/// input is the program. +fn split_header_prefix(input: TokenStream2) -> Result<(Vec, TokenStream2), Fail> { + let has_semi = input + .clone() + .into_iter() + .any(|tt| matches!(&tt, TokenTree::Punct(p) if p.as_char() == ';')); + if !has_semi { + return Ok((Vec::new(), input)); + } + let mut headers = Vec::new(); + let mut it = input.into_iter(); + for tt in it.by_ref() { + match tt { + TokenTree::Punct(ref p) if p.as_char() == ';' => break, + TokenTree::Punct(ref p) if p.as_char() == ',' => {} + TokenTree::Ident(id) => headers.push(id), + other => { + return Err(( + other.span(), + "expected header names (identifiers) before `;`, e.g. \ + `egglog_static!(math, seen; )`" + .to_string(), + )); + } + } + } + Ok((headers, it.collect())) +} + +/// If the input begins with the internal `@egglog_checked` marker, return the +/// tokens after it; otherwise `None`. +fn strip_checked_marker(input: &TokenStream2) -> Option { + let mut it = input.clone().into_iter(); + match (it.next(), it.next()) { + (Some(TokenTree::Punct(p)), Some(TokenTree::Ident(id))) + if p.as_char() == '@' && id == "egglog_checked" => + { + Some(it.collect()) + } + _ => None, + } +} + +/// Expand to `Result, egglog::Error>` that re-parses `src` (already +/// checked at expansion time) into unresolved commands. +fn emit_commands(src: &str) -> TokenStream { + quote! {{ + let mut __parser = ::egglog::ast::Parser::default(); + ::egglog::ast::Parser::get_program_from_string( + &mut __parser, + ::core::option::Option::None, + #src, + ) + .map_err(::egglog::Error::from) + }} + .into() +} + +/// Split a leading `[ … ]` declarations group from the program that follows +/// (the internal `@egglog_checked` form). +fn split_declarations(input: TokenStream2) -> Result<(TokenStream2, TokenStream2), Fail> { + let mut it = input.into_iter(); + match it.next() { + Some(TokenTree::Group(g)) if g.delimiter() == Delimiter::Bracket => { + Ok((g.stream(), it.collect())) + } + other => { + let span = other.map(|t| t.span()).unwrap_or_else(Span::call_site); + Err((span, "expected a `[ … ]` declarations group".to_string())) + } } - Ok(src) } /// Map an egglog error back to the `proc_macro2` span of the token it points @@ -167,9 +397,11 @@ fn map_offset(segments: &[Segment], i: usize, j: usize) -> Option { .map(|s| s.span) } -/// Emit a `compile_error!` pointing at `span`. +/// Emit a `compile_error!` pointing at `span`. Brace-delimited so it's valid in +/// both expression position (the `egglog_static!` family) and item position +/// (`egglog_header!`, which expands to a `macro_rules!` item). fn compile_error_at(span: Span, msg: &str) -> TokenStream { - quote_spanned! { span => compile_error!(#msg) }.into() + quote_spanned! { span => compile_error! { #msg } }.into() } /// Render a token stream as egglog source text. Lists become parenthesized; @@ -177,11 +409,7 @@ fn compile_error_at(span: Span, msg: &str) -> TokenStream { /// egglog's tokenizer), so `my-ruleset`, `:no-merge`, and `-1.0` survive as /// single atoms while space-separated tokens stay apart. A `#` (splice) is /// rejected — a compile-time program has no runtime values to splice. -fn render( - ts: TokenStream2, - out: &mut String, - segments: &mut Vec, -) -> Result<(), (Span, String)> { +fn render(ts: TokenStream2, out: &mut String, segments: &mut Vec) -> Result<(), Fail> { let mut it = ts.into_iter().peekable(); let mut first = true; while let Some(tt) = it.next() { @@ -237,3 +465,94 @@ fn render( } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn ts(s: &str) -> TokenStream2 { + s.parse().unwrap() + } + + fn render_str(input: &str) -> Result { + let mut src = String::new(); + let mut segments = Vec::new(); + render(ts(input), &mut src, &mut segments)?; + Ok(src) + } + + #[test] + fn render_glues_adjacent_tokens_into_egglog_atoms() { + // `?x`, `:no-merge`, `my-ruleset`, and `-1` must survive as single atoms. + assert_eq!(render_str("(f ?x)").unwrap(), "(f ?x)"); + assert_eq!( + render_str("(function f (i64) i64 :no-merge)").unwrap(), + "(function f (i64) i64 :no-merge)" + ); + assert_eq!( + render_str("(ruleset my-ruleset)").unwrap(), + "(ruleset my-ruleset)" + ); + assert_eq!(render_str("(set (f 0) -1)").unwrap(), "(set (f 0) -1)"); + } + + #[test] + fn render_rejects_splices() { + let (_span, msg) = render_str("(Num #x)").unwrap_err(); + assert!(msg.contains("splice"), "{msg}"); + } + + #[test] + fn check_accepts_valid_and_reports_type_errors() { + assert!(check(ts("(datatype Math (Num i64))")).is_ok()); + + let (_span, msg) = check(ts("(relation r (Nonexistent))")).unwrap_err(); + assert!(msg.contains("Undefined sort Nonexistent"), "{msg}"); + } + + #[test] + fn check_with_uses_declarations_in_scope() { + let decls = ts("(datatype Math (Num i64) (Add Math Math))"); + + let ok = ts("(rule ((= e (Add (Num a) (Num b)))) ((union e (Num (+ a b)))))"); + assert!(check_with(decls.clone(), ok).is_ok()); + + // `Mul` is in neither the declarations nor the fragment -> error. + let bad = ts("(rule ((= e (Add (Num a) (Num b)))) ((union e (Mul (Num 1) (Num 2)))))"); + let (_span, msg) = check_with(decls, bad).unwrap_err(); + assert!(msg.contains("Mul"), "{msg}"); + } + + #[test] + fn header_prefix_splits_on_semicolon() { + let (headers, program) = split_header_prefix(ts("a, b, c; (run 1)")).unwrap(); + let names: Vec<_> = headers.iter().map(|i| i.to_string()).collect(); + assert_eq!(names, ["a", "b", "c"]); + assert!(!program.is_empty()); + + // No top-level `;` -> no headers, whole input is the program. + let (none, _) = split_header_prefix(ts("(datatype Math (Num i64))")).unwrap(); + assert!(none.is_empty()); + } + + #[test] + fn checked_marker_is_recognized() { + assert!(strip_checked_marker(&ts("@egglog_checked [x] (y)")).is_some()); + assert!(strip_checked_marker(&ts("(datatype Math (Num i64))")).is_none()); + } + + #[test] + fn error_offset_maps_to_the_innermost_token() { + // Render (no reformatting here), then map a source byte offset back to a + // token span: offset of `y` should select the `y` atom, not the list. + let mut src = String::new(); + let mut segments = Vec::new(); + render(ts("(Add x y)"), &mut src, &mut segments).unwrap(); + assert_eq!(src, "(Add x y)"); + + let y = src.find('y').unwrap(); + let span = map_offset(&segments, y, y + 1).expect("offset should map to a token"); + // `src` equals the input here, so the byte offset is the token's column. + assert_eq!(span.start().column, y); + } +} diff --git a/static-macro/tests/basic.rs b/static-macro/tests/basic.rs index b9a80e573..223d13cd6 100644 --- a/static-macro/tests/basic.rs +++ b/static-macro/tests/basic.rs @@ -1,6 +1,47 @@ use egglog::EGraph; use egglog::ast::Command; -use egglog_static::{egglog_static, run_egglog_static}; +use egglog_static::{egglog_header, egglog_static, run_egglog_static}; + +// Two independent, self-contained schemas, as if declared in (and exported +// from) separate crates. Each is typechecked at its own `egglog_header!` site. +egglog_header!(math_schema + (datatype Math (Num i64) (Add Math Math)) +); +egglog_header!(seen_schema + (relation seen (i64)) +); + +#[test] +fn single_header_checks_a_fragment() { + let fragment: Vec = egglog_static!(math_schema; + (rule ((= e (Add (Num a) (Num b)))) ((union e (Num (+ a b))))) + ) + .unwrap(); + let mut egraph = EGraph::default(); + egraph + .parse_and_run_program(None, "(datatype Math (Num i64) (Add Math Math))") + .unwrap(); + egraph.run_program(fragment).unwrap(); +} + +#[test] +fn composed_headers_pool_their_declarations() { + // The fragment uses `Add`/`Num` (from `math_schema`) *and* `seen` (from + // `seen_schema`) — it only typechecks with both schemas in scope. + let fragment: Vec = egglog_static!(math_schema, seen_schema; + (rule ((= e (Add (Num a) (Num b)))) ((seen a))) + ) + .unwrap(); + + let mut egraph = EGraph::default(); + egraph + .parse_and_run_program( + None, + "(datatype Math (Num i64) (Add Math Math)) (relation seen (i64))", + ) + .unwrap(); + egraph.run_program(fragment).unwrap(); +} #[test] fn hands_back_checked_commands_to_run_elsewhere() { From f12ccf69138321984291e954a0d7941a92a0982a Mon Sep 17 00:00:00 2001 From: oliver Date: Wed, 15 Jul 2026 16:35:34 +0000 Subject: [PATCH 10/10] egglog_header!: emit _schema() for the schema's runtime commands egglog_header! now also generates `pub fn _schema() -> Result, egglog::Error>` returning the header's declarations as commands. A schema can then be declared once (the header) and used for BOTH compile-time checking (the generated macro) and runtime emission (the fn), instead of being restated. Co-Authored-By: Claude Opus 4.8 (1M context) --- static-macro/src/lib.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/static-macro/src/lib.rs b/static-macro/src/lib.rs index 2c781d9ef..8ba8339e1 100644 --- a/static-macro/src/lib.rs +++ b/static-macro/src/lib.rs @@ -227,6 +227,14 @@ pub fn egglog_header(input: TokenStream) -> TokenStream { "`{name}!` is an egglog schema handle — use `egglog_static!({name}; )` \ to check a program against it" ); + // Alongside the checking macro, emit `_schema()` returning the schema's + // own commands, so the schema can be *run* into an e-graph from a single + // source of truth (the header) rather than being restated. + let schema_fn = Ident::new(&format!("{name}_schema"), name.span()); + let schema_doc = format!( + "The declarations of the `{name}` egglog schema, as commands to run into \ + an e-graph. Pair with `egglog_static!({name}; …)`-checked fragments." + ); quote! { #[doc = #doc] #[macro_export] @@ -241,6 +249,19 @@ pub fn egglog_header(input: TokenStream) -> TokenStream { ::core::compile_error!(#direct_use) }; } + + #[doc = #schema_doc] + #[allow(dead_code)] + pub fn #schema_fn() + -> ::core::result::Result<::std::vec::Vec<::egglog::ast::Command>, ::egglog::Error> { + let mut __parser = ::egglog::ast::Parser::default(); + ::egglog::ast::Parser::get_program_from_string( + &mut __parser, + ::core::option::Option::None, + #src, + ) + .map_err(::egglog::Error::from) + } } .into() }