Skip to main content

max / quasi

Grow the form for the team and policy documents team: a `-> Screen` shape and the `screen` member, a `-> Row` shape and the `row` member, `Node`'s own constructors as members by their own names, and an array as an argument. A document holds regions as slots rather than as nodes, so a region emits one or the other by where it lands. policy: `for`, which is the one item that cannot be hoisted above its container, and `list { .. }` for rows built where they are read. A container that holds a loop accumulates instead of chaining; the chain stays for the ones that do not, because a `let mut` never reassigned is a warning in the caller's crate that the caller cannot see the cause of. A shape's generated signature names the vocabulary type rather than the path the file imported it under. team.rs takes `Screen as Described` because it has a function called `screen`, and a declaration should not have to know that.
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-03 17:34 UTC
Signed with PGP, not checked
Commit: 6890a9b14a3f923aa08721ec8abf39e508af5ef0
Parent: b99519f
3 files changed, +311 insertions, -26 deletions
@@ -43,6 +43,15 @@
43 43 /// a form that spelled the field would be naming something no caller can
44 44 /// reach.
45 45 Attribute { name: Ident, args: Vec<Arg> },
46 + /// `for <binder> in <hole> { .. }` -- the same body once per element.
47 + ///
48 + /// A loop is the one item that cannot be hoisted above its container: its
49 + /// members name the binder, which does not exist until the loop does.
50 + For {
51 + binder: Ident,
52 + iterable: Hole,
53 + body: Vec<Item>,
54 + },
46 55 /// Anything that puts something into the enclosing container.
47 56 Emit(Emission),
48 57 }
@@ -114,6 +123,9 @@
114 123
115 124 pub enum Arg {
116 125 Str(Interpolated),
126 + /// `[ <arg>, .. ]`, which is an array literal and never a collection: what
127 + /// it can hold is an arg, so it cannot reopen into a sub-grammar.
128 + List(Vec<Arg>),
117 129 Hole(Hole),
118 130 Int(i64),
119 131 Bool(bool),
@@ -124,8 +136,22 @@
124 136
125 137 /// Everything that emits into the enclosing container.
126 138 pub enum Emission {
127 - /// `text <arg>;`
128 - Text(Arg),
139 + /// `<member> <arg>*;` -- one of `Node`'s own constructors, by its own name.
140 + Simple { member: Ident, args: Vec<Arg> },
141 + /// `screen <arrangement> <arg> { .. }`
142 + Screen {
143 + arrangement: Ident,
144 + title: Arg,
145 + body: Vec<Item>,
146 + },
147 + /// `row <arg> { .. }`
148 + Row { primary: Arg, body: Vec<Item> },
149 + /// `list { .. }` -- the rows built in place rather than supplied.
150 + ///
151 + /// Its own member rather than a container in the general sense: a `Vec`
152 + /// does not chain, so the one thing that accretes by pushing is kept where
153 + /// it can be read instead of bending every other container's shape.
154 + List(Vec<Item>),
129 155 /// `act <arg> to <action> ( { .. } | ; )`
130 156 Act {
131 157 label: Arg,
@@ -12,6 +12,7 @@
12 12
13 13 use proc_macro2::{Span, TokenStream};
14 14 use quote::{format_ident, quote};
15 + use syn::spanned::Spanned as _;
15 16 use syn::{LitStr, Result};
16 17
17 18 use crate::ast::{
@@ -29,6 +30,10 @@
29 30 Run,
30 31 /// One control: its body sets, and emits nothing.
31 32 Act,
33 + /// A document: its members are regions, held as slots rather than nodes.
34 + Screen,
35 + /// One row of a list: its members are the controls it offers.
36 + Row,
32 37 }
33 38
34 39 pub fn declaration(declaration: &Declaration) -> Result<TokenStream> {
@@ -56,6 +61,17 @@
56 61 });
57 62 let body = value_body(items, &shaped, optional, name.span())?;
58 63
64 + // The vocabulary type, not the path the file happened to import it under:
65 + // team.rs takes `Screen as Described` because it has a function called
66 + // `screen`, and a declaration names what it returns rather than what the
67 + // module around it calls that.
68 + let shaped_type = format_ident!("{}", shaped, span = returns.span());
69 + let returns = if optional {
70 + quote!(::std::option::Option<::quasi_router::#shaped_type>)
71 + } else {
72 + quote!(::quasi_router::#shaped_type)
73 + };
74 +
59 75 Ok(quote! {
60 76 #(#docs)*
61 77 #(#flags)*
@@ -121,6 +137,12 @@
121 137 "a setting needs something to set: put it in the member's body",
122 138 ));
123 139 }
140 + Item::For { binder, .. } => {
141 + return Err(syn::Error::new(
142 + binder.span(),
143 + "a loop emits many members, and this shape is its single one",
144 + ));
145 + }
124 146 Item::Emit(emission) => emissions.push(emission),
125 147 }
126 148 }
@@ -157,6 +179,35 @@
157 179 ));
158 180 }
159 181 },
182 + "Screen" => match only {
183 + Emission::Screen {
184 + arrangement,
185 + title,
186 + body,
187 + } => {
188 + let title = arg(title)?;
189 + accrete(
190 + body,
191 + Container::Screen,
192 + &quote!(::quasi_router::Screen::#arrangement(#title)),
193 + )?
194 + }
195 + other => {
196 + return Err(syn::Error::new(
197 + emission_span(other),
198 + "a `-> Screen` shape is its single `screen` member",
199 + ));
200 + }
201 + },
202 + "Row" => match only {
203 + Emission::Row { primary, body } => row(primary, body)?,
204 + other => {
205 + return Err(syn::Error::new(
206 + emission_span(other),
207 + "a `-> Row` shape is its single `row` member",
208 + ));
209 + }
210 + },
160 211 other => {
161 212 return Err(syn::Error::new(
162 213 span,
@@ -188,6 +239,10 @@
188 239
189 240 /// A body that accretes onto something already built.
190 241 fn accrete(items: &[Item], container: Container, base: &TokenStream) -> Result<TokenStream> {
242 + if items.iter().any(|item| matches!(item, Item::For { .. })) {
243 + return accumulate(items, container, base);
244 + }
245 +
191 246 let mut bindings = Vec::new();
192 247 let mut steps = Vec::new();
193 248 for item in items {
@@ -197,6 +252,7 @@
197 252 bindings.push(quote!(let #name = #value;));
198 253 }
199 254 Item::Attribute { name, args } => steps.push(attribute(name, args)?),
255 + Item::For { .. } => unreachable!("a loop takes the accumulating form"),
200 256 Item::Emit(emission) => {
201 257 // A member is evaluated into its own binding before the
202 258 // container is built, because a container's own name is often
@@ -213,8 +269,60 @@
213 269 Ok(quote!({ #(#bindings)* #base #(#steps)* }))
214 270 }
215 271
272 + /// The same body, for a container a loop adds to.
273 + ///
274 + /// A chain cannot hold a loop, so this accumulates instead. The two forms are
275 + /// kept apart rather than merged because the chain is what a container with no
276 + /// loop should read as, and a `let mut` that is never reassigned is a warning
277 + /// in the caller's crate that the caller cannot see the cause of.
278 + fn accumulate(items: &[Item], container: Container, base: &TokenStream) -> Result<TokenStream> {
279 + let built = format_ident!("built", span = Span::call_site());
280 + let statements = statements(items, container, &built)?;
281 + Ok(quote!({
282 + let mut #built = #base;
283 + #(#statements)*
284 + #built
285 + }))
286 + }
287 +
288 + /// The statements one body contributes to an accumulating container.
289 + fn statements(
290 + items: &[Item],
291 + container: Container,
292 + built: &proc_macro2::Ident,
293 + ) -> Result<Vec<TokenStream>> {
294 + let mut statements = Vec::new();
295 + for (index, item) in items.iter().enumerate() {
296 + statements.push(match item {
297 + Item::Bind { name, source } => {
298 + let value = source_value(source)?;
299 + quote!(let #name = #value;)
300 + }
301 + Item::Attribute { name, args } => {
302 + let call = attribute(name, args)?;
303 + quote!(#built = #built #call;)
304 + }
305 + Item::For {
306 + binder,
307 + iterable,
308 + body,
309 + } => {
310 + let iterable = hole(iterable)?;
311 + let inner = self::statements(body, container, built)?;
312 + quote!(for #binder in #iterable { #(#inner)* })
313 + }
314 + Item::Emit(emission) => {
315 + let held = format_ident!("member_{index}", span = Span::call_site());
316 + let (value, call) = step(emission, container, &held)?;
317 + quote!({ let #held = #value; #built = #built #call; })
318 + }
319 + });
320 + }
321 + Ok(statements)
322 + }
323 +
216 324 /// One setting, as the builder call ATTRIBUTE NAMING says it is.
217 - fn attribute(name: &syn::Ident, args: &[Arg]) -> Result<TokenStream> {
325 + fn attribute(name: &proc_macro2::Ident, args: &[Arg]) -> Result<TokenStream> {
218 326 let slot = name.to_string();
219 327 let enumeration = VOCABULARY
220 328 .iter()
@@ -268,6 +376,18 @@
268 376 Ok((run, quote!(.across(#held))))
269 377 }
270 378 (Container::Slot, other) => Ok((node(other)?, quote!(.with(#held)))),
379 + (Container::Screen, Emission::Region { name, kind, body }) => {
380 + Ok((slot(name, kind, body)?, quote!(.with(#held))))
381 + }
382 + (Container::Screen, other) => Err(syn::Error::new(
383 + emission_span(other),
384 + "a document holds regions: write `region <name> as <kind> { .. }`",
385 + )),
386 + (Container::Row, Emission::Act { .. }) => Ok((act(emission)?, quote!(.act(#held)))),
387 + (Container::Row, other) => Err(syn::Error::new(
388 + emission_span(other),
389 + "a row holds controls: write `act <label> to <action>;`",
390 + )),
271 391 (Container::Run, Emission::Beside { priority, inner }) => Ok((
272 392 node(inner)?,
273 393 quote!(.beside(#held, ::quasi_router::layout::Priority::#priority)),
@@ -316,10 +436,19 @@
316 436 /// One emission as a `Node` value.
317 437 fn node(emission: &Emission) -> Result<TokenStream> {
318 438 match emission {
319 - Emission::Text(text) => {
320 - let text = arg(text)?;
321 - Ok(quote!(::quasi_router::Node::text(#text)))
439 + Emission::Simple { member, args } => {
440 + let args = args.iter().map(self::arg).collect::<Result<Vec<_>>>()?;
441 + Ok(quote!(::quasi_router::Node::#member(#(#args),*)))
322 442 }
443 + Emission::List(items) => list(items),
444 + Emission::Row { .. } => Err(syn::Error::new(
445 + emission_span(emission),
446 + "a row is not a node: put it in a `list`",
447 + )),
448 + Emission::Screen { arrangement, .. } => Err(syn::Error::new(
449 + arrangement.span(),
450 + "a document is not a node",
451 + )),
323 452 Emission::Link { text, action } => {
324 453 let text = arg(text)?;
325 454 let action = self::action(action)?;
@@ -339,12 +468,7 @@
339 468 Ok(quote!(::std::convert::Into::into(#supplier)))
340 469 }
341 470 Emission::Region { name, kind, body } => {
342 - let name = arg(name)?;
343 - let slot = accrete(
344 - body,
345 - Container::Slot,
346 - &quote!(::quasi_router::Slot::new(#name, ::quasi_router::RegionKind::#kind)),
347 - )?;
471 + let slot = slot(name, kind, body)?;
348 472 Ok(quote!(::quasi_router::Node::Region(#slot)))
349 473 }
350 474 Emission::Given {
@@ -386,6 +510,71 @@
386 510 }
387 511 }
388 512
513 + /// One region, as the `Slot` it is. A document holds these; a body wraps them
514 + /// in [`Node::Region`].
515 + fn slot(name: &Arg, kind: &syn::Ident, body: &[Item]) -> Result<TokenStream> {
516 + let name = arg(name)?;
517 + accrete(
518 + body,
519 + Container::Slot,
520 + &quote!(::quasi_router::Slot::new(#name, ::quasi_router::RegionKind::#kind)),
521 + )
522 + }
523 +
524 + /// The rows of a list, built where they are read.
525 + fn list(items: &[Item]) -> Result<TokenStream> {
526 + let rows = format_ident!("rows", span = Span::call_site());
527 + let statements = list_statements(items, &rows)?;
528 + Ok(quote!({
529 + let mut #rows = ::std::vec::Vec::new();
530 + #(#statements)*
531 + ::quasi_router::Node::list(#rows)
532 + }))
533 + }
534 +
535 + fn list_statements(items: &[Item], rows: &proc_macro2::Ident) -> Result<Vec<TokenStream>> {
536 + items
537 + .iter()
538 + .map(|item| match item {
539 + Item::Bind { name, source } => {
540 + let value = source_value(source)?;
541 + Ok(quote!(let #name = #value;))
542 + }
543 + Item::For {
544 + binder,
545 + iterable,
546 + body,
547 + } => {
548 + let iterable = hole(iterable)?;
549 + let inner = list_statements(body, rows)?;
550 + Ok(quote!(for #binder in #iterable { #(#inner)* }))
551 + }
552 + Item::Emit(Emission::Row { primary, body }) => {
553 + let row = row(primary, body)?;
554 + Ok(quote!(#rows.push(#row);))
555 + }
556 + Item::Emit(other) => Err(syn::Error::new(
557 + emission_span(other),
558 + "a list holds rows: write `row <primary> { .. }`",
559 + )),
560 + Item::Attribute { name, .. } => Err(syn::Error::new(
561 + name.span(),
562 + "a list has nothing to set: say it on the row",
563 + )),
564 + })
565 + .collect()
566 + }
567 +
568 + /// One row of a list: what it says, and what it offers.
569 + fn row(primary: &Arg, body: &[Item]) -> Result<TokenStream> {
570 + let primary = arg(primary)?;
571 + accrete(
572 + body,
573 + Container::Row,
574 + &quote!(::quasi_router::Row::new(#primary)),
575 + )
576 + }
577 +
389 578 /// One control: what it is called, what it does, and what it is like.
390 579 fn act(emission: &Emission) -> Result<TokenStream> {
391 580 let Emission::Act {
@@ -577,6 +766,10 @@
577 766 Ok(quote!(#value))
578 767 }
579 768 Arg::Bool(value) => Ok(quote!(#value)),
769 + Arg::List(items) => {
770 + let items = items.iter().map(self::arg).collect::<Result<Vec<_>>>()?;
771 + Ok(quote!([#(#items),*]))
772 + }
580 773 Arg::Borrow(inner) => {
581 774 let inner = self::arg(inner)?;
582 775 Ok(quote!(&#inner))
@@ -586,7 +779,10 @@
586 779
587 780 fn emission_span(emission: &Emission) -> Span {
588 781 match emission {
589 - Emission::Text(_) | Emission::Link { .. } | Emission::Include(_) => Span::call_site(),
782 + Emission::Include(_) | Emission::Link { .. } => Span::call_site(),
783 + Emission::Simple { member, .. } => member.span(),
784 + Emission::Screen { arrangement, .. } => arrangement.span(),
785 + Emission::Row { .. } | Emission::List(_) => Span::call_site(),
590 786 Emission::Act { action, .. } => action.verb.span(),
591 787 Emission::Guarded { guard, .. } => guard.span,
592 788 Emission::Given { .. } => Span::call_site(),
@@ -42,9 +42,16 @@
42 42 /// without a production is a parse error naming it, which is the failure worth
43 43 /// having.
44 44 const MEMBERS: &[&str] = &[
45 - "act", "across", "beside", "given", "include", "link", "region", "text",
45 + "act", "across", "beside", "given", "include", "link", "region", "row", "screen",
46 46 ];
47 47
48 + /// The members that are one of `Node`'s own constructors, called by its name.
49 + /// Growing this list is the whole of adding one.
50 + const NODE_MEMBERS: &[&str] = &["text", "page", "section", "list"];
51 +
52 + /// The arrangements a screen may be laid out in.
53 + const ARRANGEMENTS: &[&str] = &["single", "list_detail", "sidebar_content"];
54 +
48 55 /// The comparisons, and the Rust operator each is.
49 56 pub const COMPARISONS: &[(&str, &str)] = &[
50 57 ("is", "=="),
@@ -177,8 +184,20 @@
177 184 input.parse::<Token![;]>()?;
178 185 return Ok(Self::Bind { name, source });
179 186 }
187 + if input.peek(Token![for]) {
188 + input.parse::<Token![for]>()?;
189 + let binder: Ident = input.parse()?;
190 + input.parse::<Token![in]>()?;
191 + let iterable: Hole = input.parse()?;
192 + return Ok(Self::For {
193 + binder,
194 + iterable,
195 + body: block(input)?,
196 + });
197 + }
180 198 let member = input.fork().parse::<Ident>()?;
181 - if MEMBERS.contains(&member.to_string().as_str()) {
199 + let spelling = member.to_string();
200 + if MEMBERS.contains(&spelling.as_str()) || NODE_MEMBERS.contains(&spelling.as_str()) {
182 201 return Ok(Self::Emit(input.parse()?));
183 202 }
184 203 let name: Ident = input.parse()?;
@@ -327,6 +346,12 @@
327 346 input.parse::<Token![&]>()?;
328 347 return Ok(Self::Borrow(Box::new(input.parse()?)));
329 348 }
349 + if input.peek(token::Bracket) {
350 + let items;
351 + syn::bracketed!(items in input);
352 + let items = items.parse_terminated(Self::parse, Token![,])?;
353 + return Ok(Self::List(items.into_iter().collect()));
354 + }
330 355 if input.peek(LitStr) {
331 356 let lit: LitStr = input.parse()?;
332 357 return Ok(Self::Str(interpolate(&lit)?));
@@ -420,11 +445,43 @@
420 445 otherwise,
421 446 })
422 447 }
423 - "text" => {
424 - let text: Arg = input.parse()?;
448 + "screen" => {
449 + let arrangement: Ident = input.parse()?;
450 + if !ARRANGEMENTS.contains(&arrangement.to_string().as_str()) {
451 + return Err(syn::Error::new(
452 + arrangement.span(),
453 + "a screen is laid out `single`, `list_detail` or `sidebar_content`",
454 + ));
455 + }
456 + let title: Arg = input.parse()?;
457 + Ok(Self::Screen {
458 + arrangement,
459 + title,
460 + body: block(input)?,
461 + })
462 + }
463 + "row" => {
464 + let primary: Arg = input.parse()?;
465 + Ok(Self::Row {
466 + primary,
467 + body: block(input)?,
468 + })
469 + }
470 + "list" if input.peek(token::Brace) => Ok(Self::List(block(input)?)),
471 + name if NODE_MEMBERS.contains(&name) => {
472 + let mut args = Vec::new();
473 + while !input.peek(Token![;]) && guard_ahead(input)?.is_none() {
474 + args.push(input.parse()?);
475 + }
425 476 let guard = guard(input)?;
426 477 input.parse::<Token![;]>()?;
427 - Ok(guarded(guard, Self::Text(text)))
478 + Ok(guarded(
479 + guard,
480 + Self::Simple {
481 + member: member.clone(),
482 + args,
483 + },
484 + ))
428 485 }
429 486 "link" => {
430 487 let text: Arg = input.parse()?;
@@ -445,18 +502,24 @@
445 502 }
446 503 }
447 504
448 - /// `when <predicate>` or `unless <predicate>`, if one is written here.
449 - fn guard(input: ParseStream) -> Result<Option<Guard>> {
505 + /// Whether a guard starts here, without consuming it.
506 + fn guard_ahead(input: ParseStream) -> Result<Option<bool>> {
450 507 if !input.peek(Ident) {
451 508 return Ok(None);
452 509 }
453 - let word = input.fork().parse::<Ident>()?;
454 - let negated = match word.to_string().as_str() {
455 - "when" => false,
456 - "unless" => true,
457 - _ => return Ok(None),
510 + Ok(match input.fork().parse::<Ident>()?.to_string().as_str() {
511 + "when" => Some(false),
512 + "unless" => Some(true),
513 + _ => None,
514 + })
515 + }
516 +
517 + /// `when <predicate>` or `unless <predicate>`, if one is written here.
518 + fn guard(input: ParseStream) -> Result<Option<Guard>> {
519 + let Some(negated) = guard_ahead(input)? else {
520 + return Ok(None);
458 521 };
459 - input.parse::<Ident>()?;
522 + let word: Ident = input.parse()?;
460 523
461 524 let left: Hole = input.parse()?;
462 525 let predicate = if input.peek(Ident) {