Skip to main content

max / quasi

15.9 KB · 435 lines History Blame Raw
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 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.
51
52 use std::cell::RefCell;
53 use std::collections::BTreeMap;
54 use std::path::PathBuf;
55
56 use proc_macro2::{Group, Ident, Literal, TokenStream, TokenTree};
57 use syn::parse::{Parse, ParseStream};
58 use syn::{LitStr, Result, Token, braced};
59
60 use crate::ast::Item;
61
62 // The files this expansion read, absolute, in the order they were read.
63 //
64 // A thread-local because the reading happens inside a `Parse` impl, whose
65 // signature has nowhere to thread a collector through, and one macro expansion
66 // is one pass on one thread. `crate::declare` drains it directly after parsing
67 // and turns each path into an `include_bytes!`, which is what makes rustc
68 // rebuild the crate when the copy changes. Without that marker a content edit
69 // is invisible: the file is read by the macro and named nowhere the compiler
70 // looks.
71 thread_local! {
72 static READ: RefCell<Vec<PathBuf>> = const { RefCell::new(Vec::new()) };
73 }
74
75 /// The files read since the last drain.
76 pub fn taken() -> Vec<PathBuf> {
77 READ.with(|read| std::mem::take(&mut *read.borrow_mut()))
78 }
79
80 /// Whether the item ahead is a copy loop rather than an ordinary one.
81 ///
82 /// The word after `in`, and then a string literal. Both halves are needed and
83 /// the second is not belt-and-braces: `copy` on its own is a legal hole root,
84 /// because a hole root is any lowercase binding, and audiofiles' `importing`
85 /// has bound one -- `for weight in copy.weight.iter()`. A file path cannot be
86 /// mistaken for a field access, so the literal is what tells the two apart
87 /// without taking a word away from every declaration in the tree.
88 pub fn ahead(input: ParseStream) -> bool {
89 let fork = input.fork();
90 if fork.parse::<Token![for]>().is_err() {
91 return false;
92 }
93 if fork.peek(Token![&]) && fork.parse::<Token![&]>().is_err() {
94 return false;
95 }
96 if fork.parse::<Ident>().is_err() || fork.parse::<Token![in]>().is_err() {
97 return false;
98 }
99 if !fork.parse::<Ident>().is_ok_and(|word| word == "copy") {
100 return false;
101 }
102 fork.peek(LitStr)
103 }
104
105 /// Read the file and write the body out once per entry.
106 pub fn expand(input: ParseStream) -> Result<Vec<Item>> {
107 input.parse::<Token![for]>()?;
108 if input.peek(Token![&]) {
109 let ampersand = input.parse::<Token![&]>()?;
110 return Err(syn::Error::new(
111 ampersand.span,
112 "a copy loop binds text and not a reference: drop the `&`",
113 ));
114 }
115 let binder: Ident = input.parse()?;
116 input.parse::<Token![in]>()?;
117 input.parse::<Ident>()?;
118 let file: LitStr = input.parse()?;
119 input.parse::<Token![as]>()?;
120 let key: Ident = input.parse()?;
121
122 let body;
123 braced!(body in input);
124 let template: TokenStream = body.parse()?;
125
126 let mut items = Vec::new();
127 for entry in read(&file, &key)? {
128 let filled = substitute(template.clone(), &binder, &entry, &file)?;
129 items.extend(syn::parse2::<Body>(filled)?.0);
130 }
131 Ok(items)
132 }
133
134 /// A body's items, so a filled template can be parsed as what it became.
135 struct Body(Vec<Item>);
136
137 impl Parse for Body {
138 fn parse(input: ParseStream) -> Result<Self> {
139 Ok(Self(crate::parse::items(input)?))
140 }
141 }
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
152 /// One entry's fields, by name.
153 type Entry = BTreeMap<String, Value>;
154
155 /// The entries under `key`, read from the file the declaration named.
156 ///
157 /// The path is relative to the manifest of the crate being compiled, which is
158 /// the only root a macro can resolve against that does not depend on which file
159 /// the invocation sits in.
160 fn read(file: &LitStr, key: &Ident) -> Result<Vec<Entry>> {
161 let root = std::env::var("CARGO_MANIFEST_DIR").map_err(|_| {
162 syn::Error::new(
163 file.span(),
164 "`copy` needs CARGO_MANIFEST_DIR, which cargo sets and a bare rustc does not",
165 )
166 })?;
167 let path = PathBuf::from(root).join(file.value());
168 let text = std::fs::read_to_string(&path).map_err(|error| {
169 syn::Error::new(
170 file.span(),
171 format!("cannot read {}: {error}", path.display()),
172 )
173 })?;
174 READ.with(|read| read.borrow_mut().push(path.clone()));
175
176 let table: toml::Table = text.parse().map_err(|error| {
177 syn::Error::new(
178 file.span(),
179 format!("{} is not TOML: {error}", path.display()),
180 )
181 })?;
182 let named = format!("`{key}` in {}", path.display());
183 let Some(value) = table.get(&key.to_string()) else {
184 return Err(syn::Error::new(key.span(), format!("no {named}")));
185 };
186 let Some(array) = value.as_array() else {
187 return Err(syn::Error::new(
188 key.span(),
189 format!("{named} is not an array of tables"),
190 ));
191 };
192
193 let mut entries = Vec::new();
194 for (index, element) in array.iter().enumerate() {
195 let Some(fields) = element.as_table() else {
196 return Err(syn::Error::new(
197 key.span(),
198 format!("{named} entry {index} is not a table"),
199 ));
200 };
201 let mut entry = Entry::new();
202 for (name, field) in fields {
203 let refused = || {
204 syn::Error::new(
205 key.span(),
206 format!(
207 "{named} entry {index} field `{name}` is not a string \
208 or a list of them: copy is words"
209 ),
210 )
211 };
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);
224 }
225 entries.push(entry);
226 }
227 Ok(entries)
228 }
229
230 /// The template with every `binder.field` replaced by the text it names.
231 ///
232 /// A token walk rather than a walk of the syntax tree, because the tree is not
233 /// built yet and building it first would mean a second traversal of every
234 /// production in the grammar, drifting from the first the way a second emitter
235 /// would. `binder.field` is three tokens in every position the grammar admits,
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.
242 fn substitute(
243 template: TokenStream,
244 binder: &Ident,
245 entry: &Entry,
246 file: &LitStr,
247 ) -> Result<TokenStream> {
248 let mut filled = Vec::new();
249 let tokens: Vec<TokenTree> = template.into_iter().collect();
250 let mut index = 0;
251
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] {
259 TokenTree::Group(group) => {
260 let inner = substitute(group.stream(), binder, entry, file)?;
261 let mut replacement = Group::new(group.delimiter(), inner);
262 replacement.set_span(group.span());
263 filled.push(TokenTree::Group(replacement));
264 index += 1;
265 }
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;
274 }
275 }
276 }
277 Ok(filled.into_iter().collect())
278 }
279
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,
289 binder: &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> {
411 let lone = || {
412 syn::Error::new(
413 binder.span(),
414 format!("`{binder}` is one entry's copy: name a field, as `{binder}.heading`"),
415 )
416 };
417 match tokens.get(index + 1) {
418 Some(TokenTree::Punct(punct)) if punct.as_char() == '.' => {}
419 _ => return Err(lone()),
420 }
421 match tokens.get(index + 2) {
422 Some(TokenTree::Ident(field)) => Ok(field.clone()),
423 _ => Err(lone()),
424 }
425 }
426
427 /// Copy with its braces doubled, so the string production reads it as text.
428 ///
429 /// A brace in copy is a brace somebody typed. A hole in copy is structure that
430 /// wandered into the wrong file, and doubling is what makes the difference
431 /// impossible to reach by accident rather than a thing to remember.
432 fn escaped(text: &str) -> String {
433 text.replace('{', "{{").replace('}', "}}")
434 }
435