Skip to main content

polydat_grammar/
tile_structural.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! The structural front end and the host boundary of Polytile (SRD 114
5//! §3, §5.6).
6//!
7//! A host may hold a template as text, as JSON text, or as a JSON value
8//! it has already parsed. All three arrive here and leave as a
9//! [`TileDef`], the same statement the `tile` keyword produces, so the
10//! compiler has one tile to compile. The structural form is turned into
11//! template text first: strings become value holes, string holes, or
12//! statics by the rules of §3.1, and directive arrays and objects
13//! become `@for` and `@if` blocks by the rules of §3.2. The textual
14//! parser then yields the pieces, which is what makes the two forms
15//! equivalent by construction.
16
17use serde_json::{Map, Value};
18
19use super::ast::{TileBodyKind, TileDef, TileOptions, TilePiece};
20use super::lexer::Span;
21use super::tile::parse_template;
22
23/// The encodings a tile may declare.
24pub const ENCODINGS: &[&str] = &["json", "text", "csv"];
25
26fn check_encoding(name: &str, encoding: &str) -> Result<(), String> {
27    if ENCODINGS.contains(&encoding) {
28        Ok(())
29    } else {
30        Err(format!(
31            "tile '{name}': unknown encoding '{encoding}'; encodings are json, text, csv"
32        ))
33    }
34}
35
36/// A tile from template text a host holds: the body of a `tile`
37/// statement without the statement. `text` may be multi-line; it is
38/// taken exactly, as a heredoc body is.
39pub fn tile_from_text(
40    name: &str,
41    encoding: &str,
42    text: &str,
43    options: &TileOptions,
44    span: Span,
45) -> Result<TileDef, String> {
46    check_encoding(name, encoding)?;
47    let pieces = parse_template(text, options, span).map_err(|e| format!("tile '{name}': {e}"))?;
48    Ok(TileDef {
49        name: name.to_string(),
50        encoding: Some(encoding.to_string()),
51        options: options.clone(),
52        body_kind: TileBodyKind::Heredoc,
53        body: text.to_string(),
54        pieces,
55        span,
56    })
57}
58
59/// A tile from a structural template in JSON text (§3). The encoding is
60/// `json`.
61pub fn tile_from_json_text(
62    name: &str,
63    json: &str,
64    options: &TileOptions,
65    span: Span,
66) -> Result<TileDef, String> {
67    let value: Value = serde_json::from_str(json)
68        .map_err(|e| format!("tile '{name}': structural template is not JSON: {e}"))?;
69    tile_from_json_value(name, &value, options, span)
70}
71
72/// A tile from a structural template a host has already parsed (§3).
73/// The encoding is `json`.
74pub fn tile_from_json_value(
75    name: &str,
76    value: &Value,
77    options: &TileOptions,
78    span: Span,
79) -> Result<TileDef, String> {
80    let body =
81        template_text_from_value(value, options).map_err(|e| format!("tile '{name}': {e}"))?;
82    let pieces = parse_template(&body, options, span).map_err(|e| format!("tile '{name}': {e}"))?;
83    Ok(TileDef {
84        name: name.to_string(),
85        encoding: Some("json".to_string()),
86        options: options.clone(),
87        body_kind: TileBodyKind::Block,
88        body,
89        pieces,
90        span,
91    })
92}
93
94/// Turn a structural template into template text under `options`.
95/// The text is what an author would have written in a `tile ... : json`
96/// block for the same document.
97pub fn template_text_from_value(value: &Value, options: &TileOptions) -> Result<String, String> {
98    let mut t = Textualizer {
99        opts: options,
100        out: String::new(),
101    };
102    t.value(value)?;
103    Ok(t.out)
104}
105
106struct Textualizer<'a> {
107    opts: &'a TileOptions,
108    out: String,
109}
110
111/// Where a directive member's separating comma goes.
112#[derive(Clone, Copy, PartialEq, Eq)]
113enum Comma {
114    /// A static member: the ordinary `, ` between members.
115    Between,
116    /// A directive with a member before it: `, ` opens each repetition.
117    Leading,
118    /// A directive with members after it: `, ` closes each repetition.
119    Trailing,
120    /// A lone directive: the encoding's separator between repetitions.
121    Sep,
122}
123
124/// A directive string: `@for <header>`, `@if <cond>`, or `@else`.
125enum Directive<'s> {
126    For(&'s str),
127    If(&'s str),
128    Else,
129}
130
131impl Textualizer<'_> {
132    fn directive<'s>(&self, s: &'s str) -> Option<Directive<'s>> {
133        let rest = s.trim().strip_prefix(self.opts.sigil.as_str())?;
134        if let Some(h) = rest.strip_prefix("for")
135            && h.starts_with(char::is_whitespace)
136        {
137            return Some(Directive::For(h.trim()));
138        }
139        if let Some(c) = rest.strip_prefix("if")
140            && c.starts_with(char::is_whitespace)
141        {
142            return Some(Directive::If(c.trim()));
143        }
144        if rest == "else" {
145            return Some(Directive::Else);
146        }
147        None
148    }
149
150    fn value(&mut self, v: &Value) -> Result<(), String> {
151        match v {
152            Value::Null | Value::Bool(_) | Value::Number(_) => self.out.push_str(&v.to_string()),
153            Value::String(s) => self.string(s, false)?,
154            Value::Array(items) => self.array(items)?,
155            Value::Object(map) => self.object(map)?,
156        }
157        Ok(())
158    }
159
160    /// A string node (§3.1). As a member name, `key` is set and the
161    /// string is always in string position.
162    fn string(&mut self, s: &str, key: bool) -> Result<(), String> {
163        if self.directive(s).is_some() {
164            return Err(format!(
165                "directive string `{s}` outside a directive position; a `{}for` or `{}if` string leads an array, or is an object key",
166                self.opts.sigil, self.opts.sigil
167            ));
168        }
169        let pieces = parse_template(s, self.opts, Span { line: 0, col: 0 })
170            .map_err(|e| format!("in string `{s}`: {e}"))?;
171        if pieces
172            .iter()
173            .any(|p| matches!(p, TilePiece::Projection { .. } | TilePiece::Branch { .. }))
174        {
175            return Err(format!(
176                "string `{s}` contains a directive; in the structural form directives are arrays and object keys"
177            ));
178        }
179        if !key
180            && let [TilePiece::Hole(h)] = pieces.as_slice()
181            && h.decl_type.as_deref() != Some("str")
182        {
183            // A value hole: the node is the value, encoded by type.
184            self.out.push_str(&self.opts.open);
185            self.out.push_str(&h.text);
186            self.out.push_str(&self.opts.close);
187            return Ok(());
188        }
189        // A string hole or a static string.
190        self.out.push('"');
191        for piece in &pieces {
192            match piece {
193                TilePiece::Static(text) => {
194                    let escaped = serde_json::to_string(text).expect("string serializes");
195                    let inner = &escaped[1..escaped.len() - 1];
196                    self.out.push_str(&inner.replace(
197                        &self.opts.open,
198                        &format!("{}{}", self.opts.open, self.opts.open),
199                    ));
200                }
201                TilePiece::Hole(h) => {
202                    self.out.push_str(&self.opts.open);
203                    self.out.push_str(&h.text);
204                    self.out.push_str(&self.opts.close);
205                }
206                _ => unreachable!("directives rejected above"),
207            }
208        }
209        self.out.push('"');
210        Ok(())
211    }
212
213    fn array(&mut self, items: &[Value]) -> Result<(), String> {
214        self.out.push('[');
215        match items
216            .first()
217            .and_then(|f| f.as_str())
218            .and_then(|s| self.directive(s))
219        {
220            Some(Directive::For(header)) => {
221                self.out
222                    .push_str(&format!("{}for {header} {{ ", self.opts.sigil));
223                self.items(&items[1..])?;
224                self.out.push_str(" }");
225            }
226            Some(Directive::If(cond)) => {
227                let split = items[1..].iter().position(|v| {
228                    v.as_str()
229                        .is_some_and(|s| matches!(self.directive(s), Some(Directive::Else)))
230                });
231                let (then, otherwise) = match split {
232                    Some(i) => (&items[1..1 + i], Some(&items[2 + i..])),
233                    None => (&items[1..], None),
234                };
235                self.out
236                    .push_str(&format!("{}if {cond} {{ ", self.opts.sigil));
237                self.items(then)?;
238                self.out.push_str(" }");
239                if let Some(o) = otherwise {
240                    self.out.push_str(&format!(" {}else {{ ", self.opts.sigil));
241                    self.items(o)?;
242                    self.out.push_str(" }");
243                }
244            }
245            Some(Directive::Else) => {
246                return Err(format!(
247                    "`{}else` without a leading `{}if`",
248                    self.opts.sigil, self.opts.sigil
249                ));
250            }
251            None => self.items(items)?,
252        }
253        self.out.push(']');
254        Ok(())
255    }
256
257    fn items(&mut self, items: &[Value]) -> Result<(), String> {
258        for (i, item) in items.iter().enumerate() {
259            if i > 0 {
260                self.out.push_str(", ");
261            }
262            self.value(item)?;
263        }
264        Ok(())
265    }
266
267    /// An object. A directive member beside static members carries the
268    /// comma that separates it inside its own body, so a projection that
269    /// renders zero tuples or a branch that renders nothing leaves no
270    /// dangling separator: a directive with a member before it puts the
271    /// comma first in every repetition, a leading directive with members
272    /// after it puts the comma last, and a lone directive uses the
273    /// encoding's separator.
274    fn object(&mut self, map: &Map<String, Value>) -> Result<(), String> {
275        self.out.push('{');
276        let entries: Vec<(&String, &Value)> = map.iter().collect();
277        let mut i = 0;
278        let mut emitted_static = false;
279        while i < entries.len() {
280            let (key, value) = entries[i];
281            let directive = self.directive(key);
282            let is_directive = directive.is_some();
283            let has_before = emitted_static;
284            // Any later member other than this directive's own `@else`.
285            let has_after = entries[i + 1..]
286                .iter()
287                .any(|(k, _)| !matches!(self.directive(k), Some(Directive::Else)));
288            let comma = match (is_directive, has_before, has_after) {
289                (false, _, _) => Comma::Between,
290                (true, true, _) => Comma::Leading,
291                (true, false, true) => Comma::Trailing,
292                (true, false, false) => Comma::Sep,
293            };
294            if !is_directive && emitted_static {
295                self.out.push_str(", ");
296            }
297            match directive {
298                Some(Directive::For(header)) => {
299                    // A member projection: the value's members, per tuple.
300                    self.out
301                        .push_str(&format!("{}for {header}", self.opts.sigil));
302                    if matches!(comma, Comma::Leading | Comma::Trailing) {
303                        self.out.push_str(" sep \"\"");
304                    }
305                    self.out.push_str(" { ");
306                    self.members(key, value, comma)?;
307                    self.out.push_str(" }");
308                }
309                Some(Directive::If(cond)) => {
310                    self.out
311                        .push_str(&format!("{}if {cond} {{ ", self.opts.sigil));
312                    self.members(key, value, comma)?;
313                    self.out.push_str(" }");
314                    if let Some((next_key, next_value)) = entries.get(i + 1)
315                        && matches!(self.directive(next_key), Some(Directive::Else))
316                    {
317                        self.out.push_str(&format!(" {}else {{ ", self.opts.sigil));
318                        self.members(next_key, next_value, comma)?;
319                        self.out.push_str(" }");
320                        i += 1;
321                    }
322                }
323                Some(Directive::Else) => {
324                    return Err(format!(
325                        "`{}else` key without a preceding `{}if` key",
326                        self.opts.sigil, self.opts.sigil
327                    ));
328                }
329                None => {
330                    self.string(key, true)?;
331                    self.out.push_str(": ");
332                    self.value(value)?;
333                    emitted_static = true;
334                }
335            }
336            i += 1;
337        }
338        self.out.push('}');
339        Ok(())
340    }
341
342    /// The members of a directive key's object value, as `"k": v` runs,
343    /// with the separator the position calls for.
344    fn members(&mut self, key: &str, value: &Value, comma: Comma) -> Result<(), String> {
345        let Some(obj) = value.as_object() else {
346            return Err(format!(
347                "the value under directive key `{key}` must be an object of members"
348            ));
349        };
350        if matches!(comma, Comma::Leading) {
351            self.out.push_str(", ");
352        }
353        for (i, (k, v)) in obj.iter().enumerate() {
354            if i > 0 {
355                self.out.push_str(", ");
356            }
357            self.string(k, true)?;
358            self.out.push_str(": ");
359            self.value(v)?;
360        }
361        if matches!(comma, Comma::Trailing) {
362            self.out.push_str(", ");
363        }
364        Ok(())
365    }
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371
372    fn text(json: &str) -> String {
373        let v: Value = serde_json::from_str(json).unwrap();
374        template_text_from_value(&v, &TileOptions::default()).unwrap()
375    }
376
377    #[test]
378    fn strings_classify_as_value_string_or_static() {
379        assert_eq!(
380            text(r#"{"ts": "${ts}", "name": "row-${row}", "s": "${ts: str}", "k": "plain"}"#),
381            r#"{"ts": ${ts}, "name": "row-${row}", "s": "${ts: str}", "k": "plain"}"#
382        );
383    }
384
385    #[test]
386    fn directive_arrays_and_objects_become_blocks() {
387        assert_eq!(
388            text(r#"["@for s in 0..4", {"n": "${s}"}]"#),
389            r#"[@for s in 0..4 { {"n": ${s}} }]"#
390        );
391        assert_eq!(
392            text(r#"["@if v", 1, "@else", 2]"#),
393            r#"[@if v { 1 } @else { 2 }]"#
394        );
395        assert_eq!(
396            text(r#"{"@for t in a,b": {"${t}": true}}"#),
397            r#"{@for t in a,b { "${t}": true }}"#
398        );
399    }
400
401    #[test]
402    fn scalars_and_escapes_pass_through() {
403        assert_eq!(
404            text(r#"{"a": null, "b": true, "c": 1.5, "d": "q\"uote ${x}"}"#),
405            r#"{"a": null, "b": true, "c": 1.5, "d": "q\"uote ${x}"}"#
406        );
407    }
408
409    #[test]
410    fn directive_in_value_position_is_an_error() {
411        let v: Value = serde_json::from_str(r#"{"a": "@for s in 0..4"}"#).unwrap();
412        let e = template_text_from_value(&v, &TileOptions::default()).unwrap_err();
413        assert!(e.contains("outside a directive position"), "{e}");
414    }
415}