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            text,
172            expr,
173            decl_type,
174            format,
175            raw,
176            span: self.span,
177        })
178    }
179
180    /// Consume up to and including the close delimiter at bracket depth
181    /// zero, returning the enclosed text.
182    fn until_close(&mut self) -> Result<String, String> {
183        let close = self.opts.close.clone();
184        let mut depth = 0i32;
185        let mut quote: Option<char> = None;
186        let mut buf = String::new();
187        while self.pos < self.chars.len() {
188            let c = self.chars[self.pos];
189            if let Some(q) = quote {
190                buf.push(c);
191                self.pos += 1;
192                if c == '\\' && self.pos < self.chars.len() {
193                    buf.push(self.chars[self.pos]);
194                    self.pos += 1;
195                } else if c == q {
196                    quote = None;
197                }
198                continue;
199            }
200            if depth == 0 && self.starts_with(&close) {
201                self.take(&close);
202                return Ok(buf);
203            }
204            match c {
205                '"' | '\'' => quote = Some(c),
206                '(' | '[' | '{' => depth += 1,
207                ')' | ']' | '}' => depth -= 1,
208                _ => {}
209            }
210            buf.push(c);
211            self.pos += 1;
212        }
213        Err(self.err(&format!("unterminated hole; expected `{close}`")))
214    }
215
216    /// `@for <source> [sep "..."] { body }`. Positioned at the sigil.
217    fn projection(&mut self) -> Result<TilePiece, String> {
218        self.take(&self.opts.sigil.clone());
219        self.take("for");
220        let header = self.header_until_brace(HeaderKind::For)?;
221        let (source_text, sep) = split_sep(&header);
222        let source = for_source_from_text(source_text.trim(), self.span, true)
223            .map_err(|e| self.err(&format!("projection: {e}")))?;
224        let body = self.block()?;
225        Ok(TilePiece::Projection {
226            source,
227            sep,
228            body,
229            span: self.span,
230        })
231    }
232
233    /// `@if cond { body } [@else { body }]`. Positioned at the sigil.
234    fn branch(&mut self) -> Result<TilePiece, String> {
235        self.take(&self.opts.sigil.clone());
236        self.take("if");
237        let header = self.header_until_brace(HeaderKind::If)?;
238        let cond = parse_hole_expr(header.trim())
239            .map_err(|e| self.err(&format!("branch condition `{}`: {e}", header.trim())))?;
240        let then = self.block()?;
241        let save = self.pos;
242        self.skip_ws();
243        let else_kw = format!("{}else", self.opts.sigil);
244        let otherwise = if self.starts_with(&else_kw) {
245            self.take(&else_kw);
246            self.skip_ws();
247            Some(self.block()?)
248        } else {
249            self.pos = save;
250            None
251        };
252        Ok(TilePiece::Branch {
253            cond,
254            then,
255            otherwise,
256            span: self.span,
257        })
258    }
259
260    /// Text up to a `{` at bracket depth zero that is not a `{name}`
261    /// placeholder. A hole cannot appear in a directive header, so the
262    /// open delimiter is not considered here. Leaves the brace
263    /// unconsumed.
264    fn header_until_brace(&mut self, kind: HeaderKind) -> Result<String, String> {
265        let mut depth = 0i32;
266        let mut quote: Option<char> = None;
267        let mut buf = String::new();
268        while self.pos < self.chars.len() {
269            let c = self.chars[self.pos];
270            if let Some(q) = quote {
271                buf.push(c);
272                self.pos += 1;
273                if c == q {
274                    quote = None;
275                }
276                continue;
277            }
278            // A hole cannot appear in a directive header; a delimiter
279            // here means the block never came. (Delimiters that begin
280            // with `{` are checked as blocks below instead.)
281            if depth == 0
282                && !self.opts.open.starts_with('{')
283                && self.starts_with(&self.opts.open.clone())
284            {
285                return Err(self.err(
286                    "directive has no `{` block; a hole cannot appear in a directive header",
287                ));
288            }
289            if depth == 0 && c == '{' {
290                // `{name}` is an interpolation placeholder only when it
291                // is attached to header text (`1..{n}`, `{a}..{b}`). A
292                // free-standing `{name}` after whitespace is the block:
293                // `@if x {plain}`.
294                // A free-standing one followed by an operator continues
295                // the header (`where {k} > 0 {`); followed by whitespace
296                // and then text, a sigil, a delimiter, or another `{`,
297                // it is the block itself.
298                const HEADER_PUNCT: &str = "<>=!+-*/%.,()[]&|?:";
299                let placeholder = placeholder_len(&self.chars, self.pos).is_some_and(|len| {
300                    let before = buf.chars().last().is_some_and(|b| HEADER_PUNCT.contains(b));
301                    let after = self
302                        .chars
303                        .get(self.pos + len)
304                        .is_some_and(|a| HEADER_PUNCT.contains(*a));
305                    if before || after {
306                        return true;
307                    }
308                    let rest: String = self.chars[self.pos + len..].iter().collect();
309                    let rest = rest.trim_start();
310                    if rest.is_empty()
311                        || rest.starts_with(&self.opts.sigil)
312                        || rest.starts_with(&self.opts.open)
313                    {
314                        return false;
315                    }
316                    if rest.starts_with(|c: char| HEADER_PUNCT.contains(c) && !"()[]".contains(c)) {
317                        return true;
318                    }
319                    // Free-standing, followed by more text: it is the
320                    // block when the header so far is already complete
321                    // (`@if x {plain} tail`), and interpolation when the
322                    // header still needs it (`where {k} < {limit} {`).
323                    !header_complete(kind, &buf)
324                });
325                if !placeholder {
326                    return Ok(buf);
327                }
328            }
329            match c {
330                '"' | '\'' => quote = Some(c),
331                '(' | '[' => depth += 1,
332                ')' | ']' => depth -= 1,
333                _ => {}
334            }
335            buf.push(c);
336            self.pos += 1;
337        }
338        Err(self.err("directive has no `{` block"))
339    }
340
341    /// A `{ ... }` block; returns its pieces. Positioned at `{`.
342    fn block(&mut self) -> Result<Vec<TilePiece>, String> {
343        if self.pos >= self.chars.len() || self.chars[self.pos] != '{' {
344            return Err(self.err("expected `{`"));
345        }
346        self.pos += 1;
347        let mut body = self.pieces(true)?;
348        if self.pos >= self.chars.len() || self.chars[self.pos] != '}' {
349            return Err(self.err("unterminated block; expected `}`"));
350        }
351        self.pos += 1;
352        trim_block(&mut body);
353        Ok(body)
354    }
355
356    fn skip_ws(&mut self) {
357        while self.pos < self.chars.len() && self.chars[self.pos].is_whitespace() {
358            self.pos += 1;
359        }
360    }
361}
362
363/// The braces of a directive block delimit it; the whitespace that pads
364/// them for readability is not part of the body. `@if x { "hot" }`
365/// renders `"hot"`, and `@for` items concatenate without stray blanks.
366fn trim_block(body: &mut Vec<TilePiece>) {
367    if let Some(TilePiece::Static(s)) = body.first_mut() {
368        let t = s.trim_start().to_string();
369        *s = t;
370    }
371    if let Some(TilePiece::Static(s)) = body.last_mut() {
372        let t = s.trim_end().to_string();
373        *s = t;
374    }
375    body.retain(|p| !matches!(p, TilePiece::Static(s) if s.is_empty()));
376}
377
378/// Which directive a header belongs to; decides what "complete" means.
379#[derive(Clone, Copy)]
380enum HeaderKind {
381    For,
382    If,
383}
384
385/// Whether header text so far already forms a whole directive header.
386/// A `for` header must parse as a source and not end mid-expression; an
387/// `if` header must parse as an expression.
388fn header_complete(kind: HeaderKind, header: &str) -> bool {
389    let h = header.trim();
390    if h.is_empty() {
391        return false;
392    }
393    match kind {
394        HeaderKind::If => parse_hole_expr(h).is_ok(),
395        HeaderKind::For => {
396            let dangling = h.ends_with(|c: char| "<>=!+-*/%&|,(".contains(c))
397                || ["where", "order", "in", "&&", "||"]
398                    .iter()
399                    .any(|kw| h.ends_with(kw));
400            if dangling {
401                return false;
402            }
403            let (source, _) = split_sep(h);
404            for_source_from_text(source.trim(), Span { line: 0, col: 0 }, true).is_ok()
405        }
406    }
407}
408
409/// Split a trailing `sep "<text>"` off a directive header. Inside a JSON
410/// string literal the quotes arrive escaped as `\"`; both spellings are
411/// accepted.
412fn split_sep(header: &str) -> (String, Option<String>) {
413    let unescaped = header.replace("\\\"", "\"");
414    let t = unescaped.trim_end();
415    if let Some(q) = t.strip_suffix('"')
416        && let Some(open_quote) = q.rfind('"')
417        && q[..open_quote].trim_end().ends_with(" sep")
418    {
419        let sep = q[open_quote + 1..].to_string();
420        let head = q[..open_quote].trim_end();
421        let head = head[..head.len() - 3].trim_end();
422        return (head.to_string(), Some(sep));
423    }
424    (t.to_string(), None)
425}
426
427pub fn parse_hole_expr(text: &str) -> Result<Expr, String> {
428    let tokens = lex(text)?;
429    parse_expression(tokens)
430}
431
432/// Index of the last `needle` at bracket depth zero and outside quotes.
433fn rfind_top_level(s: &str, needle: char) -> Option<usize> {
434    let mut depth = 0i32;
435    let mut quote: Option<char> = None;
436    let mut found = None;
437    for (i, c) in s.char_indices() {
438        if let Some(q) = quote {
439            if c == q {
440                quote = None;
441            }
442            continue;
443        }
444        match c {
445            '"' | '\'' => quote = Some(c),
446            '(' | '[' | '{' => depth += 1,
447            ')' | ']' | '}' => depth -= 1,
448            _ if depth == 0 && c == needle => found = Some(i),
449            _ => {}
450        }
451    }
452    found
453}
454
455/// Length of a `{identifier}` placeholder at `pos`, if present.
456fn placeholder_len(chars: &[char], pos: usize) -> Option<usize> {
457    let mut i = pos + 1;
458    let first = *chars.get(i)?;
459    if !(first.is_ascii_alphabetic() || first == '_') {
460        return None;
461    }
462    while i < chars.len() && (chars[i].is_ascii_alphanumeric() || chars[i] == '_') {
463        i += 1;
464    }
465    (chars.get(i) == Some(&'}')).then_some(i + 1 - pos)
466}
467
468/// Render pieces back to template text under `opts`, the inverse of
469/// [`parse_template`] up to whitespace inside directives.
470pub fn render_template(pieces: &[TilePiece], opts: &TileOptions) -> String {
471    let mut out = String::new();
472    for piece in pieces {
473        match piece {
474            TilePiece::Static(s) => {
475                out.push_str(&s.replace(&opts.open, &format!("{}{}", opts.open, opts.open)))
476            }
477            TilePiece::Hole(h) => {
478                out.push_str(&opts.open);
479                out.push_str(&h.text);
480                out.push_str(&opts.close);
481            }
482            TilePiece::Projection {
483                source, sep, body, ..
484            } => {
485                out.push_str(&opts.sigil);
486                out.push_str("for ");
487                out.push_str(&source.text);
488                if let Some(s) = sep {
489                    out.push_str(&format!(" sep \"{s}\""));
490                }
491                out.push_str(" { ");
492                out.push_str(&render_template(body, opts));
493                out.push_str(" }");
494            }
495            TilePiece::Branch {
496                cond,
497                then,
498                otherwise,
499                ..
500            } => {
501                out.push_str(&opts.sigil);
502                out.push_str("if ");
503                out.push_str(&super::pprint::pp_expr(cond));
504                out.push_str(" { ");
505                out.push_str(&render_template(then, opts));
506                out.push_str(" }");
507                if let Some(o) = otherwise {
508                    out.push(' ');
509                    out.push_str(&opts.sigil);
510                    out.push_str("else { ");
511                    out.push_str(&render_template(o, opts));
512                    out.push_str(" }");
513                }
514            }
515        }
516    }
517    out
518}