Skip to main content

max / quasi

Let a copy field hold a list of words A content file's field may now be an array of strings, read by a loop nested inside the copy loop: for profile in copy "content/use-cases.toml" as profiles { label profile.title; list { for feature in profile.features { row feature; } } } The seal this amends is against types, and a list of words is not one. A number or a bool is still refused naming the field, a list holds strings so a content file cannot grow a tree, and the inner loop is unrolled by the same pass as the outer one, so neither reaches the AST. What forced it is MNW's /use-cases: nine cards each ending in a feature list. Without it the choices were nine numbered fields with the count written into the declaration, or leaving PROFILE_LIST a Rust const and keeping a loop the emitter cannot fold, which is what the production exists to remove. The inner binder is the element rather than a record, which is what tells the two loops apart at a glance. Reading a field off it is refused naming both, since the likely mistake is reaching for the outer loop's record from inside the inner one, and the two ways to confuse a string field with a list field are each refused with the spelling to use instead. The walk indexes a collected stream rather than peeking one token, a nested loop being seven tokens before its body.
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 21:20 UTC
Signed with PGP, not checked
Commit: ce82938308ae40c91714dfa487bc30e570213f1a
Parent: d99e763
4 files changed, +261 insertions, -33 deletions
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "quasi-declare"
3 - version = "0.1.11"
3 + version = "0.1.12"
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
@@ -22,10 +22,32 @@
22 22 //! has stopped being copy and is structure; move it back into the declaration
23 23 //! rather than growing the format.
24 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.
25 + //! Values are words: a string, or a list of them. A number or a bool in a
26 + //! content file is refused naming the field, because a screen that reads copy is
27 + //! reading words, and the moment the format grows types it is a description
28 + //! language with two spellings.
29 + //!
30 + //! A list is admitted because it is still words and not a type. `/use-cases`
31 + //! is the site: nine cards, each ending in a bulleted list of features, and the
32 + //! alternative to a list field was nine numbered fields with the count written
33 + //! into the declaration. What a list may hold is strings, so there is no
34 + //! nesting to grow into a tree, and it is read by a nested loop:
35 + //!
36 + //! ```text
37 + //! for profile in copy "content/use-cases.toml" as profiles {
38 + //! label profile.title;
39 + //! list {
40 + //! for feature in profile.features {
41 + //! row feature;
42 + //! }
43 + //! }
44 + //! }
45 + //! ```
46 + //!
47 + //! The inner loop is unrolled by the same pass as the outer one, so neither
48 + //! reaches [`crate::ast`]. Its binder is the element itself and has no fields,
49 + //! which is what tells the two loops apart at a glance: `profile.title` reads a
50 + //! field and `feature` is one.
29 51
30 52 use std::cell::RefCell;
31 53 use std::collections::BTreeMap;
@@ -118,8 +140,17 @@
118 140 }
119 141 }
120 142
143 + /// One field's value: words, or a list of them.
144 + ///
145 + /// Two and no third. A list holds strings, so a content file cannot grow a
146 + /// tree, and the reason is the module header's: copy is words.
147 + enum Value {
148 + Text(String),
149 + List(Vec<String>),
150 + }
151 +
121 152 /// One entry's fields, by name.
122 - type Entry = BTreeMap<String, String>;
153 + type Entry = BTreeMap<String, Value>;
123 154
124 155 /// The entries under `key`, read from the file the declaration named.
125 156 ///
@@ -169,13 +200,27 @@
169 200 };
170 201 let mut entry = Entry::new();
171 202 for (name, field) in fields {
172 - let Some(text) = field.as_str() else {
173 - return Err(syn::Error::new(
203 + let refused = || {
204 + syn::Error::new(
174 205 key.span(),
175 - format!("{named} entry {index} field `{name}` is not a string: copy is words"),
176 - ));
206 + format!(
207 + "{named} entry {index} field `{name}` is not a string \
208 + or a list of them: copy is words"
209 + ),
210 + )
177 211 };
178 - entry.insert(name.clone(), text.to_owned());
212 + let value = if let Some(text) = field.as_str() {
213 + Value::Text(text.to_owned())
214 + } else if let Some(array) = field.as_array() {
215 + let mut items = Vec::with_capacity(array.len());
216 + for element in array {
217 + items.push(element.as_str().ok_or_else(refused)?.to_owned());
218 + }
219 + Value::List(items)
220 + } else {
221 + return Err(refused());
222 + };
223 + entry.insert(name.clone(), value);
179 224 }
180 225 entries.push(entry);
181 226 }
@@ -189,6 +234,11 @@
189 234 /// production in the grammar, drifting from the first the way a second emitter
190 235 /// would. `binder.field` is three tokens in every position the grammar admits,
191 236 /// which is what makes the walk exact rather than approximate.
237 + ///
238 + /// A nested `for <x> in <binder>.<field> { .. }` over a list field is unrolled
239 + /// here too, once per element, with the inner binder standing for the element
240 + /// itself. It is the same unrolling as the outer loop and for the same reason:
241 + /// a loop the emitter can fold is one that never reaches the AST.
192 242 fn substitute(
193 243 template: TokenStream,
194 244 binder: &Ident,
@@ -196,51 +246,180 @@
196 246 file: &LitStr,
197 247 ) -> Result<TokenStream> {
198 248 let mut filled = Vec::new();
199 - let mut tokens = template.into_iter().peekable();
249 + let tokens: Vec<TokenTree> = template.into_iter().collect();
250 + let mut index = 0;
200 251
201 - while let Some(token) = tokens.next() {
202 - match token {
252 + while index < tokens.len() {
253 + if let Some((items, next)) = nested(&tokens, index, binder, entry, file)? {
254 + filled.extend(items);
255 + index = next;
256 + continue;
257 + }
258 + match &tokens[index] {
203 259 TokenTree::Group(group) => {
204 260 let inner = substitute(group.stream(), binder, entry, file)?;
205 261 let mut replacement = Group::new(group.delimiter(), inner);
206 262 replacement.set_span(group.span());
207 263 filled.push(TokenTree::Group(replacement));
264 + index += 1;
208 265 }
209 - TokenTree::Ident(ident) if ident == *binder => {
210 - let field = field_after(&mut tokens, &ident)?;
211 - let Some(text) = entry.get(&field.to_string()) else {
212 - return Err(syn::Error::new(
213 - field.span(),
214 - format!("{} has no `{field}` in {}", binder, file.value()),
215 - ));
216 - };
217 - let mut literal = Literal::string(&escaped(text));
218 - literal.set_span(ident.span());
219 - filled.push(TokenTree::Literal(literal));
266 + TokenTree::Ident(ident) if ident == binder => {
267 + let field = field_after(&tokens, index, ident)?;
268 + filled.push(literal(text(entry, &field, binder, file)?, ident.span()));
269 + index += 3;
270 + }
271 + other => {
272 + filled.push(other.clone());
273 + index += 1;
220 274 }
221 - other => filled.push(other),
222 275 }
223 276 }
224 277 Ok(filled.into_iter().collect())
225 278 }
226 279
227 - /// The field named after the binder, or an error saying the binder is text.
228 - fn field_after(
229 - tokens: &mut std::iter::Peekable<proc_macro2::token_stream::IntoIter>,
280 + /// A `for <x> in <binder>.<field> { .. }` at `index`, unrolled.
281 + ///
282 + /// `Ok(None)` when the tokens there are not one, which is the ordinary case and
283 + /// not a failure. The body is written out once per element with `x` replaced by
284 + /// the element, and `x` is replaced as a bare ident because an element of a list
285 + /// is text and has no fields to read.
286 + fn nested(
287 + tokens: &[TokenTree],
288 + index: usize,
230 289 binder: &Ident,
231 - ) -> Result<Ident> {
290 + entry: &Entry,
291 + file: &LitStr,
292 + ) -> Result<Option<(Vec<TokenTree>, usize)>> {
293 + let [
294 + TokenTree::Ident(keyword),
295 + TokenTree::Ident(inner),
296 + TokenTree::Ident(in_word),
297 + TokenTree::Ident(outer),
298 + TokenTree::Punct(dot),
299 + TokenTree::Ident(field),
300 + TokenTree::Group(body),
301 + ] = &tokens[index..tokens.len().min(index + 7)]
302 + else {
303 + return Ok(None);
304 + };
305 + if keyword != "for" || in_word != "in" || outer != binder || dot.as_char() != '.' {
306 + return Ok(None);
307 + }
308 + if body.delimiter() != proc_macro2::Delimiter::Brace {
309 + return Ok(None);
310 + }
311 +
312 + let Some(value) = entry.get(&field.to_string()) else {
313 + return Err(syn::Error::new(
314 + field.span(),
315 + format!("{binder} has no `{field}` in {}", file.value()),
316 + ));
317 + };
318 + let Value::List(items) = value else {
319 + return Err(syn::Error::new(
320 + field.span(),
321 + format!(
322 + "`{binder}.{field}` in {} is one string, so it is written and not \
323 + iterated",
324 + file.value()
325 + ),
326 + ));
327 + };
328 +
329 + let mut filled = Vec::new();
330 + for item in items {
331 + filled.extend(element(body.stream(), inner, item, file)?);
332 + }
333 + Ok(Some((filled, index + 7)))
334 + }
335 +
336 + /// One pass of a nested loop's body, with its binder standing for `item`.
337 + ///
338 + /// The binder is the element and not a record, so a `binder.field` here is a
339 + /// field read against text. That is refused naming both, because the likely
340 + /// mistake is reaching for the outer loop's record from inside the inner one.
341 + fn element(
342 + template: TokenStream,
343 + binder: &Ident,
344 + item: &str,
345 + file: &LitStr,
346 + ) -> Result<Vec<TokenTree>> {
347 + let mut filled = Vec::new();
348 + let tokens: Vec<TokenTree> = template.into_iter().collect();
349 + let mut index = 0;
350 +
351 + while index < tokens.len() {
352 + match &tokens[index] {
353 + TokenTree::Group(group) => {
354 + let inner = element(group.stream(), binder, item, file)?;
355 + let mut replacement = Group::new(group.delimiter(), inner.into_iter().collect());
356 + replacement.set_span(group.span());
357 + filled.push(TokenTree::Group(replacement));
358 + }
359 + TokenTree::Ident(ident) if ident == binder => {
360 + if let Some(TokenTree::Punct(punct)) = tokens.get(index + 1)
361 + && punct.as_char() == '.'
362 + {
363 + return Err(syn::Error::new(
364 + ident.span(),
365 + format!(
366 + "`{binder}` is one entry of a list in {}, which is text: \
367 + it has no fields",
368 + file.value()
369 + ),
370 + ));
371 + }
372 + filled.push(literal(item, ident.span()));
373 + }
374 + other => filled.push(other.clone()),
375 + }
376 + index += 1;
377 + }
378 + Ok(filled)
379 + }
380 +
381 + /// One field's words, or an error saying a list is iterated rather than written.
382 + fn text<'a>(entry: &'a Entry, field: &Ident, binder: &Ident, file: &LitStr) -> Result<&'a str> {
383 + let Some(value) = entry.get(&field.to_string()) else {
384 + return Err(syn::Error::new(
385 + field.span(),
386 + format!("{binder} has no `{field}` in {}", file.value()),
387 + ));
388 + };
389 + match value {
390 + Value::Text(text) => Ok(text),
391 + Value::List(_) => Err(syn::Error::new(
392 + field.span(),
393 + format!(
394 + "`{binder}.{field}` in {} is a list, so it is iterated and not \
395 + written: `for <x> in {binder}.{field} {{ .. }}`",
396 + file.value()
397 + ),
398 + )),
399 + }
400 + }
401 +
402 + /// Copy as a string literal, spanned where it was written.
403 + fn literal(text: &str, span: proc_macro2::Span) -> TokenTree {
404 + let mut literal = Literal::string(&escaped(text));
405 + literal.set_span(span);
406 + TokenTree::Literal(literal)
407 + }
408 +
409 + /// The field named after the binder, or an error saying the binder is text.
410 + fn field_after(tokens: &[TokenTree], index: usize, binder: &Ident) -> Result<Ident> {
232 411 let lone = || {
233 412 syn::Error::new(
234 413 binder.span(),
235 414 format!("`{binder}` is one entry's copy: name a field, as `{binder}.heading`"),
236 415 )
237 416 };
238 - match tokens.next() {
417 + match tokens.get(index + 1) {
239 418 Some(TokenTree::Punct(punct)) if punct.as_char() == '.' => {}
240 419 _ => return Err(lone()),
241 420 }
242 - match tokens.next() {
243 - Some(TokenTree::Ident(field)) => Ok(field),
421 + match tokens.get(index + 2) {
422 + Some(TokenTree::Ident(field)) => Ok(field.clone()),
244 423 _ => Err(lone()),
245 424 }
246 425 }
@@ -84,6 +84,45 @@
84 84 assert!(rendered.contains("me"), "the loop ran over the binding");
85 85 }
86 86
87 + declare! {
88 + /// A list field, read by a nested loop.
89 + shape listed() -> Node;
90 +
91 + region "listed" as Pane {
92 + for profile in copy "tests/content/copy.toml" as listed {
93 + section profile.title;
94 + list {
95 + for feature in profile.features {
96 + row feature;
97 + }
98 + }
99 + }
100 + }
101 + }
102 +
103 + /// Both loops are gone, the inner one as much as the outer.
104 + ///
105 + /// Five rows from two entries is the assertion that matters: an inner loop that
106 + /// survived would draw one row per entry, and one that ran once would draw two.
107 + /// The binder of the inner loop is the element itself, which is what tells a
108 + /// list read from a record read.
109 + #[test]
110 + fn a_list_field_is_unrolled_per_element() {
111 + let rendered = format!("{:?}", listed());
112 +
113 + for feature in [
114 + "Lossless upload",
115 + "Chapters",
116 + "Cover art",
117 + "Markdown",
118 + "Scheduled publishing",
119 + ] {
120 + assert!(rendered.contains(feature), "{feature} is missing");
121 + }
122 + assert!(rendered.contains("Musicians"), "the first title is missing");
123 + assert!(rendered.contains("Writers"), "the second title is missing");
124 + }
125 +
87 126 #[test]
88 127 fn a_brace_in_copy_is_a_brace() {
89 128 let rendered = format!("{:?}", braces());
@@ -12,3 +12,13 @@
12 12 # A brace in copy is a brace somebody typed, never a hole.
13 13 [[braced]]
14 14 label = "{not a hole}"
15 +
16 + # A list field, which is words and iterated rather than written. `/use-cases`
17 + # is the site: a card per profile, each ending in a list of features.
18 + [[listed]]
19 + title = "Musicians"
20 + features = ["Lossless upload", "Chapters", "Cover art"]
21 +
22 + [[listed]]
23 + title = "Writers"
24 + features = ["Markdown", "Scheduled publishing"]