Skip to main content

max / quasi

Stage a description into a template, and measure what that costs A spike against the question of whether a semantic starting point can reach the speed of a compiled template. It can, and by a wider margin than the render-time argument predicted. describe() is the library_contacts pane with its data reads behind a Data seam, so the runtime path and the emitter evaluate one description and cannot drift. Symbolic answers every field with a sentinel; rendering that through the ordinary Webview gives the screen's markup with holes in it, and the loop bodies come back by rendering again with one extra row per table and taking the difference. No HTML generation is reimplemented, and holes are filled through makeover-webview's own escaper, so Node::Text's guarantee survives into the residual. fw13, release, best of 5 passes of 20000, ns per render: rows build+render staged askama 5 6721 884 543 25 28722 3733 2552 200 213231 28093 17975 Allocations per render, which are the stable half: rows build+render staged 5 230 / 50763 7 / 23940 25 912 / 198478 9 / 97507 200 6865 / 1519846 12 / 784866 The staged path is not only free of the tree, it folds away everything the renderer recomputes per request that is a function of the shape: the narrowing pass, the column classes, the action attributes. That is why it beats rendering a tree it was handed for free. The emitted Askama template is generated, never edited: QUASI_EMIT_TEMPLATE=crates/quasi-bench/templates/contacts.html \ cargo run --release -p quasi-bench What this does not cover: library_contacts has no conditional in its shape, so the case where a branch changes the tree is untested. That is the real constraint, and it is 56 if and 3 match sites across the server's shape functions.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01MptwXZ8k65v19rFmdGAyki
Author: Max Johnson <me@maxj.phd> · 2026-09-01 16:44 UTC
Signed with PGP, not checked
Commit: 55b006824a0488df3744d8381992d60cc9932785
Parent: a0a837b
7 files changed, +732 insertions, -105 deletions
M Cargo.lock +60 -4
@@ -101,6 +101,48 @@
101 101 source = "registry+https://github.com/rust-lang/crates.io-index"
102 102 checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
103 103
104 + [[package]]
105 + name = "askama"
106 + version = "0.14.0"
107 + source = "registry+https://github.com/rust-lang/crates.io-index"
108 + checksum = "f75363874b771be265f4ffe307ca705ef6f3baa19011c149da8674a87f1b75c4"
109 + dependencies = [
110 + "askama_derive",
111 + "itoa",
112 + "percent-encoding",
113 + "serde",
114 + "serde_json",
115 + ]
116 +
117 + [[package]]
118 + name = "askama_derive"
119 + version = "0.14.0"
120 + source = "registry+https://github.com/rust-lang/crates.io-index"
121 + checksum = "129397200fe83088e8a68407a8e2b1f826cf0086b21ccdb866a722c8bcd3a94f"
122 + dependencies = [
123 + "askama_parser",
124 + "basic-toml",
125 + "memchr",
126 + "proc-macro2",
127 + "quote",
128 + "rustc-hash 2.1.3",
129 + "serde",
130 + "serde_derive",
131 + "syn 2.0.119",
132 + ]
133 +
134 + [[package]]
135 + name = "askama_parser"
136 + version = "0.14.0"
137 + source = "registry+https://github.com/rust-lang/crates.io-index"
138 + checksum = "d6ab5630b3d5eaf232620167977f95eb51f3432fc76852328774afbd242d4358"
139 + dependencies = [
140 + "memchr",
141 + "serde",
142 + "serde_derive",
143 + "winnow 0.7.15",
144 + ]
145 +
104 146 [[package]]
105 147 name = "async-broadcast"
106 148 version = "0.7.2"
@@ -346,6 +388,15 @@
346 388 source = "registry+https://github.com/rust-lang/crates.io-index"
347 389 checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
348 390
391 + [[package]]
392 + name = "basic-toml"
393 + version = "0.1.10"
394 + source = "registry+https://github.com/rust-lang/crates.io-index"
395 + checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a"
396 + dependencies = [
397 + "serde",
398 + ]
399 +
349 400 [[package]]
350 401 name = "bit-set"
351 402 version = "0.8.0"
@@ -3455,10 +3506,12 @@
3455 3506 name = "quasi-bench"
3456 3507 version = "0.94.0"
3457 3508 dependencies = [
3509 + "askama",
3458 3510 "dhat",
3459 3511 "makeover",
3460 3512 "makeover-layout",
3461 3513 "makeover-tui",
3514 + "makeover-webview",
3462 3515 "quasi-http",
3463 3516 "quasi-router",
3464 3517 "quasi-tui",
@@ -5900,6 +5953,9 @@
5900 5953 version = "0.7.15"
5901 5954 source = "registry+https://github.com/rust-lang/crates.io-index"
5902 5955 checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
5956 + dependencies = [
5957 + "memchr",
5958 + ]
5903 5959
5904 5960 [[package]]
5905 5961 name = "winnow"
@@ -6215,10 +6271,6 @@
6215 6271 name = "synckit-client"
6216 6272 version = "0.10.0"
6217 6273
6218 - [[patch.unused]]
6219 - name = "quasi-type"
6220 - version = "0.1.3"
6221 -
6222 6274 [[patch.unused]]
6223 6275 name = "kberg"
6224 6276 version = "0.1.0"
@@ -6234,3 +6286,7 @@
6234 6286 [[patch.unused]]
6235 6287 name = "tagtree"
6236 6288 version = "0.4.1"
6289 +
6290 + [[patch.unused]]
6291 + name = "quasi-type"
6292 + version = "0.1.3"
@@ -20,6 +20,10 @@
20 20 # two builds: `cargo run --release -p quasi-bench` times, and
21 21 # `cargo run --release -p quasi-bench --features count` counts.
22 22 count = ["dep:dhat"]
23 + # The generated template, compiled. Off by default so the ordinary bench does
24 + # not carry a templating engine; on, it adds the third column that says whether
25 + # a staged description reaches a compiled template.
26 + askama = ["dep:askama"]
23 27
24 28 [dependencies]
25 29 quasi-router = { path = "../quasi-router", version = "0.94.0" }
@@ -30,6 +34,9 @@
30 34 # because a bench calling a trait method should name the trait it calls.
31 35 quasi-http = { path = "../quasi-http", version = "0.94.0" }
32 36 makeover-layout = "0.41.0"
37 + # The renderer's own escaper, so the staged path fills a hole exactly the way
38 + # `Node::Text` does and the guarantee is not quietly reimplemented.
39 + makeover-webview = "0.70.0"
33 40 makeover-tui = { version = "0.41.0", features = ["theme"] }
34 41 # Only to load a bundled theme file. `makeover_tui::Theme` is `#[non_exhaustive]`,
35 42 # so `Theme::from_theme` is the one way to get one, the same reason quasi-tui's
@@ -41,3 +48,6 @@
41 48 # `unsafe_code` and `forbid` cannot be overridden per crate; see
42 49 # `src/counting.rs`.
43 50 dhat = { version = "0.3", optional = true }
51 + # Compiles `templates/contacts.html`, which is emitted from the description by
52 + # `QUASI_EMIT_TEMPLATE=... cargo run -p quasi-bench` and never hand-edited.
53 + askama = { version = "0.14", optional = true }
@@ -14,12 +14,9 @@
14 14 //!
15 15 //! The row counts are the bench's axis, not the screen's: see [`SIZES`].
16 16
17 - use makeover_layout as layout;
18 - use quasi_router::screen::{Act, Cell, Cells, Column};
19 - use quasi_router::{Action, Node, RegionKind, Slot};
17 + use quasi_router::Node;
20 18
21 - /// The region id, matching the original screen's.
22 - const REGION: &str = "tab-content";
19 + use crate::staging;
23 20
24 21 /// Row counts, and why these three.
25 22 ///
@@ -30,19 +27,19 @@
30 27 pub const SIZES: [usize; 3] = [5, 25, 200];
31 28
32 29 /// One buyer row's worth of strings.
33 - struct Buyer {
34 - username: String,
35 - email: String,
36 - purchases: String,
37 - spent: String,
38 - last_purchase: String,
30 + pub(crate) struct Buyer {
31 + pub(crate) username: String,
32 + pub(crate) email: String,
33 + pub(crate) purchases: String,
34 + pub(crate) spent: String,
35 + pub(crate) last_purchase: String,
39 36 }
40 37
41 38 /// One shared-with row's worth of strings.
42 - struct Shared {
43 - seller_id: String,
44 - username: String,
45 - name: String,
39 + pub(crate) struct Shared {
40 + pub(crate) seller_id: String,
41 + pub(crate) username: String,
42 + pub(crate) name: String,
46 43 }
47 44
48 45 /// Rows built once, outside the timed region.
@@ -52,8 +49,8 @@
52 49 /// same string, which keeps an allocator from making a repeated value look
53 50 /// cheaper than a real one.
54 51 pub struct Rows {
55 - buyers: Vec<Buyer>,
56 - shared: Vec<Shared>,
52 + pub(crate) buyers: Vec<Buyer>,
53 + pub(crate) shared: Vec<Shared>,
57 54 }
58 55
59 56 impl Rows {
@@ -85,87 +82,11 @@
85 82 /// Rebuilt per iteration on purpose. Building the description is part of what a
86 83 /// request pays, and a bench that hoisted it out would measure the renderer
87 84 /// against a tree the handler never actually has to make.
85 + ///
86 + /// The shape itself lives in [`crate::staging`], written once against the
87 + /// `Data` seam so that the runtime path and the staged path cannot drift. This
88 + /// is that one description evaluated over real strings.
88 89 #[must_use]
89 90 pub fn pane(rows: &Rows) -> Node {
90 - let slot = Slot::new(REGION, RegionKind::Pane)
91 - .with(Node::section(format!(
92 - "Your Buyers ({})",
93 - rows.buyers.len()
94 - )))
95 - .with(Node::text(
96 - "Buyers who opted to share their email with you at purchase time.",
97 - ))
98 - .with(buyers_table(&rows.buyers))
99 - .with(Node::section("Shared With"))
100 - .with(Node::text(
101 - "You've shared your email with these creators. You can revoke sharing at any time.",
102 - ))
103 - .with(shared_table(&rows.shared));
104 -
105 - Node::Region(slot)
106 - }
107 -
108 - /// Five columns, three widths, two priorities, two flavours of linked value.
109 - fn buyers_table(buyers: &[Buyer]) -> Node {
110 - Node::Table {
111 - columns: vec![
112 - Column::new("Username")
113 - .width(layout::Width::Content)
114 - .priority(layout::Priority::Essential),
115 - Column::new("Email")
116 - .width(layout::Width::Fill)
117 - .priority(layout::Priority::Essential),
118 - Column::new("Purchases").width(layout::Width::Content),
119 - Column::new("Total Spent").width(layout::Width::Content),
120 - Column::new("Last Purchase")
121 - .width(layout::Width::Content)
122 - .priority(layout::Priority::Optional),
123 - ],
124 - rows: buyers
125 - .iter()
126 - .map(|buyer| {
127 - Cells::new([
128 - Cell::new(buyer.username.clone())
129 - .activate(Action::get(format!("/u/{}", buyer.username))),
130 - Cell::new(buyer.email.clone())
131 - .activate(Action::external(format!("mailto:{}", buyer.email))),
132 - Cell::new(buyer.purchases.clone()),
133 - Cell::new(buyer.spent.clone()),
134 - Cell::new(buyer.last_purchase.clone()),
135 - ])
136 - })
137 - .collect(),
138 - more: None,
139 - }
140 - }
141 -
142 - /// Two columns, one of them a destructive act.
143 - fn shared_table(shared: &[Shared]) -> Node {
144 - Node::Table {
145 - columns: vec![
146 - Column::new("Creator")
147 - .width(layout::Width::Fill)
148 - .priority(layout::Priority::Essential),
149 - Column::new("")
150 - .width(layout::Width::Content)
151 - .priority(layout::Priority::Essential),
152 - ],
153 - rows: shared
154 - .iter()
155 - .map(|creator| {
156 - Cells::new([
157 - Cell::new(creator.name.clone())
158 - .activate(Action::get(format!("/u/{}", creator.username))),
159 - Cell::acts([Act::new(
160 - "Revoke",
161 - Action::delete(format!(
162 - "/library/tabs/contacts/revoke/{}",
163 - creator.seller_id
164 - )),
165 - )]),
166 - ])
167 - })
168 - .collect(),
169 - more: None,
170 - }
91 + staging::describe(&staging::Live(rows))
171 92 }
@@ -39,6 +39,10 @@
39 39 //! those are the two things a route actually answers and the shell is not free.
40 40
41 41 mod fixture;
42 + mod staging;
43 +
44 + #[cfg(feature = "askama")]
45 + mod compiled;
42 46
43 47 #[cfg(feature = "count")]
44 48 mod counting;
@@ -109,13 +113,48 @@
109 113 println!("nanoseconds per render, best of {PASSES} passes of {ITERATIONS}\n");
110 114
111 115 println!(
112 - "{:>5} {:>22} {:>22} {:>22}",
113 - "rows", "webview fragment", "webview screen", "terminal"
116 + "{:>5} {:>13} {:>13} {:>13} {:>13} {:>13} {:>13}",
117 + "rows", "build tree", "render tree", "build+render", "staged", COMPILED_HEAD, "terminal"
114 118 );
115 119
120 + // The residual, emitted once. A build-time artifact in a real host; emitted
121 + // here at startup so the spike needs no build script to answer the
122 + // question it exists to answer.
123 + let program = staging::emit();
124 +
125 + // The proposal, written to disk: the description compiled to an Askama
126 + // template. Not on the timing path; this is how the file that the `askama`
127 + // feature compiles gets regenerated when the description changes.
128 + if let Ok(path) = std::env::var("QUASI_EMIT_TEMPLATE") {
129 + std::fs::write(&path, staging::as_askama_template(&program))
130 + .expect("the emitted template is writable");
131 + println!("wrote {path}");
132 + return;
133 + }
134 +
135 + check_staged_matches(&webview, &program);
136 +
116 137 for size in fixture::SIZES {
117 138 let rows = fixture::Rows::new(size);
118 139
140 + // The tree on its own: what a description costs to construct before
141 + // anything has been rendered. This is the half staging deletes.
142 + let build = measure(|| {
143 + black_box(fixture::pane(black_box(&rows)));
144 + });
145 + // The renderer on its own, over a tree it did not have to build. The
146 + // closest thing in this bench to what a compiled template does, and
147 + // therefore the floor the staged column is trying to reach.
148 + let prebuilt = fixture::pane(&rows);
149 + let render = measure(|| {
150 + black_box(webview.fragment(black_box(&prebuilt)));
151 + });
152 + let staged = measure(|| {
153 + let mut out = String::new();
154 + staging::exec(&program, black_box(&rows), &mut out);
155 + black_box(out);
156 + });
157 +
119 158 let fragment = measure(|| {
120 159 let node = fixture::pane(&rows);
121 160 black_box(webview.fragment(black_box(&node)));
@@ -137,7 +176,11 @@
137 176 black_box(&buf);
138 177 });
139 178
140 - println!("{size:>5} {fragment:>22} {screen:>22} {terminal:>22}");
179 + let compiled = measure_compiled(&webview, &rows, &screen);
180 +
181 + println!(
182 + "{size:>5} {build:>13} {render:>13} {fragment:>13} {staged:>13} {compiled:>13} {terminal:>13}"
183 + );
141 184 }
142 185
143 186 #[cfg(not(feature = "count"))]
@@ -213,3 +256,54 @@
213 256 bytes_after - bytes_before
214 257 )
215 258 }
259 +
260 + /// The residual must produce what the renderer produces.
261 + ///
262 + /// Byte-identity is the right assertion *here* and nowhere else in this design:
263 + /// it is not an acceptance test for staging, it is the check that the emitter
264 + /// dropped nothing. The staged path wins by deleting the tree, not by changing
265 + /// the markup, so at this stage the bytes should still agree. Every further
266 + /// optimisation available to an emitter (fusing adjacent literals, hoisting
267 + /// per-row constants, dropping wrappers whose region name is resolved at emit
268 + /// time) changes the bytes on purpose, and this assertion is what would be
269 + /// relaxed to take them.
270 + fn check_staged_matches(webview: &Webview, program: &[staging::Op]) {
271 + for size in fixture::SIZES {
272 + let rows = fixture::Rows::new(size);
273 + let direct = webview.fragment(&fixture::pane(&rows));
274 + let mut staged = String::new();
275 + staging::exec(program, &rows, &mut staged);
276 + assert_eq!(
277 + direct.len(),
278 + staged.len(),
279 + "staged output differs in length at {size} rows"
280 + );
281 + assert!(
282 + direct == staged,
283 + "staged output differs from the renderer at {size} rows"
284 + );
285 + }
286 + println!("residual verified against the renderer at every row count\n");
287 + }
288 +
289 + /// The heading of the sixth column, which depends on what was compiled in.
290 + #[cfg(feature = "askama")]
291 + const COMPILED_HEAD: &str = "askama";
292 + #[cfg(not(feature = "askama"))]
293 + const COMPILED_HEAD: &str = "webview screen";
294 +
295 + /// The generated template, rendered.
296 + #[cfg(feature = "askama")]
297 + fn measure_compiled(_webview: &Webview, rows: &fixture::Rows, _screen: &str) -> String {
298 + use askama::Template as _;
299 + measure(|| {
300 + let page = compiled::Contacts::new(black_box(rows));
301 + black_box(page.render().expect("the generated template renders"));
302 + })
303 + }
304 +
305 + /// Without the feature this column stays what it was: the whole-document cost.
306 + #[cfg(not(feature = "askama"))]
307 + fn measure_compiled(_webview: &Webview, _rows: &fixture::Rows, screen: &str) -> String {
308 + screen.to_string()
309 + }
@@ -1,0 +1,45 @@
1 + //! The emitted template, compiled.
2 + //!
3 + //! `templates/contacts.html` is generated from the description by
4 + //! [`crate::staging::as_askama_template`] and is never hand-edited. Regenerate
5 + //! it with:
6 + //!
7 + //! ```text
8 + //! QUASI_EMIT_TEMPLATE=crates/quasi-bench/templates/contacts.html \
9 + //! cargo run --release -p quasi-bench --features askama
10 + //! ```
11 + //!
12 + //! This column is the point of the whole spike. The description is the source,
13 + //! the template is an intermediate nobody writes, and Askama compiles that
14 + //! intermediate to straight-line Rust. If this and the staged column are close,
15 + //! then a semantic starting point costs nothing over a compiled template and
16 + //! the residual can stay a data program; if this is much faster, the gap is
17 + //! enum dispatch and the answer is to generate code rather than a table.
18 +
19 + use askama::Template;
20 +
21 + use crate::fixture::{Buyer, Rows, Shared};
22 +
23 + /// The context the generated template reads.
24 + ///
25 + /// Field names are the ones [`crate::staging::expr`] emits, so the template and
26 + /// this struct move together or the build fails, which is the failure mode to
27 + /// want.
28 + #[derive(Template)]
29 + #[template(path = "contacts.html")]
30 + pub(crate) struct Contacts<'a> {
31 + pub(crate) count: usize,
32 + pub(crate) buyers: &'a [Buyer],
33 + pub(crate) shared: &'a [Shared],
34 + }
35 +
36 + impl<'a> Contacts<'a> {
37 + #[must_use]
38 + pub(crate) fn new(rows: &'a Rows) -> Self {
39 + Self {
40 + count: rows.buyers.len(),
41 + buyers: &rows.buyers,
42 + shared: &rows.shared,
43 + }
44 + }
45 + }
@@ -1,0 +1,500 @@
1 + //! Staging: the same description, evaluated twice.
2 + //!
3 + //! A spike, measuring one claim: that a semantic description can be partially
4 + //! evaluated into a residual program, and that the residual reaches the speed
5 + //! of a compiled template while the description stays the only thing anyone
6 + //! writes.
7 + //!
8 + //! # The mechanism
9 + //!
10 + //! [`describe`] is generic over [`Data`]. Two implementations exist:
11 + //!
12 + //! - [`Live`] answers with the real strings a request has.
13 + //! - [`Symbolic`] answers with a fixed-width sentinel per field, and reports
14 + //! whatever row count the emitter asks for.
15 + //!
16 + //! Running `describe` over `Symbolic` and handing the result to the ordinary
17 + //! [`Webview`] renderer produces the screen's markup with sentinels where the
18 + //! data would be. That string is the residual. Splitting it on the sentinels
19 + //! gives a program of literals and holes, and the loop bodies are recovered by
20 + //! rendering twice at different row counts and taking the difference.
21 + //!
22 + //! The renderer is not reimplemented anywhere here. The emitted literals are
23 + //! the renderer's own output, which is what makes this a staging of the
24 + //! existing description layer rather than a second one.
25 + //!
26 + //! # What it does not cover, stated rather than implied
27 + //!
28 + //! `library_contacts` has no conditional in its shape, so nothing here
29 + //! exercises the case where a branch changes the tree. That is the constraint
30 + //! this approach really carries: a Rust `if` inside a shape function has no
31 + //! value for a symbolic pass to branch on, and would have to be expressed as a
32 + //! combinator the emitter can see. 56 `if` sites and 3 `match` sites across the
33 + //! server's shape functions are the size of that.
34 + //!
35 + //! Escaping is not reimplemented either: [`exec`] fills every hole through
36 + //! `makeover_webview::form::escape_into`, the same function the renderer uses,
37 + //! so the guarantee `Node::Text` carries survives into the residual.
38 +
39 + use std::fmt::Write as _;
40 +
41 + use makeover_layout as layout;
42 + use makeover_webview::form::escape_into;
43 + use quasi_http::Serves;
44 + use quasi_router::screen::{Act, Cell, Cells, Column};
45 + use quasi_router::{Action, Node, RegionKind, Slot};
46 + use quasi_webview::Webview;
47 +
48 + use crate::fixture::Rows;
49 +
50 + /// The region id, matching the original screen's.
51 + const REGION: &str = "tab-content";
52 +
53 + /// A field of the buyers table.
54 + #[derive(Clone, Copy)]
55 + pub enum B {
56 + Username,
57 + Email,
58 + Purchases,
59 + Spent,
60 + Last,
61 + }
62 +
63 + /// A field of the shared-with table.
64 + #[derive(Clone, Copy)]
65 + pub enum S {
66 + Name,
67 + Username,
68 + Id,
69 + }
70 +
71 + /// Hole ids. One number per field, plus the scalar in the heading.
72 + const H_COUNT: u16 = 0;
73 +
74 + impl B {
75 + fn hole(self) -> u16 {
76 + match self {
77 + B::Username => 1,
78 + B::Email => 2,
79 + B::Purchases => 3,
80 + B::Spent => 4,
81 + B::Last => 5,
82 + }
83 + }
84 + }
85 +
86 + impl S {
87 + fn hole(self) -> u16 {
88 + match self {
89 + S::Name => 6,
90 + S::Username => 7,
91 + S::Id => 8,
92 + }
93 + }
94 + }
95 +
96 + /// Where a description gets its values.
97 + ///
98 + /// The whole staging story is that this is the only seam. A shape function
99 + /// takes one of these and never touches a database, a request or a clock.
100 + pub trait Data {
101 + fn buyer_rows(&self) -> usize;
102 + fn shared_rows(&self) -> usize;
103 + fn count_label(&self) -> String;
104 + fn buyer(&self, row: usize, field: B) -> String;
105 + fn shared(&self, row: usize, field: S) -> String;
106 + }
107 +
108 + /// The real data, as a request has it.
109 + pub struct Live<'a>(pub &'a Rows);
110 +
111 + impl Data for Live<'_> {
112 + fn buyer_rows(&self) -> usize {
113 + self.0.buyers.len()
114 + }
115 + fn shared_rows(&self) -> usize {
116 + self.0.shared.len()
117 + }
118 + fn count_label(&self) -> String {
119 + self.0.buyers.len().to_string()
120 + }
121 + fn buyer(&self, row: usize, field: B) -> String {
122 + let b = &self.0.buyers[row];
123 + match field {
124 + B::Username => b.username.clone(),
125 + B::Email => b.email.clone(),
126 + B::Purchases => b.purchases.clone(),
127 + B::Spent => b.spent.clone(),
128 + B::Last => b.last_purchase.clone(),
129 + }
130 + }
131 + fn shared(&self, row: usize, field: S) -> String {
132 + let s = &self.0.shared[row];
133 + match field {
134 + S::Name => s.name.clone(),
135 + S::Username => s.username.clone(),
136 + S::Id => s.seller_id.clone(),
137 + }
138 + }
139 + }
140 +
141 + /// A sentinel, fixed width and alphanumeric so nothing escapes it and nothing
142 + /// in real markup collides with it.
143 + fn sentinel(hole: u16) -> String {
144 + format!("ZQH{hole:04}HQZ")
145 + }
146 +
147 + /// The width of one sentinel, which the scanner relies on.
148 + const SENTINEL_LEN: usize = 10;
149 +
150 + /// Values replaced by sentinels, and row counts the emitter chooses.
151 + pub struct Symbolic {
152 + pub buyers: usize,
153 + pub shared: usize,
154 + }
155 +
156 + impl Data for Symbolic {
157 + fn buyer_rows(&self) -> usize {
158 + self.buyers
159 + }
160 + fn shared_rows(&self) -> usize {
161 + self.shared
162 + }
163 + fn count_label(&self) -> String {
164 + sentinel(H_COUNT)
165 + }
166 + fn buyer(&self, _row: usize, field: B) -> String {
167 + sentinel(field.hole())
168 + }
169 + fn shared(&self, _row: usize, field: S) -> String {
170 + sentinel(field.hole())
171 + }
172 + }
173 +
174 + /// The pane, written once, evaluated by either implementation.
175 + ///
176 + /// This is `fixture::pane` with its data reads behind [`Data`] and nothing else
177 + /// changed. Every column width, priority and action is the original screen's.
178 + #[must_use]
179 + pub fn describe<D: Data>(d: &D) -> Node {
180 + let slot = Slot::new(REGION, RegionKind::Pane)
181 + .with(Node::section(format!("Your Buyers ({})", d.count_label())))
182 + .with(Node::text(
183 + "Buyers who opted to share their email with you at purchase time.",
184 + ))
185 + .with(buyers_table(d))
186 + .with(Node::section("Shared With"))
187 + .with(Node::text(
188 + "You've shared your email with these creators. You can revoke sharing at any time.",
189 + ))
190 + .with(shared_table(d));
191 +
192 + Node::Region(slot)
193 + }
194 +
195 + fn buyers_table<D: Data>(d: &D) -> Node {
196 + Node::Table {
197 + columns: vec![
198 + Column::new("Username")
199 + .width(layout::Width::Content)
200 + .priority(layout::Priority::Essential),
201 + Column::new("Email")
202 + .width(layout::Width::Fill)
203 + .priority(layout::Priority::Essential),
204 + Column::new("Purchases").width(layout::Width::Content),
205 + Column::new("Total Spent").width(layout::Width::Content),
206 + Column::new("Last Purchase")
207 + .width(layout::Width::Content)
208 + .priority(layout::Priority::Optional),
209 + ],
210 + rows: (0..d.buyer_rows())
211 + .map(|r| {
212 + Cells::new([
213 + Cell::new(d.buyer(r, B::Username))
214 + .activate(Action::get(format!("/u/{}", d.buyer(r, B::Username)))),
215 + Cell::new(d.buyer(r, B::Email))
216 + .activate(Action::external(format!("mailto:{}", d.buyer(r, B::Email)))),
217 + Cell::new(d.buyer(r, B::Purchases)),
218 + Cell::new(d.buyer(r, B::Spent)),
219 + Cell::new(d.buyer(r, B::Last)),
220 + ])
221 + })
222 + .collect(),
223 + more: None,
224 + }
225 + }
226 +
227 + fn shared_table<D: Data>(d: &D) -> Node {
228 + Node::Table {
229 + columns: vec![
230 + Column::new("Creator")
231 + .width(layout::Width::Fill)
232 + .priority(layout::Priority::Essential),
233 + Column::new("")
234 + .width(layout::Width::Content)
235 + .priority(layout::Priority::Essential),
236 + ],
237 + rows: (0..d.shared_rows())
238 + .map(|r| {
239 + Cells::new([
240 + Cell::new(d.shared(r, S::Name))
241 + .activate(Action::get(format!("/u/{}", d.shared(r, S::Username)))),
242 + Cell::acts([Act::new(
243 + "Revoke",
244 + Action::delete(format!(
245 + "/library/tabs/contacts/revoke/{}",
246 + d.shared(r, S::Id)
247 + )),
248 + )]),
249 + ])
250 + })
251 + .collect(),
252 + more: None,
253 + }
254 + }
255 +
256 + /// One instruction of the residual program.
257 + pub enum Op {
258 + /// Markup the renderer produced that no data reaches.
259 + Lit(String),
260 + /// A value, escaped on the way in.
261 + Hole(u16),
262 + /// The buyers table body, once per row.
263 + BuyerRows(Vec<Op>),
264 + /// The shared-with table body, once per row.
265 + SharedRows(Vec<Op>),
266 + }
267 +
268 + /// The emitter: run the description symbolically and keep what comes back.
269 + ///
270 + /// Three renders rather than one. A single symbolic render gives the markup but
271 + /// not where a row begins and ends, so the loop bodies are recovered by
272 + /// rendering again with one more row in one table at a time and taking the
273 + /// difference. The renderer is the same one a request uses.
274 + ///
275 + /// # Panics
276 + ///
277 + /// If a loop body cannot be recovered, which would mean the renderer's output
278 + /// is not `prefix + row * n + suffix` for this screen. Loud on purpose: a
279 + /// silent fallback here would report a staged number for an unstaged program.
280 + #[must_use]
281 + pub fn emit() -> Vec<Op> {
282 + let webview = Webview::new();
283 + let render = |buyers, shared| webview.fragment(&describe(&Symbolic { buyers, shared }));
284 +
285 + let base = render(1, 1);
286 + let more_buyers = render(2, 1);
287 + let more_shared = render(1, 2);
288 +
289 + // The three symbolic renders, for when a shape stops being `P R Q` and the
290 + // recovery below starts refusing. Diffing them is how the one bug this
291 + // emitter has had was found.
292 + if let Ok(dir) = std::env::var("QUASI_DUMP") {
293 + let dir = std::path::Path::new(&dir);
294 + std::fs::write(dir.join("emit-base.html"), &base).ok();
295 + std::fs::write(dir.join("emit-buyers-2.html"), &more_buyers).ok();
296 + std::fs::write(dir.join("emit-shared-2.html"), &more_shared).ok();
297 + }
298 + let buyer_row = repeated_unit(&base, &more_buyers).expect("a buyers row repeats");
299 + let shared_row = repeated_unit(&base, &more_shared).expect("a shared row repeats");
300 +
301 + let at_buyer = base
302 + .find(&buyer_row)
303 + .expect("the buyers row is in the page");
304 + let prefix = &base[..at_buyer];
305 + let rest = &base[at_buyer + buyer_row.len()..];
306 +
307 + let at_shared = rest
308 + .find(&shared_row)
309 + .expect("the shared row is in the page");
310 + let middle = &rest[..at_shared];
311 + let suffix = &rest[at_shared + shared_row.len()..];
312 +
313 + let mut ops = compile(prefix);
314 + ops.push(Op::BuyerRows(compile(&buyer_row)));
315 + ops.extend(compile(middle));
316 + ops.push(Op::SharedRows(compile(&shared_row)));
317 + ops.extend(compile(suffix));
318 + ops
319 + }
320 +
321 + /// The block `wide` holds one more time than `narrow` does.
322 + ///
323 + /// `narrow` is `P R Q` and `wide` is `P R R Q`. The length difference is `R`
324 + /// outright, since the symbolic rows are the same string, and the common prefix
325 + /// runs to the end of the first `R`. So `R` is the span ending where the two
326 + /// first disagree.
327 + ///
328 + /// The prefix and the suffix deliberately overlap inside `narrow`, by exactly
329 + /// `R`. An earlier version of this forbade that overlap and could therefore
330 + /// never find a repeat at all.
331 + fn repeated_unit(narrow: &str, wide: &str) -> Option<String> {
332 + let unit = wide.len().checked_sub(narrow.len())?;
333 + if unit == 0 {
334 + return None;
335 + }
336 +
337 + let n = narrow.as_bytes();
338 + let w = wide.as_bytes();
339 + let mut head = 0;
340 + while head < n.len() && head < w.len() && n[head] == w[head] {
341 + head += 1;
342 + }
343 +
344 + let start = head.checked_sub(unit)?;
345 + if !narrow.is_char_boundary(start) || !narrow.is_char_boundary(head) {
346 + return None;
347 + }
348 + let unit_text = &narrow[start..head];
349 +
350 + // Removing one copy from `wide` has to give `narrow` back, or the shape is
351 + // not `P R Q` and the emitter must not pretend it is.
352 + let mut rebuilt = String::with_capacity(narrow.len());
353 + rebuilt.push_str(&wide[..start]);
354 + rebuilt.push_str(&wide[start + unit..]);
355 + if rebuilt != narrow {
356 + return None;
357 + }
358 +
359 + Some(unit_text.to_string())
360 + }
361 +
362 + /// Split a chunk of emitted markup into literals and holes.
363 + fn compile(chunk: &str) -> Vec<Op> {
364 + let mut ops = Vec::new();
365 + let mut rest = chunk;
366 + while let Some(at) = rest.find("ZQH") {
367 + let candidate = &rest[at..];
368 + let parsed = candidate
369 + .get(..SENTINEL_LEN)
370 + .filter(|s| s.ends_with("HQZ"))
371 + .and_then(|s| s[3..7].parse::<u16>().ok());
372 + match parsed {
373 + Some(hole) => {
374 + if at > 0 {
375 + ops.push(Op::Lit(rest[..at].to_string()));
376 + }
377 + ops.push(Op::Hole(hole));
378 + rest = &rest[at + SENTINEL_LEN..];
379 + }
380 + // "ZQH" that is not a sentinel. Keep it and carry on past it.
381 + None => {
382 + ops.push(Op::Lit(rest[..at + 3].to_string()));
383 + rest = &rest[at + 3..];
384 + }
385 + }
386 + }
387 + if !rest.is_empty() {
388 + ops.push(Op::Lit(rest.to_string()));
389 + }
390 + ops
391 + }
392 +
393 + /// Run the residual against real data.
394 + pub fn exec(ops: &[Op], rows: &Rows, out: &mut String) {
395 + run(ops, rows, 0, out);
396 + }
397 +
398 + fn run(ops: &[Op], rows: &Rows, row: usize, out: &mut String) {
399 + for op in ops {
400 + match op {
401 + Op::Lit(s) => out.push_str(s),
402 + Op::Hole(h) => fill(*h, rows, row, out),
403 + Op::BuyerRows(body) => {
404 + for r in 0..rows.buyers.len() {
405 + run(body, rows, r, out);
406 + }
407 + }
408 + Op::SharedRows(body) => {
409 + for r in 0..rows.shared.len() {
410 + run(body, rows, r, out);
411 + }
412 + }
413 + }
414 + }
415 + }
416 +
417 + /// One value into the buffer, through the renderer's own escaper.
418 + fn fill(hole: u16, rows: &Rows, row: usize, out: &mut String) {
419 + match hole {
420 + // A length: no escaping to do, since a decimal integer has nothing to
421 + // escape.
422 + 0 => {
423 + let _ = write!(out, "{}", rows.buyers.len());
424 + }
425 + 1 => escape_into(&rows.buyers[row].username, out),
426 + 2 => escape_into(&rows.buyers[row].email, out),
427 + 3 => escape_into(&rows.buyers[row].purchases, out),
428 + 4 => escape_into(&rows.buyers[row].spent, out),
429 + 5 => escape_into(&rows.buyers[row].last_purchase, out),
430 + 6 => escape_into(&rows.shared[row].name, out),
431 + 7 => escape_into(&rows.shared[row].username, out),
432 + 8 => escape_into(&rows.shared[row].seller_id, out),
433 + _ => {}
434 + }
435 + }
436 +
437 + /// The residual, written out as an Askama template.
438 + ///
439 + /// This is the proposal stated exactly: the description is the source, and the
440 + /// template is a generated intermediate rather than something anyone authors.
441 + /// Holes become `{{ }}` and the two loop bodies become `{% for %}`, so what
442 + /// comes out is an ordinary Askama file that compiles to straight-line Rust.
443 + ///
444 + /// # Panics
445 + ///
446 + /// If the emitted markup contains an Askama opening delimiter, which would make
447 + /// the generated file mean something other than the markup it came from. It
448 + /// does not today; the check is here so that it cannot start silently.
449 + #[must_use]
450 + pub fn as_askama_template(ops: &[Op]) -> String {
451 + let mut out = String::new();
452 + write_template(ops, None, &mut out);
453 + out
454 + }
455 +
456 + fn write_template(ops: &[Op], binding: Option<&str>, out: &mut String) {
457 + for op in ops {
458 + match op {
459 + Op::Lit(s) => {
460 + assert!(
461 + !s.contains("{{") && !s.contains("{%") && !s.contains("{#"),
462 + "emitted markup carries an Askama delimiter"
463 + );
464 + out.push_str(s);
465 + }
466 + Op::Hole(h) => {
467 + out.push_str("{{ ");
468 + out.push_str(&expr(*h, binding));
469 + out.push_str(" }}");
470 + }
471 + Op::BuyerRows(body) => {
472 + out.push_str("{% for b in buyers %}");
473 + write_template(body, Some("b"), out);
474 + out.push_str("{% endfor %}");
475 + }
476 + Op::SharedRows(body) => {
477 + out.push_str("{% for s in shared %}");
478 + write_template(body, Some("s"), out);
479 + out.push_str("{% endfor %}");
480 + }
481 + }
482 + }
483 + }
484 +
485 + /// The template expression one hole becomes.
486 + fn expr(hole: u16, binding: Option<&str>) -> String {
487 + let field = match hole {
488 + 0 => return "count".to_string(),
489 + 1 => "username",
490 + 2 => "email",
491 + 3 => "purchases",
492 + 4 => "spent",
493 + 5 => "last_purchase",
494 + 6 => "name",
495 + 7 => "username",
496 + 8 => "seller_id",
497 + _ => "unknown",
498 + };
499 + format!("{}.{field}", binding.unwrap_or("self"))
500 + }
@@ -1,0 +1,1 @@
1 + <div id="tab-content" class="region pane"><h2 class="heading">Your Buyers ({{ count }})</h2><p class="text">Buyers who opted to share their email with you at purchase time.</p><div role="table" class="table"><div role="row" class="table-head"><span role="columnheader" class="table-heading col-Username cell-content cell-keeps">Username</span><span role="columnheader" class="table-heading col-Email cell-fill cell-keeps">Email</span><span role="columnheader" class="table-heading col-Purchases cell-content cell-drops-next">Purchases</span><span role="columnheader" class="table-heading col-Total-Spent cell-content cell-drops-next">Total Spent</span><span role="columnheader" class="table-heading col-Last-Purchase cell-content cell-drops-first">Last Purchase</span></div><{% for b in buyers %}div role="row" class="table-row" data-row><div class="cell col-Username cell-content cell-keeps"><a class="cell-link" data-act href="/u/{{ b.username }}" hx-get="/u/{{ b.username }}" hx-swap="outerMorph">{{ b.username }}</a></div><div class="cell col-Email cell-fill cell-keeps"><a class="cell-link" data-act href="mailto:{{ b.email }}" target="_blank" rel="noopener noreferrer">{{ b.email }}</a></div><div class="cell col-Purchases cell-content cell-drops-next cell-value">{{ b.purchases }}</div><div class="cell col-Total-Spent cell-content cell-drops-next cell-value">{{ b.spent }}</div><div class="cell col-Last-Purchase cell-content cell-drops-first cell-value">{{ b.last_purchase }}</div></div><{% endfor %}/div><h2 class="heading">Shared With</h2><p class="text">You&#39;ve shared your email with these creators. You can revoke sharing at any time.</p><div role="table" class="table"><div role="row" class="table-head"><span role="columnheader" class="table-heading col-Creator cell-fill cell-keeps">Creator</span><span role="columnheader" class="table-heading col- cell-content cell-keeps"></span></div><{% for s in shared %}div role="row" class="table-row" data-row><div class="cell col-Creator cell-fill cell-keeps"><a class="cell-link" data-act href="/u/{{ s.username }}" hx-get="/u/{{ s.username }}" hx-swap="outerMorph">{{ s.name }}</a></div><div class="cell col- cell-content cell-keeps"><span class="cell-actions"><button type="button" class="button" data-act hx-delete="/library/tabs/contacts/revoke/{{ s.seller_id }}" hx-swap="outerMorph">Revoke</button></span></div></div><{% endfor %}/div><div class="anchored" id="tab-content-anchored" data-menu="anchored" hidden></div></div>