Skip to main content

polydat_grammar/
tile.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! The tile template grammar (SRD 114 §2.2): holes, projections,
5//! branches, and the doubled-open escape, parsed over configurable
6//! delimiters and sigil. The textual front end for Polytile; the
7//! structural front end (§3) lowers to the same pieces.
8
9use crate::PortType;
10
11use super::ast::{Expr, TileHole, TileOptions, TilePiece};
12use super::lexer::{Span, lex};
13use super::parser::{for_source_from_text, parse_expression};
14
15/// Parse a tile body into pieces under `opts`. `span` is the tile
16/// statement's position, used for diagnostics.
17pub fn parse_template(
18    text: &str,
19    opts: &TileOptions,
20    span: Span,
21) -> Result<Vec<TilePiece>, String> {
22    let mut p = TemplateParser {
23        chars: text.chars().collect(),
24        pos: 0,
25        opts,
26        span,
27    };
28    let pieces = p.pieces(false)?;
29    if p.pos < p.chars.len() {
30        return Err(p.err("unexpected `}` closing a block that was never opened"));
31    }
32    Ok(pieces)
33}
34
35struct TemplateParser<'a> {
36    chars: Vec<char>,
37    pos: usize,
38    opts: &'a TileOptions,
39    span: Span,
40}
41
42impl TemplateParser<'_> {
43    fn err(&self, msg: &str) -> String {
44        format!(
45            "tile at line {}, col {}: {msg} (template offset {})",
46            self.span.line, self.span.col, self.pos
47        )
48    }
49
50    fn starts_with(&self, s: &str) -> bool {
51        let sc: Vec<char> = s.chars().collect();
52        self.chars[self.pos..].starts_with(&sc)
53    }
54
55    fn take(&mut self, s: &str) {
56        self.pos += s.chars().count();
57    }
58
59    fn rest(&self) -> String {
60        self.chars[self.pos..].iter().collect()
61    }
62
63    /// Parse pieces until end of text, or until an unmatched `}` when
64    /// `in_block` (the caller consumes it).
65    fn pieces(&mut self, in_block: bool) -> Result<Vec<TilePiece>, String> {
66        let open = self.opts.open.clone();
67        let doubled = format!("{open}{open}");
68        let sigil = self.opts.sigil.clone();
69        let mut out: Vec<TilePiece> = Vec::new();
70        let mut static_buf = String::new();
71        // Braces inside static text (JSON objects, for instance) are
72        // balanced within the block; only an unmatched `}` ends it.
73        let mut static_depth = 0i32;
74        let flush = |buf: &mut String, out: &mut Vec<TilePiece>| {
75            if !buf.is_empty() {
76                out.push(TilePiece::Static(std::mem::take(buf)));
77            }
78        };
79        while self.pos < self.chars.len() {
80            if self.starts_with(&doubled) {
81                self.take(&doubled);
82                static_buf.push_str(&open);
83                continue;
84            }
85            if self.starts_with(&open) {
86                flush(&mut static_buf, &mut out);
87                out.push(TilePiece::Hole(self.hole()?));
88                continue;
89            }
90            if self.starts_with(&sigil) {
91                let after: String = self.rest().chars().skip(sigil.chars().count()).collect();
92                if after.starts_with("for") && after[3..].starts_with(char::is_whitespace) {
93                    flush(&mut static_buf, &mut out);
94                    out.push(self.projection()?);
95                    continue;
96                }
97                if after.starts_with("if") && after[2..].starts_with(char::is_whitespace) {
98                    flush(&mut static_buf, &mut out);
99                    out.push(self.branch()?);
100                    continue;
101                }
102            }
103            let c = self.chars[self.pos];
104            if c == '{' {
105                static_depth += 1;
106            } else if c == '}' {
107                if in_block && static_depth == 0 {
108                    break;
109                }
110                static_depth -= 1;
111            }
112            static_buf.push(c);
113            self.pos += 1;
114        }
115        flush(&mut static_buf, &mut out);
116        Ok(out)
117    }
118
119    /// `${ expr [: type] [| format] [!] }`. Positioned at the open delimiter.
120    fn hole(&mut self) -> Result<TileHole, String> {
121        let start = self.pos;
122        self.take(&self.opts.open.clone());
123        let inner = self.until_close()?;
124        let text = inner.trim().to_string();
125        if text.is_empty() {
126            return Err(self.err("empty hole"));
127        }
128        let (mut body, raw) = match text.strip_suffix('!') {
129            Some(b) => (b.trim().to_string(), true),
130            None => (text.clone(), false),
131        };
132        let mut format = None;
133        if let Some(idx) = rfind_top_level(&body, '|')
134            && !body[..idx].ends_with('|')
135            && body[idx + 1..]
136                .trim()
137                .chars()
138                .all(|c| c.is_ascii_alphanumeric() || ".<>^-+#0_".contains(c))
139            && !body[idx + 1..].trim().is_empty()
140        {
141            format = Some(body[idx + 1..].trim().to_string());
142            body = body[..idx].trim().to_string();
143        }
144        let mut decl_type = None;
145        if let Some(idx) = rfind_top_level(&body, ':')
146            && let Some(kw) = body.get(idx + 1..).map(str::trim)
147            && PortType::from_keyword(kw).is_some()
148        {
149            decl_type = Some(kw.to_string());
150            body = body[..idx].trim().to_string();
151        }
152        if body.is_empty() {
153            return Err(self.err(&format!("hole `{text}` has no expression")));
154        }
155        let expr = parse_hole_expr(&body).map_err(|e| {
156            // `${x: integer}`: the suffix looked like a declaration but
157            // is not a type keyword, so it stayed in the expression.
158            if let Some(idx) = rfind_top_level(&body, ':')
159                && let Some(word) = body.get(idx + 1..).map(str::trim)
160                && !word.is_empty()
161                && word.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
162            {
163                return self.err(&format!(
164                    "hole `{text}`: unknown type '{word}'; types are the port-type keywords (u64, i64, f64, str, bool, json, bytes, ...)"
165                ));
166            }
167            self.err(&format!("hole `{text}`: {e}"))
168        })?;
169        let _ = start;
170        Ok(TileHole {
171            expr,
172            decl_type,
173            format,
174            raw,
175            span: self.span,
176        })
177    }
178
179    /// Consume up to and including the close delimiter at bracket depth
180    /// zero, returning the enclosed text.
181    fn until_close(&mut self) -> Result<String, String> {
182        let close = self.opts.close.clone();
183        let mut depth = 0i32;
184        let mut quote: Option<char> = None;
185        let mut buf = String::new();
186        while self.pos < self.chars.len() {
187            let c = self.chars[self.pos];
188            if let Some(q) = quote {
189                buf.push(c);
190                self.pos += 1;
191                if c == '\\' && self.pos < self.chars.len() {
192                    buf.push(self.chars[self.pos]);
193                    self.pos += 1;
194                } else if c == q {
195                    quote = None;
196                }
197                continue;
198            }
199            if depth == 0 && self.starts_with(&close) {
200                self.take(&close);
201                return Ok(buf);
202            }
203            match c {
204                '"' | '\'' => quote = Some(c),
205                '(' | '[' | '{' => depth += 1,
206                ')' | ']' | '}' => depth -= 1,
207                _ => {}
208            }
209            buf.push(c);
210            self.pos += 1;
211        }
212        Err(self.err(&format!("unterminated hole; expected `{close}`")))
213    }
214
215    /// `@for <source> [sep "..."] { body }`. Positioned at the sigil.
216    fn projection(&mut self) -> Result<TilePiece, String> {
217        self.take(&self.opts.sigil.clone());
218        self.take("for");
219        let header = self.header_until_brace(HeaderKind::For)?;
220        let (source_text, sep) = split_sep(&header);
221        let source = for_source_from_text(source_text.trim(), self.span, true)
222            .map_err(|e| self.err(&format!("projection: {e}")))?;
223        let body = self.block()?;
224        Ok(TilePiece::Projection {
225            source,
226            sep,
227            body,
228            span: self.span,
229        })
230    }
231
232    /// `@if cond { body } [@else { body }]`. Positioned at the sigil.
233    fn branch(&mut self) -> Result<TilePiece, String> {
234        self.take(&self.opts.sigil.clone());
235        self.take("if");
236        let header = self.header_until_brace(HeaderKind::If)?;
237        let cond = parse_hole_expr(header.trim())
238            .map_err(|e| self.err(&format!("branch condition `{}`: {e}", header.trim())))?;
239        let then = self.block()?;
240        let save = self.pos;
241        self.skip_ws();
242        let else_kw = format!("{}else", self.opts.sigil);
243        let otherwise = if self.starts_with(&else_kw) {
244            self.take(&else_kw);
245            self.skip_ws();
246            Some(self.block()?)
247        } else {
248            self.pos = save;
249            None
250        };
251        Ok(TilePiece::Branch {
252            cond,
253            then,
254            otherwise,
255            span: self.span,
256        })
257    }
258
259    /// Text up to a `{` at bracket depth zero that is not a `{name}`
260    /// placeholder. A hole cannot appear in a directive header, so the
261    /// open delimiter is not considered here. Leaves the brace
262    /// unconsumed.
263    fn header_until_brace(&mut self, kind: HeaderKind) -> Result<String, String> {
264        let mut depth = 0i32;
265        let mut quote: Option<char> = None;
266        let mut buf = String::new();
267        while self.pos < self.chars.len() {
268            let c = self.chars[self.pos];
269            if let Some(q) = quote {
270                buf.push(c);
271                self.pos += 1;
272                if c == q {
273                    quote = None;
274                }
275                continue;
276            }
277            // A hole cannot appear in a directive header; a delimiter
278            // here means the block never came. (Delimiters that begin
279            // with `{` are checked as blocks below instead.)
280            if depth == 0
281                && !self.opts.open.starts_with('{')
282                && self.starts_with(&self.opts.open.clone())
283            {
284                return Err(self.err(
285                    "directive has no `{` block; a hole cannot appear in a directive header",
286                ));
287            }
288            if depth == 0 && c == '{' {
289                // `{name}` is an interpolation placeholder only when it
290                // is attached to header text (`1..{n}`, `{a}..{b}`). A
291                // free-standing `{name}` after whitespace is the block:
292                // `@if x {plain}`.
293                // A free-standing one followed by an operator continues
294                // the header (`where {k} > 0 {`); followed by whitespace
295                // and then text, a sigil, a delimiter, or another `{`,
296                // it is the block itself.
297                const HEADER_PUNCT: &str = "<>=!+-*/%.,()[]&|?:";
298                let placeholder = placeholder_len(&self.chars, self.pos).is_some_and(|len| {
299                    let before = buf.chars().last().is_some_and(|b| HEADER_PUNCT.contains(b));
300                    let after = self
301                        .chars
302                        .get(self.pos + len)
303                        .is_some_and(|a| HEADER_PUNCT.contains(*a));
304                    if before || after {
305                        return true;
306                    }
307                    let rest: String = self.chars[self.pos + len..].iter().collect();
308                    let rest = rest.trim_start();
309                    if rest.is_empty()
310                        || rest.starts_with(&self.opts.sigil)
311                        || rest.starts_with(&self.opts.open)
312                    {
313                        return false;
314                    }
315                    if rest.starts_with(|c: char| HEADER_PUNCT.contains(c) && !"()[]".contains(c)) {
316                        return true;
317                    }
318                    // Free-standing, followed by more text: it is the
319                    // block when the header so far is already complete
320                    // (`@if x {plain} tail`), and interpolation when the
321                    // header still needs it (`where {k} < {limit} {`).
322                    !header_complete(kind, &buf)
323                });
324                if !placeholder {
325                    return Ok(buf);
326                }
327            }
328            match c {
329                '"' | '\'' => quote = Some(c),
330                '(' | '[' => depth += 1,
331                ')' | ']' => depth -= 1,
332                _ => {}
333            }
334            buf.push(c);
335            self.pos += 1;
336        }
337        Err(self.err("directive has no `{` block"))
338    }
339
340    /// A `{ ... }` block; returns its pieces. Positioned at `{`.
341    fn block(&mut self) -> Result<Vec<TilePiece>, String> {
342        if self.pos >= self.chars.len() || self.chars[self.pos] != '{' {
343            return Err(self.err("expected `{`"));
344        }
345        self.pos += 1;
346        let mut body = self.pieces(true)?;
347        if self.pos >= self.chars.len() || self.chars[self.pos] != '}' {
348            return Err(self.err("unterminated block; expected `}`"));
349        }
350        self.pos += 1;
351        trim_block(&mut body);
352        Ok(body)
353    }
354
355    fn skip_ws(&mut self) {
356        while self.pos < self.chars.len() && self.chars[self.pos].is_whitespace() {
357            self.pos += 1;
358        }
359    }
360}
361
362/// The braces of a directive block delimit it; the whitespace that pads
363/// them for readability is not part of the body. `@if x { "hot" }`
364/// renders `"hot"`, and `@for` items concatenate without stray blanks.
365fn trim_block(body: &mut Vec<TilePiece>) {
366    if let Some(TilePiece::Static(s)) = body.first_mut() {
367        let t = s.trim_start().to_string();
368        *s = t;
369    }
370    if let Some(TilePiece::Static(s)) = body.last_mut() {
371        let t = s.trim_end().to_string();
372        *s = t;
373    }
374    body.retain(|p| !matches!(p, TilePiece::Static(s) if s.is_empty()));
375}
376
377/// Which directive a header belongs to; decides what "complete" means.
378#[derive(Clone, Copy)]
379enum HeaderKind {
380    For,
381    If,
382}
383
384/// Whether header text so far already forms a whole directive header.
385/// A `for` header must parse as a source and not end mid-expression; an
386/// `if` header must parse as an expression.
387fn header_complete(kind: HeaderKind, header: &str) -> bool {
388    let h = header.trim();
389    if h.is_empty() {
390        return false;
391    }
392    match kind {
393        HeaderKind::If => parse_hole_expr(h).is_ok(),
394        HeaderKind::For => {
395            let dangling = h.ends_with(|c: char| "<>=!+-*/%&|,(".contains(c))
396                || ["where", "order", "in", "&&", "||"]
397                    .iter()
398                    .any(|kw| h.ends_with(kw));
399            if dangling {
400                return false;
401            }
402            let (source, _) = split_sep(h);
403            for_source_from_text(source.trim(), Span { line: 0, col: 0 }, true).is_ok()
404        }
405    }
406}
407
408/// Split a trailing `sep "<text>"` off a directive header. Inside a JSON
409/// string literal the quotes arrive escaped as `\"`; both spellings are
410/// accepted.
411fn split_sep(header: &str) -> (String, Option<String>) {
412    let unescaped = header.replace("\\\"", "\"");
413    let t = unescaped.trim_end();
414    if let Some(q) = t.strip_suffix('"')
415        && let Some(open_quote) = q.rfind('"')
416        && q[..open_quote].trim_end().ends_with(" sep")
417    {
418        let sep = q[open_quote + 1..].to_string();
419        let head = q[..open_quote].trim_end();
420        let head = head[..head.len() - 3].trim_end();
421        return (head.to_string(), Some(sep));
422    }
423    (t.to_string(), None)
424}
425
426pub fn parse_hole_expr(text: &str) -> Result<Expr, String> {
427    let tokens = lex(text)?;
428    parse_expression(tokens)
429}
430
431/// Index of the last `needle` at bracket depth zero and outside quotes.
432fn rfind_top_level(s: &str, needle: char) -> Option<usize> {
433    let mut depth = 0i32;
434    let mut quote: Option<char> = None;
435    let mut found = None;
436    for (i, c) in s.char_indices() {
437        if let Some(q) = quote {
438            if c == q {
439                quote = None;
440            }
441            continue;
442        }
443        match c {
444            '"' | '\'' => quote = Some(c),
445            '(' | '[' | '{' => depth += 1,
446            ')' | ']' | '}' => depth -= 1,
447            _ if depth == 0 && c == needle => found = Some(i),
448            _ => {}
449        }
450    }
451    found
452}
453
454/// Length of a `{identifier}` placeholder at `pos`, if present.
455fn placeholder_len(chars: &[char], pos: usize) -> Option<usize> {
456    let mut i = pos + 1;
457    let first = *chars.get(i)?;
458    if !(first.is_ascii_alphabetic() || first == '_') {
459        return None;
460    }
461    while i < chars.len() && (chars[i].is_ascii_alphanumeric() || chars[i] == '_') {
462        i += 1;
463    }
464    (chars.get(i) == Some(&'}')).then_some(i + 1 - pos)
465}
466
467/// Render pieces back to template text under `opts`, the inverse of
468/// [`parse_template`] up to whitespace inside directives.
469pub fn render_template(pieces: &[TilePiece], opts: &TileOptions) -> String {
470    let mut out = String::new();
471    for piece in pieces {
472        match piece {
473            TilePiece::Static(s) => {
474                out.push_str(&s.replace(&opts.open, &format!("{}{}", opts.open, opts.open)))
475            }
476            TilePiece::Hole(h) => {
477                out.push_str(&opts.open);
478                out.push_str(&h.to_text());
479                out.push_str(&opts.close);
480            }
481            TilePiece::Projection {
482                source, sep, body, ..
483            } => {
484                out.push_str(&opts.sigil);
485                out.push_str("for ");
486                out.push_str(&source.to_text());
487                if let Some(s) = sep {
488                    out.push_str(&format!(" sep \"{s}\""));
489                }
490                out.push_str(" { ");
491                out.push_str(&render_template(body, opts));
492                out.push_str(" }");
493            }
494            TilePiece::Branch {
495                cond,
496                then,
497                otherwise,
498                ..
499            } => {
500                out.push_str(&opts.sigil);
501                out.push_str("if ");
502                out.push_str(&super::pprint::pp_expr(cond));
503                out.push_str(" { ");
504                out.push_str(&render_template(then, opts));
505                out.push_str(" }");
506                if let Some(o) = otherwise {
507                    out.push(' ');
508                    out.push_str(&opts.sigil);
509                    out.push_str("else { ");
510                    out.push_str(&render_template(o, opts));
511                    out.push_str(" }");
512                }
513            }
514        }
515    }
516    out
517}