Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 82 additions & 10 deletions crates/wingfoil/src/quote.rs
Original file line number Diff line number Diff line change
Expand Up @@ -271,25 +271,97 @@ macro_rules! func {
/// trade. `Span::source_text` would settle it, but joining spans across an
/// expression is nightly-only.
///
/// # One rule for all three vocabularies
///
/// Everything a generator needs recorded is marked in the argument list:
///
/// | Write | Records |
/// |---|---|
/// | `map(\|x\| ..)` | the closure |
/// | `map([fee] move \|x\| ..)` | the closure **and** the captured value |
/// | `ticker(cfg period)` | the data config |
/// | `fold(cfg 0u64, \|a, v\| ..)` | both |
/// | `join(&other, \|x, y\| ..)` | the closure; `&other` is an edge, left alone |
///
/// So the whole per-instrument shape the generator exists for reads without
/// leaving the macro:
///
/// ```
/// use wingfoil::prelude::*;
/// use wingfoil::quoted;
/// # use std::time::Duration;
/// # struct Instrument { period: Duration, fee: f64 }
/// # let cfg = [Instrument { period: Duration::from_millis(1), fee: 2.5 }];
/// # let g = GraphBuilder::new();
/// for inst in &cfg {
/// let fee = inst.fee;
/// let ticks = quoted!(g => ticker(cfg inst.period)).count();
/// let px = quoted!(ticks => map(|n: &u64| *n as f64));
/// let net = quoted!(px => map([fee] move |p: &f64| p - fee));
/// assert!(net.src().is_some());
/// }
/// ```
///
/// A `cfg`-marked argument is **cloned** into the call so the original can be
/// rendered — every [`EmitLiteral`](crate::emit::EmitLiteral) type is `Clone`,
/// and for the common `Copy` ones (`Duration`, numbers) the clone is free.
///
/// # What it does not do
///
/// Data configs still need [`with_cfg`](crate::fluent::Stream::with_cfg)
/// explicitly — recording them here would mean either evaluating the argument
/// twice or silently requiring `Clone` of it, and a bound that appears from
/// inside a macro is worse than a second method call.
/// A `&stream` edge is passed through untouched — there is nothing to record
/// about an edge, the graph already knows it.
///
/// Captures still need [`func!`] with an explicit list; a capturing closure
/// written inline here records a body that will not resolve in an artifact.
/// Nothing here detects an *undeclared* capture. `map(move |p| p - fee)` with
/// no `[fee]` records a body that will not resolve in an artifact; it surfaces
/// as a generator refusal, or a pass-2 compile error if the name happens to
/// resolve to something else.
#[macro_export]
macro_rules! quoted {
// `recv => method(closure)`
($recv:expr => $m:ident($f:expr)) => {{
// Arms are ordered most-specific first, and each is discriminated by a
// *literal* token (`cfg`, `[`) before any fragment is parsed. That matters:
// `macro_rules!` falls through cleanly on a literal mismatch, but a failed
// `$x:expr` parse is a hard error, not a fallthrough — so an arm that could
// swallow another's input must come second.

// --- data config + captured closure -------------------------------------
($recv:expr => $m:ident(cfg $a:expr, [$($cap:ident),+ $(,)?] $f:expr)) => {{
let __wf_c = $a;
let __wf_q = $crate::func!([$($cap),+] $f);
$recv.$m(__wf_c.clone(), __wf_q.f)
.with_cfg(&__wf_c)
.with_src(&__wf_q)
}};
// --- data config + closure ----------------------------------------------
($recv:expr => $m:ident(cfg $a:expr, $f:expr)) => {{
let __wf_c = $a;
let __wf_q = $crate::func!($f);
$recv.$m(__wf_q.f).with_src(&__wf_q)
$recv.$m(__wf_c.clone(), __wf_q.f)
.with_cfg(&__wf_c)
.with_src(&__wf_q)
}};
// --- data config only (a source: `ticker`, `limit`) ---------------------
($recv:expr => $m:ident(cfg $a:expr)) => {{
let __wf_c = $a;
$recv.$m(__wf_c.clone()).with_cfg(&__wf_c)
}};
// --- leading argument + captured closure --------------------------------
($recv:expr => $m:ident($a:expr, [$($cap:ident),+ $(,)?] $f:expr)) => {{
let __wf_q = $crate::func!([$($cap),+] $f);
$recv.$m($a, __wf_q.f).with_src(&__wf_q)
}};
// `recv => method(arg, closure)` — a stream edge or a seed, then the body.
// --- leading argument + closure (a stream edge for `join`, a seed) ------
($recv:expr => $m:ident($a:expr, $f:expr)) => {{
let __wf_q = $crate::func!($f);
$recv.$m($a, __wf_q.f).with_src(&__wf_q)
}};
// --- captured closure ---------------------------------------------------
($recv:expr => $m:ident([$($cap:ident),+ $(,)?] $f:expr)) => {{
let __wf_q = $crate::func!([$($cap),+] $f);
$recv.$m(__wf_q.f).with_src(&__wf_q)
}};
// --- closure ------------------------------------------------------------
($recv:expr => $m:ident($f:expr)) => {{
let __wf_q = $crate::func!($f);
$recv.$m(__wf_q.f).with_src(&__wf_q)
}};
}
19 changes: 14 additions & 5 deletions crates/wingfoil/tests/codegen_emission.rs
Original file line number Diff line number Diff line change
Expand Up @@ -522,16 +522,25 @@ wingfoil::nitro! {
#[test]
fn a_per_instrument_desk_generates_with_its_own_parameters() {
struct Instrument {
tick: Duration,
/// The synthetic clock's period. Named `period`, not `tick`: in a
/// trading context a "tick" is a price increment or a single market
/// update, neither of which is a `Duration`.
///
/// It exists only because `ticker` stands in for a market-data feed —
/// a real instrument config carries a symbol and a subscription, and
/// data arrives when it arrives. `external`/`channel` sources are still
/// excluded from compiled graphs, so the placeholder is load-bearing
/// for the test rather than representative of the domain.
period: Duration,
fee: f64,
}
let cfg = [
Instrument {
tick: Duration::from_millis(1),
period: Duration::from_millis(1),
fee: 2.5,
},
Instrument {
tick: Duration::from_millis(5),
period: Duration::from_millis(5),
fee: 1.0,
},
];
Expand All @@ -542,8 +551,8 @@ fn a_per_instrument_desk_generates_with_its_own_parameters() {
.iter()
.map(|inst| {
let px = g
.ticker(inst.tick)
.with_cfg(&inst.tick)
.ticker(inst.period)
.with_cfg(&inst.period)
.count()
.map(to_px.f)
.with_src(&to_px);
Expand Down
146 changes: 146 additions & 0 deletions crates/wingfoil/tests/quoted_macro.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,149 @@ fn it_is_not_coupled_to_any_particular_op() {
let inspected = quoted!(ticks => inspect(|_v: &u64| ()));
assert_eq!(Some("|_v: &u64| ()"), inspected.src().as_deref());
}

// ---------------------------------------------------------------------------
// The full argument grammar: `cfg` marks a data config, `[..]` a capture list.
// ---------------------------------------------------------------------------

/// `cfg` on a source's argument records the data config, so `ticker(cfg p)`
/// replaces the `ticker(p).with_cfg(&p)` repetition — where the value was
/// written twice and either could be forgotten or, worse, changed alone.
#[test]
fn cfg_marks_a_data_config() {
let g = GraphBuilder::new();
let ticks = quoted!(g => ticker(cfg PERIOD));

assert_eq!(
Some("::core::time::Duration::new(0u64, 1000000u32)"),
ticks.cfg_src().as_deref()
);
assert_eq!(None, ticks.src(), "a ticker has no closure");
}

/// A capture list inside `quoted!` forwards to `func!`'s tier-2 arm, so the
/// per-instrument parameter case needs no manual `func!` + `with_src`.
#[test]
fn a_capture_list_is_forwarded_to_func() {
let g = GraphBuilder::new();
let fee = 2.5f64;
let px = g.ticker(PERIOD).count().map(|n: &u64| *n as f64);
let net = quoted!(px => map([fee] move |p: &f64| p - fee));

assert_eq!(
Some("{ let fee = 2.5f64; move |p: &f64| p - fee }"),
net.src().as_deref()
);
}

/// Multiple captures, and the manual form it replaces, recording identically.
#[test]
fn captures_match_the_manual_form() {
let lo = 1u64;
let hi = 9u64;

let manual = {
let g = GraphBuilder::new();
let q = func!([lo, hi] move |v: &u64| v.clamp(&lo, &hi).to_owned());
g.ticker(PERIOD).count().map(q.f).with_src(&q).src()
};
let shorthand = {
let g = GraphBuilder::new();
let ticks = g.ticker(PERIOD).count();
quoted!(ticks => map([lo, hi] move |v: &u64| v.clamp(&lo, &hi).to_owned())).src()
};

assert_eq!(manual, shorthand);
assert!(
shorthand
.unwrap()
.starts_with("{ let lo = 1u64; let hi = 9u64;")
);
}

/// `cfg` and a closure together — `fold`'s shape, both recorded from one call.
#[test]
fn cfg_and_a_closure_are_both_recorded() {
let g = GraphBuilder::new();
let ticks = g.ticker(PERIOD).count();
let total = quoted!(ticks => fold(cfg 0u64, |acc: &mut u64, v: &u64| *acc += v));

assert_eq!(Some("0u64"), total.cfg_src().as_deref());
assert_eq!(
Some("|acc: &mut u64, v: &u64| *acc += v"),
total.src().as_deref()
);
}

/// A `&stream` edge stays an edge: nothing is recorded about it, because the
/// graph already knows its edges.
#[test]
fn a_stream_edge_is_passed_through_untouched() {
let g = GraphBuilder::new();
let a = g.ticker(PERIOD).count();
let b = quoted!(a => map(|i: &u64| i * 10));
let joined = quoted!(a => join(&b, |x: &u64, y: &u64| x + y));

assert_eq!(Some("|x: &u64, y: &u64| x + y"), joined.src().as_deref());
assert_eq!(None, joined.cfg_src(), "an edge is not a data config");
}

/// The whole per-instrument shape without leaving the macro — the case the
/// generator exists for, and the one that previously needed all three
/// vocabularies by hand.
#[test]
fn a_per_instrument_leg_needs_only_this_macro() {
struct Instrument {
/// The synthetic clock's period. Named `period`, not `tick`: in a
/// trading context a "tick" is a price increment or a single market
/// update, neither of which is a `Duration`.
///
/// It exists only because `ticker` stands in for a market-data feed —
/// a real instrument config carries a symbol and a subscription, and
/// data arrives when it arrives. `external`/`channel` sources are still
/// excluded from compiled graphs, so the placeholder is load-bearing
/// for the test rather than representative of the domain.
period: Duration,
fee: f64,
}
let cfg = [
Instrument {
period: Duration::from_millis(1),
fee: 2.5,
},
Instrument {
period: Duration::from_millis(5),
fee: 1.0,
},
];

let g = GraphBuilder::new();
for inst in &cfg {
let fee = inst.fee;
let ticks = quoted!(g => ticker(cfg inst.period)).count();
let px = quoted!(ticks => map(|n: &u64| *n as f64 * 100.0));
let _net = quoted!(px => map([fee] move |p: &f64| p - fee));
}

// Every node that needs recording has it: both tickers, both maps, both
// fee legs. `count` needs nothing.
let nodes = g.describe();
let unrecorded: Vec<_> = nodes
.iter()
.filter(|n| n.takes_closure_cfg && n.src.is_none())
.collect();
assert!(unrecorded.is_empty(), "unrecorded closures: {unrecorded:?}");
assert_eq!(
2,
nodes.iter().filter(|n| n.cfg_src.is_some()).count(),
"both ticker periods recorded"
);
assert_eq!(
2,
nodes
.iter()
.filter(|n| n.src.as_deref().is_some_and(|s| s.contains("let fee =")))
.count(),
"both fees frozen"
);
}