Skip to main content

max / quasi

Give a declared shape a twin that evaluates with no request A shape marked `#[staged]` gets a second function beside it: the same description with every value a sentinel, every guard a bool a plan chooses, and every loop running as many times as the plan says. Handing its result to the ordinary renderer produces the screen's markup with sentinels where the data would be, which is the residual a request later fills. Nothing here emits Rust. `symbolic` rewrites the parsed declaration into another declaration and hands that to `emit`, so the twin is built by the same code that builds the shape. That is the point rather than a convenience: the residual's literals have to be the renderer's own output or the staged path is a second renderer wearing the first one's name, and a second emitter would be that mistake one level up, drifting one production at a time. The spike could not do the branch case. It derives a residual by comparing two renders, so a conditional would have to be recovered by diffing, and its own module doc names 56 `if` sites as the size of the problem. A declaration has no `if`. It has guards the macro parsed, so the plan answers them and nothing about the output's shape is guessed. Two things found by building it. A hole's number is shape-local, so two tables reached by two includes both claim hole 0; a sentinel now carries the scope it was read at and an include enters its own. And a sentinel is a String, so a value hole in a position that is not one will not compile -- which is why the flag is opt-in and a shape nobody has looked at keeps the runtime renderer.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01P8ostB2UmZJGj5WjSHRSot
Author: Max Johnson <me@maxj.phd> · 2026-09-05 16:28 UTC
Signed with PGP, not checked
Commit: e49177c785794cc0c69d8c621ff183a63e7838b7
Parent: e1d1c32
10 files changed, +933 insertions, -7 deletions
M Cargo.lock +2 -2
@@ -3526,7 +3526,7 @@
3526 3526
3527 3527 [[package]]
3528 3528 name = "quasi-declare"
3529 - version = "0.1.7"
3529 + version = "0.1.8"
3530 3530 dependencies = [
3531 3531 "proc-macro2",
3532 3532 "quote",
@@ -3565,7 +3565,7 @@
3565 3565
3566 3566 [[package]]
3567 3567 name = "quasi-router"
3568 - version = "0.101.14"
3568 + version = "0.101.15"
3569 3569 dependencies = [
3570 3570 "makeover-layout",
3571 3571 ]
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "quasi-declare"
3 - version = "0.1.7"
3 + version = "0.1.8"
4 4 description = "The declare! form: a screen description compiled to Rust at build time."
5 5 edition.workspace = true
6 6 rust-version.workspace = true
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "quasi-router"
3 - version = "0.101.14"
3 + version = "0.101.15"
4 4 description = "Host-agnostic router: a request in, a renderer-agnostic description out"
5 5 edition.workspace = true
6 6 rust-version.workspace = true
@@ -27,6 +27,7 @@
27 27
28 28 declare! {
29 29 /// Everything inside the tab pane.
30 + #[staged]
30 31 shape pane(buyers: &[Buyer], shared: &[Shared]) -> Node;
31 32
32 33 region REGION as Pane {
@@ -46,6 +47,7 @@
46 47
47 48 declare! {
48 49 /// The buyers who shared an email.
50 + #[staged]
49 51 shape buyers_table(buyers: &[Buyer]) -> Node;
50 52
51 53 table {
@@ -86,6 +88,7 @@
86 88
87 89 declare! {
88 90 /// The creators this reader has shared an email with.
91 + #[staged]
89 92 shape shared_table(shared: &[Shared]) -> Node;
90 93
91 94 table {
@@ -197,3 +200,73 @@
197 200 assert!(!html.contains("Shared With"), "{html}");
198 201 }
199 202 }
203 +
204 + #[cfg(test)]
205 + mod staged_tests {
206 + use quasi_http::Serves as _;
207 + use quasi_router::stage::{Plan, read_sentinel};
208 + use quasi_webview::Webview;
209 +
210 + use super::*;
211 +
212 + #[test]
213 + fn the_staged_twin_renders_the_screen_with_sentinels_where_values_go() {
214 + let html = Webview::new().fragment(&pane_staged(&Plan::full(1)));
215 +
216 + // The literals are the renderer's own, so the markup is the real
217 + // screen's and not something the macro decided.
218 + assert!(html.contains(r#"id="tab-content""#), "{html}");
219 + assert!(html.contains("Your Buyers ("), "{html}");
220 + assert!(html.contains(r#"class="table-head""#), "{html}");
221 + assert!(html.contains("Last Purchase"), "{html}");
222 + assert!(html.contains("Revoke"), "{html}");
223 +
224 + // The consts stayed real: a value that is the same for every request
225 + // belongs in the literal rather than in a hole.
226 + assert!(html.contains("/library/tabs/contacts/revoke/"), "{html}");
227 +
228 + // Every per-request value became a sentinel, addresses included.
229 + let mut holes = Vec::new();
230 + let mut rest = html.as_str();
231 + while let Some(at) = quasi_router::stage::find_sentinel(rest) {
232 + if let Some(hole) = read_sentinel(&rest[at..]) {
233 + holes.push(hole);
234 + rest = &rest[at + quasi_router::stage::SENTINEL_LEN..];
235 + } else {
236 + rest = &rest[at + 3..];
237 + }
238 + }
239 + assert!(!holes.is_empty(), "no sentinels at all: {html}");
240 +
241 + // The two tables were reached by two includes, so their holes are in
242 + // different scopes and nothing fills one from the other.
243 + let scopes: std::collections::BTreeSet<u32> = holes.iter().map(|(s, _)| *s).collect();
244 + assert!(
245 + scopes.len() >= 3,
246 + "expected root and two tables: {scopes:?}"
247 + );
248 + }
249 +
250 + /// The plan decides the branch, which is what the spike could not do.
251 + #[test]
252 + fn a_failed_guard_takes_its_emission_out_of_the_render() {
253 + let webview = Webview::new();
254 + let full = webview.fragment(&pane_staged(&Plan::full(1)));
255 + let none = webview.fragment(&pane_staged(&Plan::empty()));
256 +
257 + assert!(full.contains("Your Buyers ("), "{full}");
258 + assert!(!none.contains("Your Buyers ("), "{none}");
259 + assert!(!none.contains("table-head"), "{none}");
260 + }
261 +
262 + /// A loop runs as many times as the plan says, in the scope it is read at.
263 + #[test]
264 + fn one_loop_grows_without_moving_the_other() {
265 + let webview = Webview::new();
266 + let one = webview.fragment(&pane_staged(&Plan::full(1)));
267 + let two = webview.fragment(&pane_staged(&Plan::full(2)));
268 +
269 + assert_eq!(one.matches("table-row").count(), 2);
270 + assert_eq!(two.matches("table-row").count(), 4);
271 + }
272 + }
@@ -80,7 +80,12 @@
80 80 let line = LitStr::new(line, Span::call_site());
81 81 quote!(#[doc = #line])
82 82 });
83 - let flags = flags.iter().map(|flag| quote!(#[#flag]));
83 + // `staged` asks for the twin in `symbolic` and is not an attribute
84 + // rustc has ever heard of.
85 + let flags = flags
86 + .iter()
87 + .filter(|flag| *flag != crate::symbolic::FLAG)
88 + .map(|flag| quote!(#[#flag]));
84 89 let params = params.iter().map(|param| {
85 90 let name = &param.name;
86 91 let ty = &param.ty;
@@ -21,6 +21,7 @@
21 21 mod ast;
22 22 mod emit;
23 23 mod parse;
24 + mod symbolic;
24 25
25 26 use proc_macro::TokenStream;
26 27 use syn::parse_macro_input;
@@ -44,8 +45,41 @@
44 45 #[proc_macro]
45 46 pub fn declare(input: TokenStream) -> TokenStream {
46 47 let declaration = parse_macro_input!(input as ast::Declaration);
47 - match emit::declaration(&declaration) {
48 + match expand(&declaration) {
48 49 Ok(expansion) => expansion.into(),
49 50 Err(error) => error.into_compile_error().into(),
50 51 }
51 52 }
53 +
54 + /// The shape's function, and its staged twin if it asked for one.
55 + fn expand(declaration: &ast::Declaration) -> syn::Result<proc_macro2::TokenStream> {
56 + let shape = emit::declaration(declaration)?;
57 + if !symbolic::wanted(declaration) {
58 + return Ok(shape);
59 + }
60 +
61 + let staged = symbolic::stage(declaration)?;
62 + let twin = emit::declaration(&staged.declaration)?;
63 + let (guards, loops, holes, sites) = staged.counts;
64 + let counts = &staged.counts_ident;
65 + let vis = declaration
66 + .vis
67 + .clone()
68 + .unwrap_or(syn::Visibility::Inherited);
69 + let doc = format!(
70 + " What a plan has to answer for to drive [`{}`].",
71 + staged.declaration.name
72 + );
73 +
74 + Ok(quote::quote! {
75 + #shape
76 + #twin
77 + #[doc = #doc]
78 + #vis const #counts: ::quasi_router::stage::Shape = ::quasi_router::stage::Shape {
79 + guards: #guards,
80 + loops: #loops,
81 + holes: #holes,
82 + sites: #sites,
83 + };
84 + })
85 + }
@@ -190,7 +190,9 @@
190 190 ];
191 191
192 192 /// The attributes the generated function may carry.
193 - const FLAGS: &[&str] = &["must_use", "inline"];
193 + // `staged` is not an attribute the generated function carries: it asks for
194 + // a second function beside it. See `symbolic`.
195 + const FLAGS: &[&str] = &["must_use", "inline", "staged"];
194 196
195 197 /// The verbs, and whether each addresses something.
196 198 ///
@@ -141,6 +141,7 @@
141 141 pub mod response;
142 142 pub mod router;
143 143 pub mod screen;
144 + pub mod stage;
144 145
145 146 /// The description layer, re-exported.
146 147 ///
@@ -1,0 +1,581 @@
1 + //! The staged twin of a declaration.
2 + //!
3 + //! A shape marked `#[staged]` gets a second function beside it, which is the
4 + //! same description evaluated with no request: every value is a sentinel, every
5 + //! guard is a bool a [`Plan`](quasi_router::stage::Plan) chooses, and every
6 + //! loop runs as many times as the plan says. Handing its result to the ordinary
7 + //! renderer produces the screen's markup with sentinels where the data would
8 + //! be, and that string is the residual a request later fills.
9 + //!
10 + //! # Why this is a rewrite and not a second emitter
11 + //!
12 + //! Nothing here emits Rust. It rewrites the parsed [`Declaration`] into another
13 + //! [`Declaration`] and hands that to [`crate::emit`], so the staged function is
14 + //! built by the same code that builds the ordinary one.
15 + //!
16 + //! That is the whole reason to do it this way. The residual's literals have to
17 + //! be the renderer's own output, or the staged path is a second renderer
18 + //! wearing the first one's name, and a second emitter here would be the same
19 + //! mistake one level up: two spellings of every production, drifting apart one
20 + //! conversion at a time. A rewrite over a closed grammar cannot drift, because
21 + //! a production it does not know about is a production it cannot silently
22 + //! mistranslate -- it refuses instead.
23 + //!
24 + //! # What it refuses, and why refusing is right
25 + //!
26 + //! `#[staged]` is a request, so a shape that cannot be staged is a compile
27 + //! error naming the construct rather than a silently unstaged shape. Three
28 + //! things are refused today, each because the value it needs has no sentinel:
29 + //!
30 + //! - a region whose kind comes from a supplier, which needs a `RegionKind`
31 + //! - a dispatch with no `otherwise`, whose arms become integers and would stop
32 + //! being exhaustive
33 + //! - a value hole in a position that is not a string, which rustc reports
34 + //! against the generated call rather than here
35 + //!
36 + //! The flag is opt-in for exactly this reason: a shape is staged when someone
37 + //! has looked at it, and the rest keep the runtime renderer, which is how the
38 + //! two paths coexist during the migration without a switch.
39 +
40 + use proc_macro2::Span;
41 + use quote::format_ident;
42 + use syn::{Ident, Result};
43 +
44 + use crate::ast::{
45 + Arg, Declaration, Emission, Guard, Hole, HoleRoot, Interpolated, Item, Param, Pattern,
46 + Predicate, RegionKind, Source, StrPart,
47 + };
48 +
49 + /// The flag that asks for a staged twin.
50 + pub const FLAG: &str = "staged";
51 +
52 + /// The parameter the staged function takes in place of the request's reads.
53 + const PLAN: &str = "plan";
54 +
55 + /// The staged twin of `name`, which is what an `include` retargets to.
56 + pub fn staged_name(name: &Ident) -> Ident {
57 + format_ident!("{}_staged", name, span = name.span())
58 + }
59 +
60 + /// The const holding the shape's counts, beside the staged function.
61 + fn counts_name(name: &Ident) -> Ident {
62 + format_ident!(
63 + "{}_STAGED",
64 + name.to_string().to_uppercase(),
65 + span = name.span()
66 + )
67 + }
68 +
69 + /// Whether this declaration asked to be staged.
70 + pub fn wanted(declaration: &Declaration) -> bool {
71 + declaration.flags.iter().any(|flag| flag == FLAG)
72 + }
73 +
74 + /// Numbering, one counter per thing a plan answers.
75 + ///
76 + /// Shape-local on purpose. A residual is derived per shape and composed by
77 + /// reference, so an included shape's holes are never renumbered against its
78 + /// caller's, which is what keeps the derivation linear in the tree rather than
79 + /// in the tree's expansion.
80 + #[derive(Default)]
81 + struct Counters {
82 + guards: u16,
83 + loops: u16,
84 + holes: u16,
85 + /// One per `include`, which is what scopes the shape it reaches.
86 + sites: u16,
87 + }
88 +
89 + /// A hole reading the plan: `plan.<method>(<id>)`.
90 + fn plan_read(method: &str, id: u16) -> Hole {
91 + Hole {
92 + root: HoleRoot::Binding(Ident::new(PLAN, Span::call_site())),
93 + steps: vec![crate::ast::Step::Method {
94 + name: Ident::new(method, Span::call_site()),
95 + args: vec![Arg::Int(i64::from(id))],
96 + }],
97 + }
98 + }
99 +
100 + /// A hole answering with one sentinel: `plan.hole(<id>)`.
101 + ///
102 + /// Through the plan rather than a free function because a sentinel carries the
103 + /// scope it was read at, and the plan is what knows the scope. Two tables on
104 + /// one screen each number their holes from zero, so without the scope they
105 + /// would both claim hole 0 and the residual would fill one from the other.
106 + fn sentinel(id: u16) -> Hole {
107 + plan_read("hole", id)
108 + }
109 +
110 + /// The staged declaration, and the counts a plan needs to drive it.
111 + pub struct Staged {
112 + pub declaration: Declaration,
113 + pub counts: (u16, u16, u16, u16),
114 + pub counts_ident: Ident,
115 + }
116 +
117 + /// Rewrite one declaration into its staged twin.
118 + pub fn stage(declaration: &Declaration) -> Result<Staged> {
119 + let mut counters = Counters::default();
120 + let items = items(&declaration.items, &mut counters)?;
121 +
122 + let name = staged_name(&declaration.name);
123 + let doc = format!(
124 + " The staged twin of [`{}`], evaluated with no request.",
125 + declaration.name
126 + );
127 +
128 + Ok(Staged {
129 + counts: (
130 + counters.guards,
131 + counters.loops,
132 + counters.holes,
133 + counters.sites,
134 + ),
135 + counts_ident: counts_name(&declaration.name),
136 + declaration: Declaration {
137 + docs: vec![doc],
138 + // `must_use` and `inline` carry over; the flag that asked for this
139 + // does not, or the twin would ask for a twin of its own.
140 + flags: declaration
141 + .flags
142 + .iter()
143 + .filter(|flag| *flag != FLAG)
144 + .cloned()
145 + .collect(),
146 + vis: declaration.vis.clone(),
147 + name,
148 + params: vec![Param {
149 + name: Ident::new(PLAN, Span::call_site()),
150 + ty: syn::parse_quote!(&::quasi_router::stage::Plan),
151 + }],
152 + returns: declaration.returns.clone(),
153 + items,
154 + },
155 + })
156 + }
157 +
158 + fn items(items: &[Item], counters: &mut Counters) -> Result<Vec<Item>> {
159 + items
160 + .iter()
161 + .map(|item| self::item(item, counters))
162 + .collect()
163 + }
164 +
165 + fn item(item: &Item, counters: &mut Counters) -> Result<Item> {
166 + Ok(match item {
167 + Item::Bind { name, source } => Item::Bind {
168 + name: name.clone(),
169 + source: self::source(source, counters)?,
170 + },
171 + Item::Attribute { name, args, guard } => Item::Attribute {
172 + name: name.clone(),
173 + args: self::args(args, counters)?,
174 + guard: guard.as_ref().map(|guard| self::guard(guard, counters)),
175 + },
176 + // The binder is never read: every hole rooted at it was rewritten to a
177 + // sentinel above, so the loop runs for its count and nothing else. It
178 + // keeps its name with an underscore so a reader can still see which
179 + // loop this was.
180 + Item::For {
181 + binder,
182 + body,
183 + dereferenced: _,
184 + iterable: _,
185 + } => {
186 + let id = counters.loops;
187 + counters.loops += 1;
188 + Item::For {
189 + dereferenced: false,
190 + binder: format_ident!("_{}", binder, span = binder.span()),
191 + iterable: plan_read("rows", id),
192 + body: items(body, counters)?,
193 + }
194 + }
195 + Item::Emit(emission) => Item::Emit(self::emission(emission, counters)?),
196 + })
197 + }
198 +
199 + /// A guard becomes one read of the plan, and stops being negated.
200 + ///
201 + /// `unless x` and `when not x` are the same question asked twice, and the plan
202 + /// answers the emission rather than the predicate, so the twin keeps neither
203 + /// spelling. The predicate's own holes are dropped: R9 says they are evaluated
204 + /// whether or not the emission is placed, and a sentinel evaluated for a guard
205 + /// nobody reads is a cost with no output.
206 + fn guard(guard: &Guard, counters: &mut Counters) -> Guard {
207 + let id = counters.guards;
208 + counters.guards += 1;
209 + Guard {
210 + negated: false,
211 + predicate: Predicate::Truth(plan_read("guard", id)),
212 + span: guard.span,
213 + }
214 + }
215 +
216 + fn source(source: &Source, counters: &mut Counters) -> Result<Source> {
217 + Ok(match source {
218 + Source::Str(text) => Source::Str(interpolated(text, counters)),
219 + Source::Hole(hole) => Source::Hole(value(hole, counters)),
220 + Source::Choose {
221 + arms,
222 + otherwise,
223 + scrutinee: _,
224 + } => {
225 + let id = counters.arms_next();
226 + let arms = arms
227 + .iter()
228 + .enumerate()
229 + .map(|(at, (_, value))| {
230 + Ok((
231 + Pattern::Int(i64::try_from(at).unwrap_or(i64::MAX)),
232 + self::source(value, counters)?,
233 + ))
234 + })
235 + .collect::<Result<Vec<_>>>()?;
236 + Source::Choose {
237 + scrutinee: plan_read("arm", id),
238 + arms,
239 + otherwise: Box::new(self::source(otherwise, counters)?),
240 + }
241 + }
242 + })
243 + }
244 +
245 + impl Counters {
246 + /// A dispatch is numbered out of the guard counter's neighbour, and the
247 + /// plan keeps arms in their own list.
248 + fn arms_next(&mut self) -> u16 {
249 + let id = self.holes;
250 + // Arms and holes do not share a namespace in the plan; the counter is
251 + // reused so that a shape's numbering stays a single pass over its body.
252 + self.holes += 1;
253 + id
254 + }
255 + }
256 +
257 + /// A value a request would have brought, which becomes one sentinel.
258 + ///
259 + /// A hole rooted at a path is left alone. `REGION` and `PATH` are consts, so
260 + /// they are the same string for every request and belong in the literal rather
261 + /// than in a hole: staging them would spend a sentinel to reproduce a value the
262 + /// renderer already baked in.
263 + fn value(hole: &Hole, counters: &mut Counters) -> Hole {
264 + if matches!(hole.root, HoleRoot::Path(_)) && hole.steps.is_empty() {
265 + return Hole {
266 + root: match &hole.root {
267 + HoleRoot::Path(path) => HoleRoot::Path(path.clone()),
268 + _ => unreachable!("checked above"),
269 + },
270 + steps: Vec::new(),
271 + };
272 + }
273 + let id = counters.holes;
274 + counters.holes += 1;
275 + sentinel(id)
276 + }
277 +
278 + fn interpolated(text: &Interpolated, counters: &mut Counters) -> Interpolated {
279 + Interpolated {
280 + parts: text
281 + .parts
282 + .iter()
283 + .map(|part| match part {
284 + StrPart::Lit(literal) => StrPart::Lit(literal.clone()),
285 + StrPart::Hole(hole) => StrPart::Hole(value(hole, counters)),
286 + })
287 + .collect(),
288 + span: text.span,
289 + }
290 + }
291 +
292 + fn args(args: &[Arg], counters: &mut Counters) -> Result<Vec<Arg>> {
293 + args.iter().map(|arg| self::arg(arg, counters)).collect()
294 + }
295 +
296 + fn arg(arg: &Arg, counters: &mut Counters) -> Result<Arg> {
297 + Ok(match arg {
298 + Arg::Str(text) => Arg::Str(interpolated(text, counters)),
299 + Arg::Hole(hole) => Arg::Hole(value(hole, counters)),
300 + Arg::List(items) => Arg::List(args(items, counters)?),
301 + Arg::Borrow(inner) => Arg::Borrow(Box::new(self::arg(inner, counters)?)),
302 + Arg::Int(value) => Arg::Int(*value),
303 + Arg::Bool(value) => Arg::Bool(*value),
304 + })
305 + }
306 +
307 + /// An action's target and its modifiers both carry addresses, and an address is
308 + /// a value a request brings. Rewriting them is what puts every `href` and every
309 + /// `hx-delete` into the residual as a hole rather than baking one request's
310 + /// into the literal.
311 + fn action(action: &crate::ast::Action, counters: &mut Counters) -> Result<crate::ast::Action> {
312 + Ok(crate::ast::Action {
313 + verb: action.verb.clone(),
314 + target: action
315 + .target
316 + .as_ref()
317 + .map(|target| arg(target, counters))
318 + .transpose()?,
319 + modifiers: action
320 + .modifiers
321 + .iter()
322 + .map(|modifier| {
323 + Ok(crate::ast::Modifier {
324 + name: modifier.name.clone(),
325 + args: args(&modifier.args, counters)?,
326 + })
327 + })
328 + .collect::<Result<Vec<_>>>()?,
329 + })
330 + }
331 +
332 + fn emission(emission: &Emission, counters: &mut Counters) -> Result<Emission> {
333 + Ok(match emission {
334 + Emission::Simple { member, args, body } => Emission::Simple {
335 + member: member.clone(),
336 + args: self::args(args, counters)?,
337 + body: items(body, counters)?,
338 + },
339 + Emission::Chip {
340 + value,
341 + action,
342 + removable,
343 + body,
344 + } => Emission::Chip {
345 + value: arg(value, counters)?,
346 + action: self::action(action, counters)?,
347 + removable: *removable,
348 + body: items(body, counters)?,
349 + },
350 + Emission::Screen {
351 + arrangement,
352 + args,
353 + body,
354 + } => Emission::Screen {
355 + arrangement: arrangement.clone(),
356 + args: self::args(args, counters)?,
357 + body: items(body, counters)?,
358 + },
359 + Emission::Row { primary, body } => Emission::Row {
360 + primary: arg(primary, counters)?,
361 + body: items(body, counters)?,
362 + },
363 + Emission::Form { action, body } => Emission::Form {
364 + action: self::action(action, counters)?,
365 + body: items(body, counters)?,
366 + },
367 + Emission::Field {
368 + kind,
369 + name,
370 + label,
371 + body,
372 + } => Emission::Field {
373 + kind: kind.clone(),
374 + name: arg(name, counters)?,
375 + label: arg(label, counters)?,
376 + body: items(body, counters)?,
377 + },
378 + Emission::List(body) => Emission::List(items(body, counters)?),
379 + Emission::Act {
380 + label,
381 + action,
382 + body,
383 + } => Emission::Act {
384 + label: arg(label, counters)?,
385 + action: self::action(action, counters)?,
386 + body: items(body, counters)?,
387 + },
388 + Emission::Offers {
389 + label,
390 + action,
391 + body,
392 + } => Emission::Offers {
393 + label: arg(label, counters)?,
394 + action: self::action(action, counters)?,
395 + body: items(body, counters)?,
396 + },
397 + Emission::Table(body) => Emission::Table(items(body, counters)?),
398 + Emission::Column { name, body } => Emission::Column {
399 + name: arg(name, counters)?,
400 + body: items(body, counters)?,
401 + },
402 + Emission::Cells(body) => Emission::Cells(items(body, counters)?),
403 + Emission::Cell {
404 + column,
405 + value,
406 + body,
407 + } => Emission::Cell {
408 + column: column.as_ref().map(|it| arg(it, counters)).transpose()?,
409 + value: arg(value, counters)?,
410 + body: items(body, counters)?,
411 + },
412 + Emission::Offering {
413 + label,
414 + action,
415 + body,
416 + } => Emission::Offering {
417 + label: arg(label, counters)?,
418 + action: self::action(action, counters)?,
419 + body: items(body, counters)?,
420 + },
421 + Emission::Removes {
422 + label,
423 + action,
424 + body,
425 + } => Emission::Removes {
426 + label: arg(label, counters)?,
427 + action: self::action(action, counters)?,
428 + body: items(body, counters)?,
429 + },
430 + Emission::Repeats {
431 + one,
432 + label,
433 + action,
434 + body,
435 + } => Emission::Repeats {
436 + one: arg(one, counters)?,
437 + label: arg(label, counters)?,
438 + action: self::action(action, counters)?,
439 + body: items(body, counters)?,
440 + },
441 + Emission::Activate(action) => Emission::Activate(self::action(action, counters)?),
442 + Emission::Link { text, action } => Emission::Link {
443 + text: arg(text, counters)?,
444 + action: self::action(action, counters)?,
445 + },
446 + Emission::Include(hole) => Emission::Include(include(hole, counters)?),
447 + Emission::Region { name, kind, body } => Emission::Region {
448 + name: arg(name, counters)?,
449 + kind: match kind {
450 + RegionKind::Variant(variant) => RegionKind::Variant(variant.clone()),
451 + // A `RegionKind` has no sentinel, and guessing a variant would
452 + // put a kind in the residual that no request asked for.
453 + RegionKind::Supplied(hole) => {
454 + return Err(syn::Error::new(
455 + hole.span(),
456 + "a staged shape cannot take its region kind from a supplier: \
457 + a `RegionKind` has no sentinel to stand in for it",
458 + ));
459 + }
460 + },
461 + body: items(body, counters)?,
462 + },
463 + Emission::Across { fallback, body } => Emission::Across {
464 + fallback: fallback.clone(),
465 + body: items(body, counters)?,
466 + },
467 + Emission::Beside { priority, inner } => Emission::Beside {
468 + priority: arg(priority, counters)?,
469 + inner: Box::new(self::emission(inner, counters)?),
470 + },
471 + Emission::At { at, inner } => Emission::At {
472 + at: arg(at, counters)?,
473 + inner: Box::new(self::emission(inner, counters)?),
474 + },
475 + Emission::Guarded { guard, inner } => Emission::Guarded {
476 + guard: self::guard(guard, counters),
477 + inner: Box::new(self::emission(inner, counters)?),
478 + },
479 + // The arms become integers, so the plan chooses one by position and the
480 + // scrutinee's own type never has to be reproduced. That is also why an
481 + // `otherwise` stops being optional here: a match on a `usize` against
482 + // integer literals is not exhaustive without one, and rustc would
483 + // report that against generated code rather than against the
484 + // declaration that caused it.
485 + Emission::Given {
486 + scrutinee,
487 + arms,
488 + otherwise,
489 + } => {
490 + let Some(otherwise) = otherwise else {
491 + return Err(syn::Error::new(
492 + scrutinee.span(),
493 + "a staged dispatch needs an `otherwise`: its arms are chosen by \
494 + position, and a position nothing answers has to land somewhere",
495 + ));
496 + };
497 + let id = counters.arms_next();
498 + let arms = arms
499 + .iter()
500 + .enumerate()
Lines truncated
@@ -1,0 +1,311 @@
1 + //! What a declared shape reads when it is evaluated with no request.
2 + //!
3 + //! A described screen is built from two kinds of thing: markup the renderer
4 + //! decides, which is the same for every request, and values a request brings.
5 + //! Staging separates them once, so that serving a screen is filling holes in a
6 + //! program rather than building a tree and rendering it.
7 + //!
8 + //! The separation is found by evaluating the description twice. `declare!`
9 + //! emits a second function beside every shape marked `#[staged]`, identical to
10 + //! the first except that it reads a [`Plan`] instead of a request: every value
11 + //! becomes a [`sentinel`], every guard becomes a bool the plan chooses, and
12 + //! every loop runs as many times as the plan says. Handing the result to the
13 + //! ordinary renderer produces the screen's markup with sentinels where the data
14 + //! would be, and that string is the residual.
15 + //!
16 + //! # Why the plan rather than a symbolic value
17 + //!
18 + //! A guard is a `bool` a request answers and a loop counts something a request
19 + //! brought, so neither has a sentinel to stand in for it. They are answered
20 + //! here instead, which means the derivation chooses them rather than inferring
21 + //! them: the emitter knows where every guard and every loop is, because the
22 + //! macro parsed them, and does not have to recover that by comparing two
23 + //! renders. Nothing about the shape of the output is guessed.
24 + //!
25 + //! # What a sentinel has to be
26 + //!
27 + //! Fixed width, so a scanner can step over one without parsing. Alphanumeric,
28 + //! so the renderer's escaper passes it through unchanged and a value in an
29 + //! attribute reads the same as a value in text. And unlikely in real markup,
30 + //! which `ZQH`/`HQZ` is by construction rather than by hope: a residual carries
31 + //! its own check that no literal chunk contains one.
32 +
33 + use std::iter::{Repeat, Take, repeat};
34 +
35 + /// The three-character opening of a sentinel.
36 + const OPEN: &str = "ZQH";
37 +
38 + /// The three-character closing of a sentinel.
39 + const CLOSE: &str = "HQZ";
40 +
41 + /// How wide one sentinel is, which is what lets a scanner step over one.
42 + ///
43 + /// `ZQH` + four hex digits of scope + four of hole + `HQZ`. The scope is there
44 + /// because a residual is derived by calling the staged twins for real, so one
45 + /// derivation walks many shapes and each numbers its holes from zero. Without
46 + /// it, two tables on one screen would both claim hole 0.
47 + pub const SENTINEL_LEN: usize = 14;
48 +
49 + /// Where one shape sits in the call tree a derivation walks.
50 + ///
51 + /// Built by descending: each `include` in a shape has an ordinal, and entering
52 + /// it mixes that ordinal into the caller's own scope. Two different paths to
53 + /// the same shape get different scopes, which is what makes a hole's identity
54 + /// the place it is read rather than the shape it is written in.
55 + type Scope = u32;
56 +
57 + /// The stand-in for one value a request would have brought.
58 + #[must_use]
59 + fn sentinel_at(scope: Scope, id: u16) -> String {
60 + format!("{OPEN}{:04x}{id:04x}{CLOSE}", scope & 0xffff)
61 + }
62 +
63 + /// The scope and hole a sentinel stands for, or `None` if this is not one.
64 + #[must_use]
65 + pub fn read_sentinel(text: &str) -> Option<(u32, u16)> {
66 + let found = text.get(..SENTINEL_LEN)?;
67 + if !found.starts_with(OPEN) || !found.ends_with(CLOSE) {
68 + return None;
69 + }
70 + let scope = u32::from_str_radix(&found[3..7], 16).ok()?;
71 + let id = u16::from_str_radix(&found[7..11], 16).ok()?;
72 + Some((scope, id))
73 + }
74 +
75 + /// Where a sentinel could begin, searching forward from the start.
76 + #[must_use]
77 + pub fn find_sentinel(text: &str) -> Option<usize> {
78 + text.find(OPEN)
79 + }
80 +
81 + /// How much of a shape a plan has to answer for.
82 + ///
83 + /// Emitted beside every staged shape, because the macro is what counted these
84 + /// and a derivation that recounted would be reading the same declaration twice.
85 + #[derive(Clone, Copy, Debug)]
86 + pub struct Shape {
87 + /// Guards, so a plan can pass or fail each in turn.
88 + pub guards: u16,
89 + /// Loops, so a plan can say how many times each runs.
90 + pub loops: u16,
91 + /// Value holes, which is how many sentinels this shape can put in a render.
92 + pub holes: u16,
93 + /// `include` sites, which is how many scopes descend from this one.
94 + pub sites: u16,
95 + }
96 +
97 + /// What one symbolic evaluation of a shape is told.
98 + ///
99 + /// A derivation asks the same two questions over and over: run everything, or
100 + /// run everything with one loop one row longer. So a plan is a pair of defaults
101 + /// plus the handful of overrides that make one render differ from another,
102 + /// rather than a table with an entry per site.
103 + #[derive(Clone, Debug)]
104 + pub struct Plan {
105 + scope: Scope,
106 + /// What a guard answers unless something says otherwise.
107 + guards: bool,
108 + /// How many times a loop runs unless something says otherwise.
109 + rows: usize,
110 + /// The loops that differ from [`Self::rows`], by scope and id.
111 + row_overrides: Vec<((Scope, u16), usize)>,
112 + /// The guards that differ from [`Self::guards`], by scope and id.
113 + guard_overrides: Vec<((Scope, u16), bool)>,
114 + /// Which arm each dispatch takes, by scope and id. Absent means the first.
115 + arms: Vec<((Scope, u16), usize)>,
116 + }
117 +
118 + impl Default for Plan {
119 + fn default() -> Self {
120 + Self::empty()
121 + }
122 + }
123 +
124 + impl Plan {
125 + /// A plan that says nothing: no guard passes and no loop runs.
126 + #[must_use]
127 + pub fn empty() -> Self {
128 + Self {
129 + scope: 0,
130 + guards: false,
131 + rows: 0,
132 + row_overrides: Vec::new(),
133 + guard_overrides: Vec::new(),
134 + arms: Vec::new(),
135 + }
136 + }
137 +
138 + /// Every guard passing and every loop running `rows` times.
139 + ///
140 + /// The shape a screen has when it is full, which is the render a residual
141 + /// is read off. A guard that never passes contributes no markup, so a
142 + /// residual derived from a plan that failed one would be missing a branch
143 + /// rather than carrying it.
144 + #[must_use]
145 + pub fn full(rows: usize) -> Self {
146 + Self {
147 + guards: true,
148 + rows,
149 + ..Self::empty()
150 + }
151 + }
152 +
153 + /// The same plan as seen by the shape one `include` reaches.
154 + ///
155 + /// Called by generated code, once per include site, with that site's
156 + /// ordinal in its caller.
157 + #[must_use]
158 + pub fn enter(&self, site: u16) -> Self {
159 + Self {
160 + scope: self
161 + .scope
162 + .wrapping_mul(31)
163 + .wrapping_add(u32::from(site) + 1),
164 + ..self.clone()
165 + }
166 + }
167 +
168 + /// Say how many times one loop runs, in the scope this plan is at.
169 + #[must_use]
170 + pub fn with_rows(mut self, id: u16, count: usize) -> Self {
171 + self.row_overrides.push(((self.scope, id), count));
172 + self
173 + }
174 +
175 + /// Say what one guard answers, in the scope this plan is at.
176 + #[must_use]
177 + pub fn with_guard(mut self, id: u16, passes: bool) -> Self {
178 + self.guard_overrides.push(((self.scope, id), passes));
179 + self
180 + }
181 +
182 + /// Say which arm one dispatch takes, in the scope this plan is at.
183 + #[must_use]
184 + pub fn with_arm(mut self, id: u16, arm: usize) -> Self {
185 + self.arms.push(((self.scope, id), arm));
186 + self
187 + }
188 +
189 + /// This plan's scope, which a derivation needs to name a loop it wants to
190 + /// vary inside an included shape.
191 + #[must_use]
192 + pub fn scope(&self) -> u32 {
193 + self.scope
194 + }
195 +
196 + /// One value a request would have brought. Read by generated code.
197 + #[must_use]
198 + pub fn hole(&self, id: u16) -> String {
199 + sentinel_at(self.scope, id)
200 + }
201 +
202 + /// Whether one guard passes. Read by generated code.
203 + #[must_use]
204 + pub fn guard(&self, id: u16) -> bool {
205 + look(&self.guard_overrides, self.scope, id).unwrap_or(self.guards)
206 + }
207 +
208 + /// One loop's runs, as something a `for` can take. Read by generated code.
209 + ///
210 + /// Yields `()` rather than elements: the body's holes were rewritten to
211 + /// sentinels when the shape was staged, so nothing in it reads the binder
212 + /// and there is no element for it to read.
213 + #[must_use]
214 + pub fn rows(&self, id: u16) -> Take<Repeat<()>> {
215 + let count = look(&self.row_overrides, self.scope, id).unwrap_or(self.rows);
216 + repeat(()).take(count)
217 + }
218 +
219 + /// Which arm one dispatch takes. Read by generated code.
220 + #[must_use]
221 + pub fn arm(&self, id: u16) -> usize {
222 + look(&self.arms, self.scope, id).unwrap_or(0)
223 + }
224 + }
225 +
226 + /// The last thing said about one site, or nothing.
227 + ///
228 + /// Last rather than first so that `with_rows` can be applied over a plan that
229 + /// already said something about the same loop, which is what a derivation does
230 + /// when it varies one loop at a time.
231 + fn look<T: Copy>(overrides: &[((Scope, u16), T)], scope: Scope, id: u16) -> Option<T> {
232 + overrides
233 + .iter()
234 + .rev()
235 + .find(|(at, _)| *at == (scope, id))
236 + .map(|(_, value)| *value)
237 + }
238 +
239 + #[cfg(test)]
240 + mod tests {
241 + use super::*;
242 +
243 + #[test]
244 + fn a_sentinel_is_fixed_width_and_reads_back() {
245 + let plan = Plan::full(1);
246 + for id in [0, 1, 42, 9999] {
247 + let text = plan.hole(id);
248 + assert_eq!(text.len(), SENTINEL_LEN, "{text}");
249 + assert_eq!(read_sentinel(&text), Some((plan.scope(), id)));
250 + }
251 + }
252 +
253 + /// The scanner steps over `ZQH` that is not a sentinel rather than reading
254 + /// a hole number out of whatever follows it.
255 + #[test]
256 + fn an_opening_that_is_not_a_sentinel_reads_as_nothing() {
257 + assert_eq!(read_sentinel("ZQHzzzzzzzzHQZ"), None);
258 + assert_eq!(read_sentinel("ZQH00000001XXX"), None);
259 + assert_eq!(read_sentinel("ZQH"), None);
260 + assert_eq!(read_sentinel(""), None);
261 + }
262 +
263 + /// The collision this scope exists to stop: two shapes reached by two
264 + /// includes each number their first hole zero, and the sentinels differ.
265 + #[test]
266 + fn two_included_shapes_do_not_share_a_hole() {
267 + let plan = Plan::full(1);
268 + let first = plan.enter(0);
269 + let second = plan.enter(1);
270 +
271 + assert_ne!(first.hole(0), second.hole(0));
272 + assert_ne!(first.scope(), second.scope());
273 + assert_ne!(first.hole(0), plan.hole(0));
274 + }
275 +
276 + /// A loop is varied where it is read, so saying it in the caller's scope
277 + /// leaves the included shape's own loop alone.
278 + #[test]
279 + fn a_row_count_is_said_in_one_scope_only() {
280 + let plan = Plan::full(1);
281 + let inner = plan.enter(0).with_rows(0, 7);
282 +
283 + assert_eq!(inner.rows(0).count(), 7);
284 + assert_eq!(plan.rows(0).count(), 1);
285 + assert_eq!(plan.enter(1).rows(0).count(), 1);
286 + }
287 +
288 + /// A plan answers its defaults where nothing was said, which is what lets a
289 + /// derivation vary one loop without writing every other one down.
290 + #[test]
291 + fn an_unsaid_site_takes_the_default() {
292 + let plan = Plan::full(3).with_guard(2, false);
293 +
294 + assert!(plan.guard(0));
295 + assert!(!plan.guard(2));
296 + assert_eq!(plan.rows(0).count(), 3);
297 + assert_eq!(plan.rows(97).count(), 3);
298 + assert_eq!(plan.arm(0), 0);
299 + assert_eq!(Plan::empty().rows(0).count(), 0);
300 + assert!(!Plan::empty().guard(0));
301 + }
302 +
303 + /// The last thing said wins, which is how a derivation layers one override
304 + /// over a plan that already said something about the same loop.
305 + #[test]
306 + fn the_last_word_on_a_site_is_the_one_that_counts() {
307 + let plan = Plan::full(1).with_rows(0, 4).with_rows(0, 9);
308 +
309 + assert_eq!(plan.rows(0).count(), 9);
310 + }
311 + }