//! `for in copy "" as `: copy read at macro time. //! //! The production that lets a screen's words leave Rust. `policy.rs` held five //! prose sections and seven document records as `const` arrays and looped over //! them, which put the page's copy behind a Rust file and behind a `for` loop //! the emitter cannot fold: a proc macro cannot evaluate a `const`, so the loop //! survives into whatever is emitted, however the emitter is written. //! //! This reads the copy instead. The file is TOML, the key names an array of //! tables, and the loop is unrolled here into one set of items per entry with //! `binder.field` replaced by the value it holds. Nothing about the loop //! reaches [`crate::ast`]: what the rest of the crate sees is the members //! written out, exactly as if somebody had typed them. //! //! # What may live in a content file, and what may not //! //! Copy with no holes. The split is quasicoherent `98fbee62`'s: pure values //! with no reference to code go in the file, structure with holes stays in the //! macro, and computation stays a supplier function. So a value is substituted //! as text and its braces are doubled on the way in, which is the escape the //! ordinary string production already uses. A content file that wants a hole //! has stopped being copy and is structure; move it back into the declaration //! rather than growing the format. //! //! Values are words: a string, or a list of them. A number or a bool in a //! content file is refused naming the field, because a screen that reads copy is //! reading words, and the moment the format grows types it is a description //! language with two spellings. //! //! A list is admitted because it is still words and not a type. `/use-cases` //! is the site: nine cards, each ending in a bulleted list of features, and the //! alternative to a list field was nine numbered fields with the count written //! into the declaration. What a list may hold is strings, so there is no //! nesting to grow into a tree, and it is read by a nested loop: //! //! ```text //! for profile in copy "content/use-cases.toml" as profiles { //! label profile.title; //! list { //! for feature in profile.features { //! row feature; //! } //! } //! } //! ``` //! //! The inner loop is unrolled by the same pass as the outer one, so neither //! reaches [`crate::ast`]. Its binder is the element itself and has no fields, //! which is what tells the two loops apart at a glance: `profile.title` reads a //! field and `feature` is one. use std::cell::RefCell; use std::collections::BTreeMap; use std::path::PathBuf; use proc_macro2::{Group, Ident, Literal, TokenStream, TokenTree}; use syn::parse::{Parse, ParseStream}; use syn::{LitStr, Result, Token, braced}; use crate::ast::Item; // The files this expansion read, absolute, in the order they were read. // // A thread-local because the reading happens inside a `Parse` impl, whose // signature has nowhere to thread a collector through, and one macro expansion // is one pass on one thread. `crate::declare` drains it directly after parsing // and turns each path into an `include_bytes!`, which is what makes rustc // rebuild the crate when the copy changes. Without that marker a content edit // is invisible: the file is read by the macro and named nowhere the compiler // looks. thread_local! { static READ: RefCell> = const { RefCell::new(Vec::new()) }; } /// The files read since the last drain. pub fn taken() -> Vec { READ.with(|read| std::mem::take(&mut *read.borrow_mut())) } /// Whether the item ahead is a copy loop rather than an ordinary one. /// /// The word after `in`, and then a string literal. Both halves are needed and /// the second is not belt-and-braces: `copy` on its own is a legal hole root, /// because a hole root is any lowercase binding, and audiofiles' `importing` /// has bound one -- `for weight in copy.weight.iter()`. A file path cannot be /// mistaken for a field access, so the literal is what tells the two apart /// without taking a word away from every declaration in the tree. pub fn ahead(input: ParseStream) -> bool { let fork = input.fork(); if fork.parse::().is_err() { return false; } if fork.peek(Token![&]) && fork.parse::().is_err() { return false; } if fork.parse::().is_err() || fork.parse::().is_err() { return false; } if !fork.parse::().is_ok_and(|word| word == "copy") { return false; } fork.peek(LitStr) } /// Read the file and write the body out once per entry. pub fn expand(input: ParseStream) -> Result> { input.parse::()?; if input.peek(Token![&]) { let ampersand = input.parse::()?; return Err(syn::Error::new( ampersand.span, "a copy loop binds text and not a reference: drop the `&`", )); } let binder: Ident = input.parse()?; input.parse::()?; input.parse::()?; let file: LitStr = input.parse()?; input.parse::()?; let key: Ident = input.parse()?; let body; braced!(body in input); let template: TokenStream = body.parse()?; let mut items = Vec::new(); for entry in read(&file, &key)? { let filled = substitute(template.clone(), &binder, &entry, &file)?; items.extend(syn::parse2::(filled)?.0); } Ok(items) } /// A body's items, so a filled template can be parsed as what it became. struct Body(Vec); impl Parse for Body { fn parse(input: ParseStream) -> Result { Ok(Self(crate::parse::items(input)?)) } } /// One field's value: words, or a list of them. /// /// Two and no third. A list holds strings, so a content file cannot grow a /// tree, and the reason is the module header's: copy is words. enum Value { Text(String), List(Vec), } /// One entry's fields, by name. type Entry = BTreeMap; /// The entries under `key`, read from the file the declaration named. /// /// The path is relative to the manifest of the crate being compiled, which is /// the only root a macro can resolve against that does not depend on which file /// the invocation sits in. fn read(file: &LitStr, key: &Ident) -> Result> { let root = std::env::var("CARGO_MANIFEST_DIR").map_err(|_| { syn::Error::new( file.span(), "`copy` needs CARGO_MANIFEST_DIR, which cargo sets and a bare rustc does not", ) })?; let path = PathBuf::from(root).join(file.value()); let text = std::fs::read_to_string(&path).map_err(|error| { syn::Error::new( file.span(), format!("cannot read {}: {error}", path.display()), ) })?; READ.with(|read| read.borrow_mut().push(path.clone())); let table: toml::Table = text.parse().map_err(|error| { syn::Error::new( file.span(), format!("{} is not TOML: {error}", path.display()), ) })?; let named = format!("`{key}` in {}", path.display()); let Some(value) = table.get(&key.to_string()) else { return Err(syn::Error::new(key.span(), format!("no {named}"))); }; let Some(array) = value.as_array() else { return Err(syn::Error::new( key.span(), format!("{named} is not an array of tables"), )); }; let mut entries = Vec::new(); for (index, element) in array.iter().enumerate() { let Some(fields) = element.as_table() else { return Err(syn::Error::new( key.span(), format!("{named} entry {index} is not a table"), )); }; let mut entry = Entry::new(); for (name, field) in fields { let refused = || { syn::Error::new( key.span(), format!( "{named} entry {index} field `{name}` is not a string \ or a list of them: copy is words" ), ) }; let value = if let Some(text) = field.as_str() { Value::Text(text.to_owned()) } else if let Some(array) = field.as_array() { let mut items = Vec::with_capacity(array.len()); for element in array { items.push(element.as_str().ok_or_else(refused)?.to_owned()); } Value::List(items) } else { return Err(refused()); }; entry.insert(name.clone(), value); } entries.push(entry); } Ok(entries) } /// The template with every `binder.field` replaced by the text it names. /// /// A token walk rather than a walk of the syntax tree, because the tree is not /// built yet and building it first would mean a second traversal of every /// production in the grammar, drifting from the first the way a second emitter /// would. `binder.field` is three tokens in every position the grammar admits, /// which is what makes the walk exact rather than approximate. /// /// A nested `for in . { .. }` over a list field is unrolled /// here too, once per element, with the inner binder standing for the element /// itself. It is the same unrolling as the outer loop and for the same reason: /// a loop the emitter can fold is one that never reaches the AST. fn substitute( template: TokenStream, binder: &Ident, entry: &Entry, file: &LitStr, ) -> Result { let mut filled = Vec::new(); let tokens: Vec = template.into_iter().collect(); let mut index = 0; while index < tokens.len() { if let Some((items, next)) = nested(&tokens, index, binder, entry, file)? { filled.extend(items); index = next; continue; } match &tokens[index] { TokenTree::Group(group) => { let inner = substitute(group.stream(), binder, entry, file)?; let mut replacement = Group::new(group.delimiter(), inner); replacement.set_span(group.span()); filled.push(TokenTree::Group(replacement)); index += 1; } TokenTree::Ident(ident) if ident == binder => { let field = field_after(&tokens, index, ident)?; filled.push(literal(text(entry, &field, binder, file)?, ident.span())); index += 3; } other => { filled.push(other.clone()); index += 1; } } } Ok(filled.into_iter().collect()) } /// A `for in . { .. }` at `index`, unrolled. /// /// `Ok(None)` when the tokens there are not one, which is the ordinary case and /// not a failure. The body is written out once per element with `x` replaced by /// the element, and `x` is replaced as a bare ident because an element of a list /// is text and has no fields to read. fn nested( tokens: &[TokenTree], index: usize, binder: &Ident, entry: &Entry, file: &LitStr, ) -> Result, usize)>> { let [ TokenTree::Ident(keyword), TokenTree::Ident(inner), TokenTree::Ident(in_word), TokenTree::Ident(outer), TokenTree::Punct(dot), TokenTree::Ident(field), TokenTree::Group(body), ] = &tokens[index..tokens.len().min(index + 7)] else { return Ok(None); }; if keyword != "for" || in_word != "in" || outer != binder || dot.as_char() != '.' { return Ok(None); } if body.delimiter() != proc_macro2::Delimiter::Brace { return Ok(None); } let Some(value) = entry.get(&field.to_string()) else { return Err(syn::Error::new( field.span(), format!("{binder} has no `{field}` in {}", file.value()), )); }; let Value::List(items) = value else { return Err(syn::Error::new( field.span(), format!( "`{binder}.{field}` in {} is one string, so it is written and not \ iterated", file.value() ), )); }; let mut filled = Vec::new(); for item in items { filled.extend(element(body.stream(), inner, item, file)?); } Ok(Some((filled, index + 7))) } /// One pass of a nested loop's body, with its binder standing for `item`. /// /// The binder is the element and not a record, so a `binder.field` here is a /// field read against text. That is refused naming both, because the likely /// mistake is reaching for the outer loop's record from inside the inner one. fn element( template: TokenStream, binder: &Ident, item: &str, file: &LitStr, ) -> Result> { let mut filled = Vec::new(); let tokens: Vec = template.into_iter().collect(); let mut index = 0; while index < tokens.len() { match &tokens[index] { TokenTree::Group(group) => { let inner = element(group.stream(), binder, item, file)?; let mut replacement = Group::new(group.delimiter(), inner.into_iter().collect()); replacement.set_span(group.span()); filled.push(TokenTree::Group(replacement)); } TokenTree::Ident(ident) if ident == binder => { if let Some(TokenTree::Punct(punct)) = tokens.get(index + 1) && punct.as_char() == '.' { return Err(syn::Error::new( ident.span(), format!( "`{binder}` is one entry of a list in {}, which is text: \ it has no fields", file.value() ), )); } filled.push(literal(item, ident.span())); } other => filled.push(other.clone()), } index += 1; } Ok(filled) } /// One field's words, or an error saying a list is iterated rather than written. fn text<'a>(entry: &'a Entry, field: &Ident, binder: &Ident, file: &LitStr) -> Result<&'a str> { let Some(value) = entry.get(&field.to_string()) else { return Err(syn::Error::new( field.span(), format!("{binder} has no `{field}` in {}", file.value()), )); }; match value { Value::Text(text) => Ok(text), Value::List(_) => Err(syn::Error::new( field.span(), format!( "`{binder}.{field}` in {} is a list, so it is iterated and not \ written: `for in {binder}.{field} {{ .. }}`", file.value() ), )), } } /// Copy as a string literal, spanned where it was written. fn literal(text: &str, span: proc_macro2::Span) -> TokenTree { let mut literal = Literal::string(&escaped(text)); literal.set_span(span); TokenTree::Literal(literal) } /// The field named after the binder, or an error saying the binder is text. fn field_after(tokens: &[TokenTree], index: usize, binder: &Ident) -> Result { let lone = || { syn::Error::new( binder.span(), format!("`{binder}` is one entry's copy: name a field, as `{binder}.heading`"), ) }; match tokens.get(index + 1) { Some(TokenTree::Punct(punct)) if punct.as_char() == '.' => {} _ => return Err(lone()), } match tokens.get(index + 2) { Some(TokenTree::Ident(field)) => Ok(field.clone()), _ => Err(lone()), } } /// Copy with its braces doubled, so the string production reads it as text. /// /// A brace in copy is a brace somebody typed. A hole in copy is structure that /// wandered into the wrong file, and doubling is what makes the difference /// impossible to reach by accident rather than a thing to remember. fn escaped(text: &str) -> String { text.replace('{', "{{").replace('}', "}}") }