carta-core 0.0.3

Shared conversion options, error types, and text/attribute helpers.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
//! Engine unit tests: every construct, pipe, and whitespace rule, pinned to the documented behavior.

use std::collections::BTreeMap;

use super::Value;
use super::node::Template;

fn no_partials(_: &str) -> Option<String> {
    None
}

/// Render `src` against `ctx` with no partials.
fn render(src: &str, ctx: &Value) -> String {
    Template::parse(src)
        .expect("template should parse")
        .render(ctx, &no_partials)
        .expect("template should render")
}

/// Render with an in-memory partial set, returning the render result so missing-partial errors can
/// be asserted.
fn try_render_with(
    src: &str,
    ctx: &Value,
    partials: &[(&str, &str)],
) -> Result<String, super::TemplateError> {
    let owned: Vec<(String, String)> = partials
        .iter()
        .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
        .collect();
    let resolve = |name: &str| {
        owned
            .iter()
            .find(|(k, _)| k == name)
            .map(|(_, v)| v.clone())
    };
    Template::parse(src)
        .expect("template should parse")
        .render(ctx, &resolve)
}

/// Render with an in-memory partial set, expecting success.
fn render_with(src: &str, ctx: &Value, partials: &[(&str, &str)]) -> String {
    try_render_with(src, ctx, partials).expect("template should render")
}

fn s(text: &str) -> Value {
    Value::Str(text.to_string())
}

fn map(entries: &[(&str, Value)]) -> Value {
    let mut m = BTreeMap::new();
    for (k, v) in entries {
        m.insert((*k).to_string(), v.clone());
    }
    Value::Map(m)
}

fn list(items: &[Value]) -> Value {
    Value::List(items.to_vec())
}

#[test]
fn literal_and_escaped_dollar() {
    assert_eq!(render("a $$ b", &map(&[])), "a $ b");
    assert_eq!(render("$$body$$", &map(&[])), "$body$");
}

#[test]
fn variable_present_and_absent() {
    let ctx = map(&[("x", s("hi"))]);
    assert_eq!(render("[$x$]", &ctx), "[hi]");
    assert_eq!(render("[$missing$]", &ctx), "[]");
}

#[test]
fn nested_field_walks_maps() {
    let ctx = map(&[("a", map(&[("b", s("deep"))]))]);
    assert_eq!(render("$a.b$", &ctx), "deep");
    assert_eq!(render("$a.missing$", &ctx), "");
    assert_eq!(render("$a.b.c$", &ctx), "");
}

#[test]
fn bool_renders_and_is_conditionally_falsy() {
    assert_eq!(render("$x$", &map(&[("x", Value::Bool(true))])), "true");
    assert_eq!(render("$x$", &map(&[("x", Value::Bool(false))])), "false");
    assert_eq!(
        render("$if(x)$Y$else$N$endif$", &map(&[("x", Value::Bool(false))])),
        "N"
    );
    assert_eq!(
        render("$if(x)$Y$else$N$endif$", &map(&[("x", Value::Bool(true))])),
        "Y"
    );
}

#[test]
fn truthiness_of_empty_values() {
    let cond = "$if(x)$Y$else$N$endif$";
    assert_eq!(render(cond, &map(&[])), "N"); // absent
    assert_eq!(render(cond, &map(&[("x", s(""))])), "N"); // empty string
    assert_eq!(render(cond, &map(&[("x", list(&[]))])), "N"); // empty list
    assert_eq!(render(cond, &map(&[("x", s("v"))])), "Y");
    assert_eq!(render(cond, &map(&[("x", list(&[s("v")]))])), "Y");
    // A map is present-and-true even when it has no entries.
    assert_eq!(render(cond, &map(&[("x", map(&[]))])), "Y");
    assert_eq!(render(cond, &map(&[("x", map(&[("k", s("v"))]))])), "Y");
    // A list is truthy only when some element is: all-empty members, or a single empty list, is falsy.
    assert_eq!(render(cond, &map(&[("x", list(&[s(""), s("")]))])), "N");
    assert_eq!(render(cond, &map(&[("x", list(&[list(&[])]))])), "N");
    assert_eq!(render(cond, &map(&[("x", list(&[s(""), s("v")]))])), "Y");
}

#[test]
fn map_stringifies_to_true() {
    // A present map has no textual form of its own; interpolating it reads as `true`.
    let ctx = map(&[("author", map(&[("name", s("Z"))]))]);
    assert_eq!(render("$author$", &ctx), "true");
}

#[test]
fn elseif_chain() {
    let t = "$if(a)$A$elseif(b)$B$elseif(c)$C$else$none$endif$";
    assert_eq!(render(t, &map(&[("b", Value::Bool(true))])), "B");
    assert_eq!(render(t, &map(&[("c", Value::Bool(true))])), "C");
    assert_eq!(render(t, &map(&[])), "none");
    assert_eq!(render(t, &map(&[("a", s("x"))])), "A");
}

#[test]
fn for_loop_and_separator() {
    let ctx = map(&[("xs", list(&[s("a"), s("b"), s("c")]))]);
    assert_eq!(render("$for(xs)$$it$$sep$, $endfor$", &ctx), "a, b, c");
    assert_eq!(render("$for(xs)$$xs$$endfor$", &ctx), "abc"); // bound name == current item
}

#[test]
fn direct_list_interpolation_has_no_separator() {
    let ctx = map(&[("xs", list(&[s("a"), s("b")]))]);
    assert_eq!(render("$xs$", &ctx), "ab");
}

#[test]
fn for_scalar_is_single_element() {
    let ctx = map(&[("x", s("solo"))]);
    assert_eq!(render("$for(x)$[$it$]$endfor$", &ctx), "[solo]");
}

#[test]
fn bare_it_outside_a_loop_reads_the_root() {
    // With no enclosing loop, `it` is an ordinary root variable rather than a binding error.
    let ctx = map(&[("it", s("rootval"))]);
    assert_eq!(render("[$it$]", &ctx), "[rootval]");
    // A loop still rebinds `it` to its element, shadowing the root for the loop body.
    let ctx = map(&[("it", map(&[("tags", list(&[s("one"), s("two")]))]))]);
    assert_eq!(render("$for(it.tags)$<$it$>$endfor$", &ctx), "<one><two>");
}

#[test]
fn for_over_list_of_maps() {
    let ctx = map(&[(
        "people",
        list(&[
            map(&[("name", s("Ann")), ("email", s("ann@x"))]),
            map(&[("name", s("Bob")), ("email", s("bob@y"))]),
        ]),
    )]);
    assert_eq!(
        render("$for(people)$$it.name$ <$it.email$> $endfor$", &ctx),
        "Ann <ann@x> Bob <bob@y> "
    );
}

#[test]
fn nested_for_rebinds_it_to_inner() {
    let ctx = map(&[(
        "groups",
        list(&[
            map(&[("name", s("g1")), ("items", list(&[s("a"), s("b")]))]),
            map(&[("name", s("g2")), ("items", list(&[s("c")]))]),
        ]),
    )]);
    let t = "$for(groups)$[$it.name$:$for(it.items)$$it$,$endfor$]$endfor$";
    assert_eq!(render(t, &ctx), "[g1:a,b,][g2:c,]");
}

#[test]
fn for_pairs_is_key_sorted() {
    let ctx = map(&[("m", map(&[("z", s("1")), ("a", s("2")), ("m", s("3"))]))]);
    assert_eq!(
        render("$for(m/pairs)$$it.key$=$it.value$ $endfor$", &ctx),
        "a=2 m=3 z=1 "
    );
}

#[test]
fn pipes_string_case() {
    let ctx = map(&[("x", s("Hello World"))]);
    assert_eq!(render("$x/uppercase$", &ctx), "HELLO WORLD");
    assert_eq!(render("$x/lowercase$", &ctx), "hello world");
    assert_eq!(render("$x/uppercase/reverse$", &ctx), "DLROW OLLEH");
}

#[test]
fn lowercase_is_codepoint_by_codepoint() {
    // A capital sigma always lowercases to a plain σ, never the word-final form ς a whole-string
    // mapping would choose at the end of a word.
    let ctx = map(&[("x", s("ΟΔΟΣ"))]);
    assert_eq!(render("$x/lowercase$", &ctx), "οδοσ");
}

#[test]
fn string_pipes_leave_a_bool_untouched() {
    // A bool is not textual: the case pipes pass it through, and its length is zero.
    let t = map(&[("x", Value::Bool(true))]);
    assert_eq!(render("$x/uppercase$", &t), "true");
    assert_eq!(render("$x/lowercase$", &t), "true");
    assert_eq!(render("[$x/length$]", &t), "[0]");
}

#[test]
fn pipe_chain_applies_to_an_absent_value() {
    // An absent variable is an empty value, so a trailing pipe still runs: `length` is 0, while the
    // text pipes produce nothing. A bare absent variable stays empty.
    let ctx = map(&[("present", s("x"))]);
    assert_eq!(render("[$gone/length$]", &ctx), "[0]");
    assert_eq!(render("[$gone/uppercase$]", &ctx), "[]");
    assert_eq!(render("[$gone/uppercase/length$]", &ctx), "[0]");
    assert_eq!(render("[$gone$]", &ctx), "[]");
}

#[test]
fn pipes_length_and_list_ops() {
    let ctx = map(&[("xs", list(&[s("a"), s("b"), s("c")])), ("w", s("hello"))]);
    assert_eq!(render("$xs/length$", &ctx), "3");
    assert_eq!(render("$w/length$", &ctx), "5");
    assert_eq!(render("$xs/first$", &ctx), "a");
    assert_eq!(render("$xs/last$", &ctx), "c");
    assert_eq!(render("$xs/rest$", &ctx), "bc");
    assert_eq!(render("$xs/allbutlast$", &ctx), "ab");
    assert_eq!(render("$w/reverse$", &ctx), "olleh");
}

#[test]
fn list_selection_pipes_pass_strings_through() {
    // `first`/`last`/`rest`/`allbutlast` act on lists only; a string is returned unchanged.
    let ctx = map(&[("w", s("hello"))]);
    assert_eq!(render("$w/first$", &ctx), "hello");
    assert_eq!(render("$w/last$", &ctx), "hello");
    assert_eq!(render("$w/rest$", &ctx), "hello");
    assert_eq!(render("$w/allbutlast$", &ctx), "hello");
    // An empty list selects to the empty string.
    assert_eq!(render("[$e/first$]", &map(&[("e", list(&[]))])), "[]");
}

#[test]
fn alpha_is_single_letter_cyclic() {
    let a = |n: &str| render("$n/alpha$", &map(&[("n", s(n))]));
    assert_eq!(a("1"), "a");
    assert_eq!(a("3"), "c");
    assert_eq!(a("25"), "y");
    assert_eq!(a("26"), "`"); // cycle boundary lands just before 'a'
    assert_eq!(a("27"), "a");
    assert_eq!(a("0"), "`");
    assert_eq!(a("-1"), "-1"); // negative passes through
    assert_eq!(a("abc"), "abc"); // non-integer passes through
    assert_eq!(a(" 3 "), " 3 "); // surrounding whitespace disqualifies the integer
    assert_eq!(a("+3"), "+3"); // a leading plus disqualifies it
}

#[test]
fn roman_numbers_and_passthrough() {
    let r = |n: &str| render("$n/roman$", &map(&[("n", s(n))]));
    assert_eq!(r("1"), "i");
    assert_eq!(r("4"), "iv");
    assert_eq!(r("2024"), "mmxxiv");
    assert_eq!(r("3999"), "mmmcmxcix");
    assert_eq!(r("0"), ""); // zero is empty
    assert_eq!(r("4000"), "4000"); // above the defined range, passes through
    assert_eq!(r("999999999"), "999999999"); // a large value never drives unbounded work
    assert_eq!(r("-1"), "-1"); // negative passes through
    assert_eq!(r("abc"), "abc"); // non-integer passes through
    assert_eq!(r(" 4 "), " 4 "); // surrounding whitespace disqualifies the integer
    assert_eq!(r("+4"), "+4"); // a leading plus disqualifies it
}

#[test]
fn pipes_chomp_and_nowrap() {
    assert_eq!(render("$x/chomp$", &map(&[("x", s("line\n\n"))])), "line");
    assert_eq!(render("$x/nowrap$", &map(&[("x", s("a b"))])), "a b");
}

#[test]
fn pipes_block_padding() {
    let ctx = map(&[("x", s("Hello World"))]);
    assert_eq!(
        render(r#"$x/left 20 "[" "]"$"#, &ctx),
        "[Hello World         ]"
    );
    assert_eq!(
        render(r#"$x/right 20 "[" "]"$"#, &ctx),
        "[         Hello World]"
    );
    assert_eq!(render(r#"$x/center 13 "[" "]"$"#, &ctx), "[ Hello World ]");
    // No right border: trailing pad dropped.
    assert_eq!(render("[$x/left 20$]", &ctx), "[Hello World]");
}

#[test]
fn partial_plain_and_mapped() {
    let ctx = map(&[
        ("who", s("World")),
        ("names", list(&[s("x"), s("y"), s("z")])),
    ]);
    assert_eq!(
        render_with("$greet()$", &ctx, &[("greet", "Hi $who$")]),
        "Hi World"
    );
    assert_eq!(
        render_with("$names:item()[, ]$", &ctx, &[("item", "($it$)")]),
        "(x), (y), (z)"
    );
}

#[test]
fn standalone_partial_absorbs_its_following_newline() {
    let p = &[("p", "PARTIAL\n")];
    // A partial alone on its line (only blanks before it, a newline right after) absorbs that
    // newline, so the next line follows directly; leading indentation is preserved.
    assert_eq!(render_with("A\n$p()$\nB\n", &map(&[]), p), "A\nPARTIALB\n");
    assert_eq!(
        render_with("A\n  $p()$\nB\n", &map(&[]), p),
        "A\n  PARTIALB\n"
    );
    // Trailing spaces before the newline, or any non-blank before the call, leave the newline.
    assert_eq!(
        render_with("A\n$p()$  \nB\n", &map(&[]), p),
        "A\nPARTIAL  \nB\n"
    );
    assert_eq!(render_with("XX$p()$\nB\n", &map(&[]), p), "XXPARTIAL\nB\n");
    // An inline partial only drops its own trailing newline.
    assert_eq!(render_with("A $p()$ B\n", &map(&[]), p), "A PARTIAL B\n");
}

#[test]
fn missing_partial_is_an_error() {
    // A referenced partial that cannot be resolved aborts the render rather than vanishing, so a
    // typo in a partial name never silently drops content.
    let result = try_render_with("[$gone()$]", &map(&[]), &[]);
    assert!(result.is_err(), "expected an error, got {result:?}");
}

// --- Whitespace and standalone-line rules ---

#[test]
fn comment_to_end_of_line() {
    // Column-zero comment swallows its newline; an indented one keeps the space and newline.
    assert_eq!(render("X\n$-- c\nY\n", &map(&[])), "X\nY\n");
    assert_eq!(render("X\n $-- c\nY\n", &map(&[])), "X\n \nY\n");
    assert_eq!(render("X $-- c\nY\n", &map(&[])), "X \nY\n");
}

#[test]
fn standalone_control_directive_consumes_its_line() {
    let t = "START\n$if(a)$\nLINE-A\n$endif$\nEND\n";
    assert_eq!(
        render(t, &map(&[("a", Value::Bool(true))])),
        "START\nLINE-A\nEND\n"
    );
    assert_eq!(render(t, &map(&[])), "START\nEND\n");
}

#[test]
fn indented_control_directive_keeps_its_indentation() {
    // The directive's trailing newline is swallowed, but the indentation before it survives and
    // prefixes onto the following content, so the body shifts rightward.
    let ctx = map(&[("items", list(&[s("a")]))]);
    assert_eq!(
        render("X\n  $if(items)$\nIN\n  $endif$\nY\n", &ctx),
        "X\n  IN\n  Y\n"
    );
    // Two indented directives back to back: their indents concatenate onto the next line.
    assert_eq!(
        render("X\n  $if(items)$\n  $endif$\nY\n", &ctx),
        "X\n    Y\n"
    );
}

#[test]
fn non_standalone_control_keeps_line() {
    // An opening directive sharing its line with content makes the whole construct inline: no
    // directive of it strips its line, so the `$endif$`'s own newline survives as a blank line.
    assert_eq!(
        render("$if(a)$X\n$endif$\n", &map(&[("a", Value::Bool(true))])),
        "X\n\n"
    );
    let ctx = map(&[("xs", list(&[s("a"), s("b"), s("c")]))]);
    let t = "$for(xs)$\n$if(it)$[$it$]$endif$\n$endfor$\n";
    assert_eq!(render(t, &ctx), "[a]\n[b]\n[c]\n");
}

#[test]
fn block_ness_is_decided_by_the_opening_directive() {
    let ctx = map(&[("xs", list(&[s("a"), s("b")]))]);
    // Opening shares its line (inline construct): the lone `$endfor$` keeps its newline, so a blank
    // line trails the loop even though the closing sits by itself.
    assert_eq!(
        render("$for(xs)$- $it$\n$endfor$\nZ\n", &ctx),
        "- a\n- b\n\nZ\n"
    );
    // Opening ends its line (block construct): both the opening and the lone closing are stripped,
    // leaving no blank — a leading literal before the opening is preserved.
    assert_eq!(
        render("P$for(xs)$\n- $it$\n$endfor$\nZ\n", &ctx),
        "P- a\n- b\nZ\n"
    );
}

#[test]
fn for_body_literal_indent_repeats() {
    let ctx = map(&[("xs", list(&[s("a"), s("b"), s("c")]))]);
    let t = "$for(xs)$\n  - $it$\n$endfor$\n";
    assert_eq!(render(t, &ctx), "  - a\n  - b\n  - c\n");
}

#[test]
fn space_prefixed_variable_indents_continuations() {
    let ctx = map(&[("body", s("<p>p1</p>\n<p>p2</p>"))]);
    assert_eq!(
        render(
            "$if(a)$\n  $body$\n$endif$\n",
            &map_merge(&ctx, "a", Value::Bool(true))
        ),
        "  <p>p1</p>\n  <p>p2</p>\n"
    );
}

#[test]
fn non_space_prefix_suppresses_indent() {
    let ctx = map(&[("body", s("<p>p1</p>\n<p>p2</p>"))]);
    // Non-whitespace prefix: continuation lines are not indented.
    assert_eq!(render("XY: $body$\n", &ctx), "XY: <p>p1</p>\n<p>p2</p>\n");
    // Tab prefix: not all spaces, so no indent.
    assert_eq!(render("\t$body$\n", &ctx), "\t<p>p1</p>\n<p>p2</p>\n");
}

#[test]
fn output_is_verbatim_no_added_newline() {
    assert_eq!(render("no newline", &map(&[])), "no newline");
    assert_eq!(render("one newline\n", &map(&[])), "one newline\n");
}

#[test]
fn value_trailing_newline_absorbs_the_line_break_after_it() {
    // A block value ending in a blank line, alone on its line: the line's own break folds into the
    // value's trailing blank line rather than opening a second empty line.
    let ctx = map(&[("body", s("first\n\nsecond\n\n"))]);
    assert_eq!(
        render("before\n$body$\nafter\n", &ctx),
        "before\nfirst\n\nsecond\n\nafter\n"
    );
    // A value with no trailing newline leaves the following line break intact.
    let inline = map(&[("title", s("Hello"))]);
    assert_eq!(render("a\n$title$\nb\n", &inline), "a\nHello\nb\n");
    // The value's trailing blank line absorbs the whole following newline run, however long.
    assert_eq!(
        render("before\n$body$\n\n\nafter\n", &ctx),
        "before\nfirst\n\nsecond\n\nafter\n"
    );
}

/// Build a copy of `base` (a map) with one extra key.
fn map_merge(base: &Value, key: &str, value: Value) -> Value {
    let Value::Map(m) = base else {
        return base.clone();
    };
    let mut m = m.clone();
    m.insert(key.to_string(), value);
    Value::Map(m)
}

#[test]
fn parse_errors_are_reported() {
    assert!(Template::parse("$if(a)$ no end").is_err());
    assert!(Template::parse("$for(a)$ no end").is_err());
    assert!(Template::parse("$endif$").is_err());
    assert!(Template::parse("$x/boguspipe$").is_err());
    assert!(Template::parse("$unterminated").is_err());
}