Skip to main content

max / quasi

Read a screen's copy from a content file, and unroll the loop `for x in copy "file" as key` reads TOML while the macro expands and writes the body out once per entry, so no loop reaches the tree. A proc macro cannot evaluate a Rust `const`, which is why `for part in SECTIONS` survived into whatever the macro emitted however the emitter was written; copy the macro can read is copy the macro can write out. The path resolves against CARGO_MANIFEST_DIR, so the test is an ordinary integration test rather than a trybuild case. Each file read is named in an `include_bytes!` so rustc rebuilds when the copy changes. Values are strings, braces are doubled on the way in, and a bare binder is refused: a content file holds words, and a hole in one is structure that wandered into the wrong file.
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-07 18:35 UTC
Signed with PGP, not checked
Commit: 2c39a1156c2388467aebb4d47d24275e86b8e93c
Parent: ea3410b
7 files changed, +394 insertions, -19 deletions
M Cargo.lock +12 -11
@@ -1029,7 +1029,7 @@
1029 1029 "libc",
1030 1030 "option-ext",
1031 1031 "redox_users",
1032 - "windows-sys 0.61.2",
1032 + "windows-sys 0.59.0",
1033 1033 ]
1034 1034
1035 1035 [[package]]
@@ -1328,7 +1328,7 @@
1328 1328 checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
1329 1329 dependencies = [
1330 1330 "libc",
1331 - "windows-sys 0.61.2",
1331 + "windows-sys 0.59.0",
1332 1332 ]
1333 1333
1334 1334 [[package]]
@@ -3526,12 +3526,13 @@
3526 3526
3527 3527 [[package]]
3528 3528 name = "quasi-declare"
3529 - version = "0.1.8"
3529 + version = "0.1.9"
3530 3530 dependencies = [
3531 3531 "proc-macro2",
3532 3532 "quasi-router",
3533 3533 "quote",
3534 3534 "syn 2.0.119",
3535 + "toml 1.1.4+spec-1.1.0",
3535 3536 "trybuild",
3536 3537 ]
3537 3538
@@ -3906,7 +3907,7 @@
3906 3907 "errno",
3907 3908 "libc",
3908 3909 "linux-raw-sys",
3909 - "windows-sys 0.61.2",
3910 + "windows-sys 0.59.0",
3910 3911 ]
3911 3912
3912 3913 [[package]]
@@ -4775,7 +4776,7 @@
4775 4776 "serde_with",
4776 4777 "swift-rs",
4777 4778 "thiserror 2.0.20",
4778 - "toml 1.1.4+spec-1.1.0",
4779 + "toml 0.9.12+spec-1.1.0",
4779 4780 "url",
4780 4781 "urlpattern",
4781 4782 "uuid",
@@ -4814,7 +4815,7 @@
4814 4815 "getrandom 0.4.3",
4815 4816 "once_cell",
4816 4817 "rustix",
4817 - "windows-sys 0.61.2",
4818 + "windows-sys 0.59.0",
4818 4819 ]
4819 4820
4820 4821 [[package]]
@@ -5656,7 +5657,7 @@
5656 5657 source = "registry+https://github.com/rust-lang/crates.io-index"
5657 5658 checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
5658 5659 dependencies = [
5659 - "windows-sys 0.61.2",
5660 + "windows-sys 0.59.0",
5660 5661 ]
5661 5662
5662 5663 [[package]]
@@ -6313,10 +6314,6 @@
6313 6314 "winnow 1.0.4",
6314 6315 ]
6315 6316
6316 - [[patch.unused]]
6317 - name = "quasi-type"
6318 - version = "0.1.3"
6319 -
6320 6317 [[patch.unused]]
6321 6318 name = "kberg"
6322 6319 version = "0.1.0"
@@ -6333,6 +6330,10 @@
6333 6330 name = "tagtree"
6334 6331 version = "0.4.1"
6335 6332
6333 + [[patch.unused]]
6334 + name = "quasi-type"
6335 + version = "0.1.3"
6336 +
6336 6337 [[patch.unused]]
6337 6338 name = "synckit-client"
6338 6339 version = "0.10.0"
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "quasi-declare"
3 - version = "0.1.8"
3 + version = "0.1.9"
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
@@ -16,6 +16,10 @@
16 16 proc-macro2 = "1"
17 17 quote = "1"
18 18 syn = { version = "2", features = ["full", "parsing", "printing", "proc-macro"] }
19 + # Screen copy leaves Rust as TOML and the macro reads it (quasicoherent
20 + # `98fbee62`). A build-time dependency of a proc macro, so nothing of it
21 + # reaches a consumer's binary.
22 + toml = "1"
19 23
20 24 [lints]
21 25 workspace = true
@@ -17,8 +17,17 @@
17 17 //! ordinary and expected. What is never acceptable is an escape hatch that
18 18 //! admits an expression, a block in argument position, a closure or a struct
19 19 //! literal, because those four are what keep `Node: Eq` intact.
20 + //!
21 + //! # One production reads a file
22 + //!
23 + //! `for <binder> in copy "<file>" as <key>` is copy the macro reads at
24 + //! expansion time and writes out, one set of members per entry. It is not an
25 + //! escape hatch: what a content file may hold is text, and the loop is gone
26 + //! before `ast` sees it. See `copy.rs` for the split it implements and for
27 + //! why the reading has to be marked with an `include_bytes!`.
20 28
21 29 mod ast;
30 + mod copy;
22 31 mod emit;
23 32 mod fill;
24 33 mod pairs;
@@ -46,13 +55,34 @@
46 55 /// ```
47 56 #[proc_macro]
48 57 pub fn declare(input: TokenStream) -> TokenStream {
58 + // Anything a refused expansion read, before this one reads its own.
59 + drop(copy::taken());
49 60 let declaration = parse_macro_input!(input as ast::Declaration);
61 + let copied = copy::taken();
50 62 match expand(&declaration) {
51 - Ok(expansion) => expansion.into(),
63 + Ok(expansion) => {
64 + let watched = watch(&copied);
65 + quote::quote! { #expansion #watched }.into()
66 + }
52 67 Err(error) => error.into_compile_error().into(),
53 68 }
54 69 }
55 70
71 + /// What makes rustc rebuild when a content file changes.
72 + ///
73 + /// A macro that reads a file reads it behind the compiler's back. Naming each
74 + /// one in an `include_bytes!` puts it back in front: the path is absolute
75 + /// because the emitted code lands in whatever file the invocation sits in, and
76 + /// `include_bytes!` resolves a relative path against that file rather than
77 + /// against the manifest the macro resolved with.
78 + fn watch(copied: &[std::path::PathBuf]) -> proc_macro2::TokenStream {
79 + let markers = copied.iter().map(|path| {
80 + let path = path.to_string_lossy();
81 + quote::quote! { const _: &[u8] = include_bytes!(#path); }
82 + });
83 + quote::quote! { #(#markers)* }
84 + }
85 +
56 86 /// The shape's function, and its staged twin if it asked for one.
57 87 fn expand(declaration: &ast::Declaration) -> syn::Result<proc_macro2::TokenStream> {
58 88 pairs::check(&declaration.items)?;
@@ -19,6 +19,7 @@
19 19 Action, Arg, Declaration, Emission, Guard, Hole, HoleRoot, Interpolated, Item, Modifier, Param,
20 20 Pattern, Predicate, RegionKind, Source, Step, StrPart,
21 21 };
22 + use crate::copy;
22 23
23 24 /// The modifiers, and how many arguments each takes.
24 25 ///
@@ -242,10 +243,7 @@
242 243 let returns: Type = input.parse()?;
243 244 input.parse::<Token![;]>()?;
244 245
245 - let mut items = Vec::new();
246 - while !input.is_empty() {
247 - items.push(input.parse()?);
248 - }
246 + let items = items(input)?;
249 247
250 248 Ok(Self {
251 249 docs,
@@ -1043,9 +1041,24 @@
1043 1041 fn block(input: ParseStream) -> Result<Vec<Item>> {
1044 1042 let body;
1045 1043 braced!(body in input);
1044 + items(&body)
1045 + }
1046 +
1047 + /// Every item until the input runs out.
1048 + ///
1049 + /// One place rather than two, because a copy loop is not one item: it is the
1050 + /// items its file has entries for, written out here so nothing downstream can
1051 + /// tell it was ever a loop. `pub(crate)` because a copy loop's own body is
1052 + /// parsed here too, once per entry, and a body that went through anything else
1053 + /// would be the one place a nested copy loop stopped working.
1054 + pub fn items(input: ParseStream) -> Result<Vec<Item>> {
1046 1055 let mut items = Vec::new();
1047 - while !body.is_empty() {
1048 - items.push(body.parse()?);
1056 + while !input.is_empty() {
1057 + if copy::ahead(input) {
1058 + items.extend(copy::expand(input)?);
1059 + continue;
1060 + }
1061 + items.push(input.parse()?);
1049 1062 }
1050 1063 Ok(items)
1051 1064 }
@@ -1,0 +1,249 @@
1 + //! `for <binder> in copy "<file>" as <key>`: copy read at macro time.
2 + //!
3 + //! The production that lets a screen's words leave Rust. `policy.rs` held five
4 + //! prose sections and seven document records as `const` arrays and looped over
5 + //! them, which put the page's copy behind a Rust file and behind a `for` loop
6 + //! the emitter cannot fold: a proc macro cannot evaluate a `const`, so the loop
7 + //! survives into whatever is emitted, however the emitter is written.
8 + //!
9 + //! This reads the copy instead. The file is TOML, the key names an array of
10 + //! tables, and the loop is unrolled here into one set of items per entry with
11 + //! `binder.field` replaced by the value it holds. Nothing about the loop
12 + //! reaches [`crate::ast`]: what the rest of the crate sees is the members
13 + //! written out, exactly as if somebody had typed them.
14 + //!
15 + //! # What may live in a content file, and what may not
16 + //!
17 + //! Copy with no holes. The split is quasicoherent `98fbee62`'s: pure values
18 + //! with no reference to code go in the file, structure with holes stays in the
19 + //! macro, and computation stays a supplier function. So a value is substituted
20 + //! as text and its braces are doubled on the way in, which is the escape the
21 + //! ordinary string production already uses. A content file that wants a hole
22 + //! has stopped being copy and is structure; move it back into the declaration
23 + //! rather than growing the format.
24 + //!
25 + //! Values are strings and nothing else. A number or a bool in a content file
26 + //! is refused naming the field, because a screen that reads copy is reading
27 + //! words, and the moment the format grows types it is a description language
28 + //! with two spellings.
29 +
30 + use std::cell::RefCell;
31 + use std::collections::BTreeMap;
32 + use std::path::PathBuf;
33 +
34 + use proc_macro2::{Group, Ident, Literal, TokenStream, TokenTree};
35 + use syn::parse::{Parse, ParseStream};
36 + use syn::{LitStr, Result, Token, braced};
37 +
38 + use crate::ast::Item;
39 +
40 + // The files this expansion read, absolute, in the order they were read.
41 + //
42 + // A thread-local because the reading happens inside a `Parse` impl, whose
43 + // signature has nowhere to thread a collector through, and one macro expansion
44 + // is one pass on one thread. `crate::declare` drains it directly after parsing
45 + // and turns each path into an `include_bytes!`, which is what makes rustc
46 + // rebuild the crate when the copy changes. Without that marker a content edit
47 + // is invisible: the file is read by the macro and named nowhere the compiler
48 + // looks.
49 + thread_local! {
50 + static READ: RefCell<Vec<PathBuf>> = const { RefCell::new(Vec::new()) };
51 + }
52 +
53 + /// The files read since the last drain.
54 + pub fn taken() -> Vec<PathBuf> {
55 + READ.with(|read| std::mem::take(&mut *read.borrow_mut()))
56 + }
57 +
58 + /// Whether the item ahead is a copy loop rather than an ordinary one.
59 + ///
60 + /// Told apart by the word after `in`, which is the same rule the rest of the
61 + /// grammar uses: `copy` is not a hole, because a hole is lowercase and bound,
62 + /// and no binding can be named `copy` without this refusing it here.
63 + pub fn ahead(input: ParseStream) -> bool {
64 + let fork = input.fork();
65 + if fork.parse::<Token![for]>().is_err() {
66 + return false;
67 + }
68 + if fork.peek(Token![&]) && fork.parse::<Token![&]>().is_err() {
69 + return false;
70 + }
71 + if fork.parse::<Ident>().is_err() || fork.parse::<Token![in]>().is_err() {
72 + return false;
73 + }
74 + fork.parse::<Ident>().is_ok_and(|word| word == "copy")
75 + }
76 +
77 + /// Read the file and write the body out once per entry.
78 + pub fn expand(input: ParseStream) -> Result<Vec<Item>> {
79 + input.parse::<Token![for]>()?;
80 + if input.peek(Token![&]) {
81 + let ampersand = input.parse::<Token![&]>()?;
82 + return Err(syn::Error::new(
83 + ampersand.span,
84 + "a copy loop binds text and not a reference: drop the `&`",
85 + ));
86 + }
87 + let binder: Ident = input.parse()?;
88 + input.parse::<Token![in]>()?;
89 + input.parse::<Ident>()?;
90 + let file: LitStr = input.parse()?;
91 + input.parse::<Token![as]>()?;
92 + let key: Ident = input.parse()?;
93 +
94 + let body;
95 + braced!(body in input);
96 + let template: TokenStream = body.parse()?;
97 +
98 + let mut items = Vec::new();
99 + for entry in read(&file, &key)? {
100 + let filled = substitute(template.clone(), &binder, &entry, &file)?;
101 + items.extend(syn::parse2::<Body>(filled)?.0);
102 + }
103 + Ok(items)
104 + }
105 +
106 + /// A body's items, so a filled template can be parsed as what it became.
107 + struct Body(Vec<Item>);
108 +
109 + impl Parse for Body {
110 + fn parse(input: ParseStream) -> Result<Self> {
111 + Ok(Self(crate::parse::items(input)?))
112 + }
113 + }
114 +
115 + /// One entry's fields, by name.
116 + type Entry = BTreeMap<String, String>;
117 +
118 + /// The entries under `key`, read from the file the declaration named.
119 + ///
120 + /// The path is relative to the manifest of the crate being compiled, which is
121 + /// the only root a macro can resolve against that does not depend on which file
122 + /// the invocation sits in.
123 + fn read(file: &LitStr, key: &Ident) -> Result<Vec<Entry>> {
124 + let root = std::env::var("CARGO_MANIFEST_DIR").map_err(|_| {
125 + syn::Error::new(
126 + file.span(),
127 + "`copy` needs CARGO_MANIFEST_DIR, which cargo sets and a bare rustc does not",
128 + )
129 + })?;
130 + let path = PathBuf::from(root).join(file.value());
131 + let text = std::fs::read_to_string(&path).map_err(|error| {
132 + syn::Error::new(
133 + file.span(),
134 + format!("cannot read {}: {error}", path.display()),
135 + )
136 + })?;
137 + READ.with(|read| read.borrow_mut().push(path.clone()));
138 +
139 + let table: toml::Table = text.parse().map_err(|error| {
140 + syn::Error::new(
141 + file.span(),
142 + format!("{} is not TOML: {error}", path.display()),
143 + )
144 + })?;
145 + let named = format!("`{key}` in {}", path.display());
146 + let Some(value) = table.get(&key.to_string()) else {
147 + return Err(syn::Error::new(key.span(), format!("no {named}")));
148 + };
149 + let Some(array) = value.as_array() else {
150 + return Err(syn::Error::new(
151 + key.span(),
152 + format!("{named} is not an array of tables"),
153 + ));
154 + };
155 +
156 + let mut entries = Vec::new();
157 + for (index, element) in array.iter().enumerate() {
158 + let Some(fields) = element.as_table() else {
159 + return Err(syn::Error::new(
160 + key.span(),
161 + format!("{named} entry {index} is not a table"),
162 + ));
163 + };
164 + let mut entry = Entry::new();
165 + for (name, field) in fields {
166 + let Some(text) = field.as_str() else {
167 + return Err(syn::Error::new(
168 + key.span(),
169 + format!("{named} entry {index} field `{name}` is not a string: copy is words"),
170 + ));
171 + };
172 + entry.insert(name.clone(), text.to_owned());
173 + }
174 + entries.push(entry);
175 + }
176 + Ok(entries)
177 + }
178 +
179 + /// The template with every `binder.field` replaced by the text it names.
180 + ///
181 + /// A token walk rather than a walk of the syntax tree, because the tree is not
182 + /// built yet and building it first would mean a second traversal of every
183 + /// production in the grammar, drifting from the first the way a second emitter
184 + /// would. `binder.field` is three tokens in every position the grammar admits,
185 + /// which is what makes the walk exact rather than approximate.
186 + fn substitute(
187 + template: TokenStream,
188 + binder: &Ident,
189 + entry: &Entry,
190 + file: &LitStr,
191 + ) -> Result<TokenStream> {
192 + let mut filled = Vec::new();
193 + let mut tokens = template.into_iter().peekable();
194 +
195 + while let Some(token) = tokens.next() {
196 + match token {
197 + TokenTree::Group(group) => {
198 + let inner = substitute(group.stream(), binder, entry, file)?;
199 + let mut replacement = Group::new(group.delimiter(), inner);
200 + replacement.set_span(group.span());
201 + filled.push(TokenTree::Group(replacement));
202 + }
203 + TokenTree::Ident(ident) if ident == *binder => {
204 + let field = field_after(&mut tokens, &ident)?;
205 + let Some(text) = entry.get(&field.to_string()) else {
206 + return Err(syn::Error::new(
207 + field.span(),
208 + format!("{} has no `{field}` in {}", binder, file.value()),
209 + ));
210 + };
211 + let mut literal = Literal::string(&escaped(text));
212 + literal.set_span(ident.span());
213 + filled.push(TokenTree::Literal(literal));
214 + }
215 + other => filled.push(other),
216 + }
217 + }
218 + Ok(filled.into_iter().collect())
219 + }
220 +
221 + /// The field named after the binder, or an error saying the binder is text.
222 + fn field_after(
223 + tokens: &mut std::iter::Peekable<proc_macro2::token_stream::IntoIter>,
224 + binder: &Ident,
225 + ) -> Result<Ident> {
226 + let lone = || {
227 + syn::Error::new(
228 + binder.span(),
229 + format!("`{binder}` is one entry's copy: name a field, as `{binder}.heading`"),
230 + )
231 + };
232 + match tokens.next() {
233 + Some(TokenTree::Punct(punct)) if punct.as_char() == '.' => {}
234 + _ => return Err(lone()),
235 + }
236 + match tokens.next() {
237 + Some(TokenTree::Ident(field)) => Ok(field),
238 + _ => Err(lone()),
239 + }
240 + }
241 +
242 + /// Copy with its braces doubled, so the string production reads it as text.
243 + ///
244 + /// A brace in copy is a brace somebody typed. A hole in copy is structure that
245 + /// wandered into the wrong file, and doubling is what makes the difference
246 + /// impossible to reach by accident rather than a thing to remember.
247 + fn escaped(text: &str) -> String {
248 + text.replace('{', "{{").replace('}', "}}")
249 + }
@@ -1,0 +1,14 @@
1 + # Fixture copy for the `copy` production's test. Two entries, so the test can
2 + # tell an unrolled loop from a loop that ran once.
3 +
4 + [[rows]]
5 + label = "First"
6 + detail = "the one at the top"
7 +
8 + [[rows]]
9 + label = "Second"
10 + detail = "the one under it"
11 +
12 + # A brace in copy is a brace somebody typed, never a hole.
13 + [[braced]]
14 + label = "{not a hole}"
@@ -1,0 +1,64 @@
1 + //! Copy read from a content file is written out, once per entry.
2 + //!
3 + //! The production quasicoherent `98fbee62` added, tested where it can be:
4 + //! `CARGO_MANIFEST_DIR` is what the path resolves against, so this has to be an
5 + //! ordinary integration test of this crate rather than a trybuild case, whose
6 + //! generated project has a manifest directory of its own.
7 + //!
8 + //! What is asserted is that the loop is gone. Two entries in the file produce
9 + //! two rows built by one description that names neither of them, which is only
10 + //! true if the macro read the file and wrote the members out.
11 +
12 + use quasi_declare::declare;
13 +
14 + declare! {
15 + /// Every row the fixture names, in the order it names them.
16 + shape rows() -> Node;
17 +
18 + region "copy" as Pane {
19 + list {
20 + for entry in copy "tests/content/copy.toml" as rows {
21 + row entry.label {
22 + secondary entry.detail;
23 + }
24 + }
25 + }
26 + }
27 + }
28 +
29 + declare! {
30 + /// A brace in copy, which is text and not a hole.
31 + shape braces() -> Node;
32 +
33 + region "braces" as Pane {
34 + for entry in copy "tests/content/copy.toml" as braced {
35 + text entry.label;
36 + }
37 + }
38 + }
39 +
40 + #[test]
41 + fn the_file_supplies_one_member_per_entry() {
42 + let rendered = format!("{:?}", rows());
43 +
44 + assert!(rendered.contains("First"), "the first row is missing");
45 + assert!(
46 + rendered.contains("the one at the top"),
47 + "its detail is missing"
48 + );
49 + assert!(rendered.contains("Second"), "the second row is missing");
50 + assert!(
51 + rendered.contains("the one under it"),
52 + "its detail is missing"
53 + );
54 + }
55 +
56 + #[test]
57 + fn a_brace_in_copy_is_a_brace() {
58 + let rendered = format!("{:?}", braces());
59 +
60 + assert!(
61 + rendered.contains("{not a hole}"),
62 + "a brace in copy was read as an interpolation"
63 + );
64 + }