1use serde_json::{Map, Value};
18
19use super::ast::{TileBodyKind, TileDef, TileOptions, TilePiece};
20use super::lexer::Span;
21use super::tile::parse_template;
22
23pub 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
36pub 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
59pub 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
72pub 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
94pub 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#[derive(Clone, Copy, PartialEq, Eq)]
113enum Comma {
114 Between,
116 Leading,
118 Trailing,
120 Sep,
122}
123
124enum 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 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 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 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 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 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 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 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}