anodizer-core 0.15.3

Core configuration, context, and template engine for the anodizer release tool
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
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
//! JSON boundary adapter between tera 2.0's engine types and anodizer's
//! `serde_json`-shaped filter/function bodies.
//!
//! The custom filters/functions implement a GoReleaser-parity contract whose
//! semantics are JSON-shaped (string/number/bool/null/array/object), so their
//! bodies stay on `serde_json::Value` permanently. tera 2.0 replaced its 1.x
//! `serde_json::Value` re-export with an opaque engine-native `tera::Value`,
//! so this module owns the one place where the two worlds convert:
//!
//! - [`to_json`] / [`from_json`] — value conversion via the serde round-trip
//!   (`tera::Value` implements `Serialize`; construction goes through
//!   `tera::Value::try_from_serializable`).
//! - [`kwargs_to_map`] — tera 2.0 `Kwargs` → the `HashMap<String, Value>`
//!   argument shape the 1.x-era bodies consume.
//! - [`JsonRegisterExt`] — `register_json_filter` / `register_json_function`
//!   wrappers that adapt a JSON-shaped closure to tera 2.0's
//!   `(value, Kwargs, &State)` / `(Kwargs, &State)` signatures.
//!
//! Rendered-output byte-identity across the 1.x → 2.0 engine swap is the
//! whole point: conversions must be lossless for every JSON shape (including
//! `u64 > i64::MAX` and whole-valued floats, which must stay floats so
//! `{{ 2.0 }}` keeps rendering `2.0`, not `2`).

use std::collections::HashMap;

use tera::{Kwargs, State, TeraResult};

use crate::template_preprocess::string_lit;

/// Convert an engine value to its JSON equivalent via serde.
pub(super) fn to_json(v: &tera::Value) -> TeraResult<serde_json::Value> {
    serde_json::to_value(v).map_err(tera::Error::message)
}

/// Convert a JSON value to its engine equivalent via serde.
pub(super) fn from_json(v: &serde_json::Value) -> TeraResult<tera::Value> {
    tera::Value::try_from_serializable(v)
}

/// Convert tera 2.0 keyword arguments to the `HashMap<String, Value>` shape
/// the filter/function bodies consume (the 1.x function-argument shape).
pub(super) fn kwargs_to_map(kwargs: &Kwargs) -> TeraResult<HashMap<String, serde_json::Value>> {
    kwargs.deserialize()
}

/// Registration wrappers adapting JSON-shaped closures to tera 2.0's
/// filter/function traits. Every custom registration goes through these so
/// there is exactly one engine-boundary conversion site.
pub(super) trait JsonRegisterExt {
    /// Register a filter whose body is `Fn(&Value, &args) -> Result<Value>`
    /// over `serde_json::Value` (the 1.x filter signature).
    fn register_json_filter<F>(&mut self, name: &'static str, f: F)
    where
        F: Fn(
                &serde_json::Value,
                &HashMap<String, serde_json::Value>,
            ) -> Result<serde_json::Value, tera::Error>
            + Send
            + Sync
            + 'static;

    /// Register a function whose body is `Fn(&args) -> Result<Value>` over
    /// `serde_json::Value` (the 1.x function signature).
    fn register_json_function<F>(&mut self, name: &'static str, f: F)
    where
        F: Fn(&HashMap<String, serde_json::Value>) -> Result<serde_json::Value, tera::Error>
            + Send
            + Sync
            + 'static;
}

impl JsonRegisterExt for tera::Tera {
    fn register_json_filter<F>(&mut self, name: &'static str, f: F)
    where
        F: Fn(
                &serde_json::Value,
                &HashMap<String, serde_json::Value>,
            ) -> Result<serde_json::Value, tera::Error>
            + Send
            + Sync
            + 'static,
    {
        self.register_filter(
            name,
            // Owned `Value` arg: `ArgFromValue` for `tera::Value` is identity,
            // sidestepping the higher-ranked borrow bound a `&Value` closure
            // would have to satisfy.
            move |value: tera::Value, kwargs: Kwargs, _: &State| -> TeraResult<tera::Value> {
                let json_value = to_json(&value)?;
                let args = kwargs_to_map(&kwargs)?;
                from_json(&f(&json_value, &args)?)
            },
        );
    }

    fn register_json_function<F>(&mut self, name: &'static str, f: F)
    where
        F: Fn(&HashMap<String, serde_json::Value>) -> Result<serde_json::Value, tera::Error>
            + Send
            + Sync
            + 'static,
    {
        self.register_function(
            name,
            move |kwargs: Kwargs, _: &State| -> TeraResult<tera::Value> {
                let args = kwargs_to_map(&kwargs)?;
                from_json(&f(&args)?)
            },
        );
    }
}

/// Restore tera 1.x raw string-literal semantics by doubling every backslash
/// inside string literals within `{{ … }}` / `{% … %}` blocks.
///
/// tera 1.x string literals were fully raw: the pest grammar closed a string
/// at the FIRST occurrence of its opening delimiter character, full stop —
/// 1.x had no escape syntax at all, so a backslash never protected the
/// character after it. `pattern="(\w+)"` reached the filter body as `(\w+)`;
/// `'it\'s'` closed right after `it\` (the `'` immediately following the
/// backslash IS the close, exactly like any other `'`). tera 2.0 processes
/// escape sequences instead and hard-errors on unknown ones (only
/// `\" \' \/ \\ \n \t \r` are accepted), so the same raw-authored template
/// fails to parse under 2.0 as-is. The scanner below reproduces 1.x's rule
/// verbatim — no escape awareness, because 1.x had none — and simply
/// doubles every backslash it passes through.
///
/// Doubling makes the fix work: a run of N backslashes always becomes 2N —
/// always EVEN, independent of whether N itself was even or odd. tera 2.0's
/// escape-aware lexer resolves an even run of backslashes as N/2 paired
/// escapes with nothing left over, so the character right after the run is
/// never itself consumed as an escape target — it lands on the delimiter as
/// an ordinary, unescaped byte, closing the string at EXACTLY the position
/// 1.x's first-occurrence rule chose on the original (undoubled) text. The
/// two engines agree on the boundary by construction, for every backslash
/// run length, without the scanner needing any concept of escaping.
///
/// All three 1.x literal delimiters (`"`, `'`, `` ` ``) are handled; text
/// outside blocks and comment blocks is left untouched. The boundary
/// decisions (what opens a literal, where it closes) are not re-implemented
/// here: the scan calls `template_preprocess::string_lit`'s
/// `is_string_delim` / `raw_string_end`, the same single source of truth the
/// preprocessor passes use — so the shim and the passes cannot disagree
/// about which bytes are string contents. Only the backslash doubling
/// itself lives here.
///
/// 2.0 also added inline map literals (`{'a': 1}`), whose own `{`/`}` pair
/// can sit inside a `{{ … }}` expression. A depth-blind scan sees that map
/// literal's closing `}` immediately followed by another `}` (the block's
/// own terminator or a further-nested map) and mistakes it for `}}`,
/// closing the expression early — any backslash literal later in that same
/// block then never gets doubled. Tracking `{`/`}` depth while outside a
/// string literal (string contents don't count — a literal `{` inside a
/// string is just text) makes `}}` only end the block at depth 0.
///
/// Contract: for a template that was VALID under 1.x's raw grammar, the
/// shim yields byte-identical rendered output (see the parity-sweep test
/// below, across N=0..5 backslashes before a close, including the Windows
/// path shape `'C:\Users\'`). For a template that was already INVALID under
/// 1.x — `'it\'s'` is the canonical case: a human author's JS/Python-style
/// intent to escape the embedded quote is something 1.x's raw grammar never
/// honored, since 1.x closed that same string at the `'` right after the
/// backslash too, leaving a dangling `s'` neither engine can make sense of —
/// the shim does not attempt to recover authorial intent. The transformed
/// text may cascade into an unterminated string that swallows the rest of
/// the template, but that must always surface as a LOUD tera parse/render
/// error, never a silent, wrong render.
pub(super) fn double_string_literal_backslashes(template: &str) -> std::borrow::Cow<'_, str> {
    if !template.contains('\\') {
        return std::borrow::Cow::Borrowed(template);
    }

    #[derive(PartialEq)]
    enum Region {
        Text,
        Comment,
        Block,
    }

    let mut out = String::with_capacity(template.len() + 8);
    let mut region = Region::Text;
    // Depth of `{`/`}` map-literal nesting inside the current block, outside
    // any string literal. `}}` only closes the block at depth 0.
    let mut brace_depth: u32 = 0;
    let mut chars = template.char_indices().peekable();

    while let Some((i, c)) = chars.next() {
        match region {
            Region::Text => {
                if c == '{' {
                    match chars.peek() {
                        Some(&(_, '{')) | Some(&(_, '%')) => {
                            // Consume the full 2-char open delimiter now.
                            // Leaving the second char (`{` or `%`) for the
                            // next iteration would feed it to the Block
                            // arm's generic brace-depth counter below —
                            // for `{{` that's a bare `{` indistinguishable
                            // from a user map-literal open, desyncing
                            // `brace_depth` to 1 when the block is actually
                            // at depth 0.
                            out.push(c);
                            if let Some((_, second)) = chars.next() {
                                out.push(second);
                            }
                            region = Region::Block;
                            brace_depth = 0;
                            continue;
                        }
                        Some(&(_, '#')) => region = Region::Comment,
                        _ => {}
                    }
                }
                out.push(c);
            }
            Region::Comment => {
                if c == '#' && matches!(chars.peek(), Some(&(_, '}'))) {
                    region = Region::Text;
                }
                out.push(c);
            }
            Region::Block => {
                match c {
                    // String literal: the boundary comes from string_lit's
                    // shared raw rule (first next occurrence of the same
                    // delimiter, no escape awareness; unterminated runs to
                    // EOF). Delimiters are ASCII, so `raw_string_end` always
                    // lands on a char boundary. The literal's span — a
                    // possible `}}`/`%}` inside it included — is emitted
                    // here wholesale, so block-close detection below never
                    // sees string contents.
                    _ if c.is_ascii() && string_lit::is_string_delim(c as u8) => {
                        let end = string_lit::raw_string_end(template.as_bytes(), i);
                        for ch in template[i..end].chars() {
                            if ch == '\\' {
                                // The one rewrite this whole scan exists
                                // for. Every backslash byte doubles
                                // independently (not per-pair), so a run of
                                // N backslashes always becomes 2N — always
                                // even, which is also what keeps tera 2.0's
                                // own lexer from ever treating the char
                                // after the run as an escape target.
                                out.push('\\');
                            }
                            out.push(ch);
                        }
                        while chars.next_if(|&(j, _)| j < end).is_some() {}
                        continue;
                    }
                    '{' => brace_depth += 1,
                    // A nested map literal's close: consumed by the
                    // depth counter, never a block-close candidate.
                    '}' if brace_depth > 0 => brace_depth -= 1,
                    // Block close: `}}` or `%}` outside a string literal
                    // and outside any open map literal. Consume the
                    // full 2-char close delimiter now (same reasoning
                    // as the open side) so the region genuinely returns
                    // to Text — leaving the second `}`/`%` behind used
                    // to strand the scan in Block for the rest of the
                    // template, corrupting any later quoted text.
                    '}' | '%' if matches!(chars.peek(), Some(&(_, '}'))) => {
                        out.push(c);
                        if let Some((_, second)) = chars.next() {
                            out.push(second);
                        }
                        region = Region::Text;
                        continue;
                    }
                    _ => {}
                }
                out.push(c);
            }
        }
    }

    std::borrow::Cow::Owned(out)
}

/// Coerce a filter's piped value to `$ty`, returning the tera-1.x error text
/// on mismatch (tera 2.0 dropped the `try_get_value!` macro this replaces;
/// the message format is preserved so error output stays stable).
macro_rules! try_get_value {
    ($filter_name:expr, $var_name:expr, $ty:ty, $val:expr) => {{
        match serde_json::from_value::<$ty>($val.clone()) {
            Ok(s) => s,
            Err(_) => {
                if $var_name == "value" {
                    return Err(tera::Error::message(format!(
                        "Filter `{}` was called on an incorrect value: got `{}` but expected a {}",
                        $filter_name,
                        $val,
                        stringify!($ty)
                    )));
                } else {
                    return Err(tera::Error::message(format!(
                        "Filter `{}` received an incorrect type for arg `{}`: got `{}` but expected a {}",
                        $filter_name,
                        $var_name,
                        $val,
                        stringify!($ty)
                    )));
                }
            }
        }
    }};
}
pub(super) use try_get_value;

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    /// JSON → engine → JSON must be the identity for every JSON shape.
    fn assert_round_trip(v: serde_json::Value) {
        let engine = from_json(&v).expect("from_json");
        let back = to_json(&engine).expect("to_json");
        assert_eq!(back, v, "round trip changed the value");
    }

    #[test]
    fn round_trip_i64() {
        assert_round_trip(json!(-42));
        assert_round_trip(json!(i64::MIN));
        assert_round_trip(json!(i64::MAX));
    }

    #[test]
    fn round_trip_u64_beyond_i64() {
        assert_round_trip(json!(u64::MAX));
        assert_round_trip(json!(i64::MAX as u64 + 1));
    }

    #[test]
    fn round_trip_f64_stays_float() {
        assert_round_trip(json!(1.5));
        assert_round_trip(json!(-0.25));
        let engine = from_json(&json!(1.5)).unwrap();
        assert!(engine.as_f64().is_some(), "float must stay a float");
    }

    #[test]
    fn round_trip_whole_f64_stays_float_and_renders_with_point() {
        // 2.0 must NOT collapse to the integer 2: templates rendering the
        // value must keep emitting "2.0" exactly as the 1.x engine did.
        let v = json!(2.0);
        assert_round_trip(v.clone());
        let engine = from_json(&v).unwrap();
        assert!(engine.as_f64().is_some(), "whole float must stay a float");
        assert_eq!(engine.to_string(), "2.0");
    }

    #[test]
    fn round_trip_bool_and_null() {
        assert_round_trip(json!(true));
        assert_round_trip(json!(false));
        assert_round_trip(json!(null));
    }

    #[test]
    fn round_trip_unicode_string() {
        assert_round_trip(json!("héllo wörld — 日本語 🚀"));
        assert_round_trip(json!(""));
    }

    #[test]
    fn round_trip_nested_structures() {
        assert_round_trip(json!({
            "arr": [1, "two", 3.5, null, true],
            "obj": { "inner": { "deep": ["a", { "b": 2 }] } },
        }));
    }

    #[test]
    fn round_trip_empty_containers() {
        assert_round_trip(json!([]));
        assert_round_trip(json!({}));
    }

    #[test]
    fn kwargs_to_map_extracts_all_pairs() {
        let kwargs = Kwargs::from([
            ("s", tera::Value::from("hello")),
            ("n", tera::Value::from(7)),
            ("flag", tera::Value::from(true)),
        ]);
        let map = kwargs_to_map(&kwargs).unwrap();
        assert_eq!(map.len(), 3);
        assert_eq!(map["s"], json!("hello"));
        assert_eq!(map["n"], json!(7));
        assert_eq!(map["flag"], json!(true));
    }

    #[test]
    fn kwargs_to_map_empty() {
        let map = kwargs_to_map(&Kwargs::default()).unwrap();
        assert!(map.is_empty());
    }

    #[test]
    fn kwargs_to_map_u64_beyond_i64_max() {
        // Mirrors round_trip_u64_beyond_i64: a kwarg value out of i64 range
        // must survive the Kwargs -> HashMap<String, Value> conversion too.
        let kwargs = Kwargs::from([("n", tera::Value::from(u64::MAX))]);
        let map = kwargs_to_map(&kwargs).unwrap();
        assert_eq!(map["n"], json!(u64::MAX));
    }

    #[test]
    fn kwargs_to_map_whole_f64_stays_float() {
        // Mirrors round_trip_whole_f64_stays_float_and_renders_with_point:
        // a whole-valued float kwarg must deserialize back as a float, not
        // collapse into an integer.
        let kwargs = Kwargs::from([("f", tera::Value::from(2.0_f64))]);
        let map = kwargs_to_map(&kwargs).unwrap();
        assert_eq!(map["f"], json!(2.0));
        assert!(
            map["f"].is_f64(),
            "whole float kwarg must stay a float, got: {:?}",
            map["f"]
        );
    }

    #[test]
    fn json_filter_and_function_render_end_to_end() {
        let mut tera = tera::Tera::default();
        tera.register_json_filter("echo_upper", |value, _| {
            Ok(serde_json::Value::String(
                value.as_str().unwrap_or_default().to_uppercase(),
            ))
        });
        tera.register_json_function("concat2", |args| {
            let a = args.get("a").and_then(|v| v.as_str()).unwrap_or_default();
            let b = args.get("b").and_then(|v| v.as_str()).unwrap_or_default();
            Ok(serde_json::Value::String(format!("{a}{b}")))
        });
        tera.add_raw_template("t", "{{ concat2(a=\"x\", b=name) | echo_upper }}")
            .unwrap();
        let mut ctx = tera::Context::new();
        ctx.insert("name", "yz");
        assert_eq!(tera.render("t", &ctx).unwrap(), "XYZ");
    }

    #[test]
    fn backslash_shim_doubles_inside_expression_literals() {
        let tpl = r#"{{ reReplaceAll(pattern="(\w+) (\w+)", replacement="$2 $1") }}"#;
        let out = double_string_literal_backslashes(tpl);
        assert_eq!(
            out,
            r#"{{ reReplaceAll(pattern="(\\w+) (\\w+)", replacement="$2 $1") }}"#
        );
    }

    #[test]
    fn backslash_shim_preserves_1x_raw_semantics_for_known_escapes() {
        // 1.x kept `\n` / `\\` / `\t` as literal two-char bytes (backslash
        // plus the letter) — it never interpreted them as a newline/tab.
        // Doubling makes 2.0's unescaping reproduce exactly those raw bytes.
        assert_eq!(
            double_string_literal_backslashes(r#"{% set x = "a\nb\\c\td" %}"#),
            r#"{% set x = "a\\nb\\\\c\\td" %}"#
        );
        let mut tera = tera::Tera::default();
        let tpl = double_string_literal_backslashes(r#"{{ "a\nb\\c\td" }}"#);
        tera.add_raw_template("t", tpl.as_ref()).unwrap();
        let out = tera.render("t", &tera::Context::new()).unwrap();
        assert_eq!(out, r"a\nb\\c\td", "\\t must stay two raw chars, not a tab");
    }

    #[test]
    fn backslash_shim_handles_single_quote_and_backtick_literals() {
        assert_eq!(
            double_string_literal_backslashes(r#"{{ f(a='\d', b=`\s`) }}"#),
            r#"{{ f(a='\\d', b=`\\s`) }}"#
        );
    }

    #[test]
    fn backslash_shim_leaves_text_outside_blocks_untouched() {
        let tpl = r#"C:\path\to\thing {{ Version }} more \raw text"#;
        assert_eq!(double_string_literal_backslashes(tpl), tpl);
    }

    #[test]
    fn backslash_shim_leaves_comments_untouched() {
        let tpl = r#"{# a "\w" comment #}{{ Version }}"#;
        assert_eq!(double_string_literal_backslashes(tpl), tpl);
    }

    #[test]
    fn backslash_shim_ignores_block_close_inside_literal() {
        let tpl = r#"{{ f(a="x}}\w") }} tail \q"#;
        assert_eq!(
            double_string_literal_backslashes(tpl),
            r#"{{ f(a="x}}\\w") }} tail \q"#
        );
    }

    #[test]
    fn backslash_shim_nested_map_literal_does_not_close_block_early() {
        // A nested map's inner-then-outer closing braces used to look
        // identical to the block's own `}}` to a depth-blind scan, ending
        // the block right there — the `p='\w+'` kwarg that follows would
        // never get its backslash doubled.
        let tpl = r#"{{ f(m={'a': {'b': 1}}, p='\w+') }}"#;
        assert_eq!(
            double_string_literal_backslashes(tpl),
            r#"{{ f(m={'a': {'b': 1}}, p='\\w+') }}"#
        );
    }

    #[test]
    fn backslash_shim_brace_in_string_literal_does_not_count_as_map_depth() {
        // A `{`/`}` pair that's just text inside a string literal (not an
        // actual map literal) must not perturb the depth counter used to
        // find the real block close.
        let tpl = r#"{{ f(s="{not a map}", p='\w+') }}"#;
        assert_eq!(
            double_string_literal_backslashes(tpl),
            r#"{{ f(s="{not a map}", p='\\w+') }}"#
        );
    }

    #[test]
    fn tera_rejects_adjacent_closing_map_braces_independent_of_shim() {
        // tera 2.0's lexer detects its own `}}` variable-end delimiter with
        // a context-free two-byte lookahead — its `State` enum tracks only
        // Template/Variable/Tag, nothing for map-literal nesting depth. A
        // nested map literal's directly-abutting inner+outer closing braces
        // are therefore indistinguishable from the block's own terminator
        // to tera ITSELF: this fails to parse via bare tera with no
        // anodizer preprocessing involved at all. anodizer's shim (see
        // double_string_literal_backslashes) tracks brace depth so ITS OWN
        // text scan stays honest about where the block really ends, but
        // that cannot make tera accept syntax tera's own lexer rejects —
        // template authors must separate abutting closes with a space
        // (`{'a': {'b': 1} }`, not `{'a': {'b': 1}}`).
        let mut tera = tera::Tera::default();
        let err = tera
            .add_raw_template("t", r#"{{ {"a": {"b": 1}} }}"#)
            .unwrap_err()
            .to_string();
        assert!(err.contains("expected"), "got: {err}");
    }

    #[test]
    fn backslash_shim_spaced_nested_map_literal_rendered_end_to_end() {
        // The tera-valid spelling of a nested map (a space before EVERY
        // closing brace that would otherwise abut another one) parses and
        // renders correctly, and a backslash literal later in the same
        // block still gets doubled.
        let mut tera = tera::Tera::default();
        tera.register_json_function("probe", |args| {
            let has_map = args.get("m").is_some_and(|v| v.is_object());
            let p = args.get("p").and_then(|v| v.as_str()).unwrap_or_default();
            Ok(serde_json::Value::String(format!("{has_map}:{p}")))
        });
        let tpl = double_string_literal_backslashes(r#"{{ probe(m={'a': {'b': 1} }, p='\w+') }}"#);
        tera.add_raw_template("t", tpl.as_ref()).unwrap();
        let out = tera.render("t", &tera::Context::new()).unwrap();
        assert_eq!(out, r"true:\w+");
    }

    #[test]
    fn backslash_shim_handles_multiline_blocks() {
        // The shim is a char scanner, not a dot-based regex: a block spanning
        // newlines must still get its string-literal backslashes doubled.
        let tpl = "{{\n  f(p='\\w+')\n}} tail \\x";
        assert_eq!(
            double_string_literal_backslashes(tpl),
            "{{\n  f(p='\\\\w+')\n}} tail \\x"
        );
    }

    #[test]
    fn backslash_shim_no_backslash_is_borrowed_passthrough() {
        let tpl = "{{ Version }} plain";
        assert!(matches!(
            double_string_literal_backslashes(tpl),
            std::borrow::Cow::Borrowed(_)
        ));
    }

    #[test]
    fn backslash_shim_rendered_end_to_end_matches_1x_bytes() {
        let mut tera = tera::Tera::default();
        let tpl = double_string_literal_backslashes(r#"{{ "a\nb" }}|{{ "regex: (\w+)" }}"#);
        tera.add_raw_template("t", tpl.as_ref()).unwrap();
        let out = tera.render("t", &tera::Context::new()).unwrap();
        // Literal backslash-n (two bytes), exactly as 1.x rendered it.
        assert_eq!(out, r"a\nb|regex: (\w+)");
    }

    #[test]
    fn backslash_shim_text_after_block_with_quoted_backslash_not_doubled() {
        // Regression for the brace-depth desync: the second `{` of `{{` was
        // being fed to the generic depth counter (starting it at 1 instead
        // of 0), so the closing `}}`'s first `}` merely decremented depth
        // instead of ending the block — the scan never returned to Text.
        // Trailing plain-text quotes were then misread as a Block string
        // literal and their backslashes got doubled.
        let tpl = "{{ Version }} say \"hi\\there\"";
        assert_eq!(double_string_literal_backslashes(tpl), tpl);
    }

    #[test]
    fn backslash_shim_text_with_quotes_between_two_blocks_untouched() {
        let tpl = r#"{{ A }} says "quo\test" and 'sing\lequote' {{ B }}"#;
        assert_eq!(double_string_literal_backslashes(tpl), tpl);
    }

    #[test]
    fn backslash_shim_map_literal_in_first_block_then_string_in_second() {
        let tpl = r#"{{ f(m={'a': 1}) }} between {{ g(p="\d+") }}"#;
        assert_eq!(
            double_string_literal_backslashes(tpl),
            r#"{{ f(m={'a': 1}) }} between {{ g(p="\\d+") }}"#
        );
    }

    #[test]
    fn json_filter_error_propagates() {
        let mut tera = tera::Tera::default();
        tera.register_json_filter("always_fail", |_, _| {
            Err(tera::Error::message("boom from body"))
        });
        tera.add_raw_template("t", "{{ 1 | always_fail }}").unwrap();
        let err = tera
            .render("t", &tera::Context::new())
            .unwrap_err()
            .to_string();
        assert!(err.contains("boom from body"), "got: {err}");
    }

    #[test]
    fn backslash_shim_windows_path_literal_round_trips_through_real_tera() {
        // The blind spot round 4's escape-parity design got wrong: a
        // Windows path literal has an odd trailing backslash right before
        // the close, which 1.x's raw grammar always treats as a plain,
        // unescaped close (it has no escape concept at all). The doubled
        // shim output must both match byte-for-byte AND render, through a
        // real tera::Tera, to the exact original raw path.
        let tpl = r"{{ 'C:\Users\' }} tail \x";
        let out = double_string_literal_backslashes(tpl);
        assert_eq!(out, r"{{ 'C:\\Users\\' }} tail \x");
        assert!(out.ends_with(r" tail \x"));

        let mut tera = tera::Tera::default();
        tera.add_raw_template("t", out.as_ref()).unwrap();
        let rendered = tera.render("t", &tera::Context::new()).unwrap();
        assert_eq!(rendered, r"C:\Users\ tail \x");
    }

    #[test]
    fn backslash_shim_parity_sweep_backslash_counts_0_to_5_round_trip_through_real_tera() {
        // 1.x's first-occurrence close does not care whether the backslash
        // run before the delimiter is odd or even — it always closes right
        // there. Doubling always produces an EVEN run (2N), so tera 2.0
        // always resolves it back to exactly N literal backslashes,
        // agreeing with 1.x's raw value for every N.
        for n in 0..=5 {
            let raw = "\\".repeat(n);
            let tpl = format!("{{{{ 'end{raw}' }}}} t \\x");
            let out = double_string_literal_backslashes(&tpl);

            let doubled = "\\".repeat(n * 2);
            let expected = format!("{{{{ 'end{doubled}' }}}} t \\x");
            assert_eq!(out, expected, "n={n}");
            assert!(out.ends_with(" t \\x"), "n={n}, got: {out}");

            let mut tera = tera::Tera::default();
            tera.add_raw_template("t", out.as_ref()).unwrap();
            let rendered = tera.render("t", &tera::Context::new()).unwrap();
            let expected_rendered = format!("end{raw} t \\x");
            assert_eq!(rendered, expected_rendered, "n={n}");
        }
    }

    #[test]
    fn backslash_shim_1x_invalid_literal_syntax_produces_loud_tera_error() {
        // Templates whose authors relied on JS/Python-style backslash
        // escaping of the delimiter itself were never valid under 1.x's
        // raw, non-escape-aware grammar either — 1.x closed these same
        // strings at the escaped quote, leaving a dangling remainder
        // neither engine can parse. The shim must not silently mis-render
        // these; it must fail loudly, exactly as 1.x would have.
        let invalid_templates = [
            r#"{{ 'it\'s' }} plain \raw {{ g(p="\d+") }}"#,
            r#"{{ 'abc\' more content ' }} tail \x"#,
        ];
        for tpl in invalid_templates {
            let shimmed = double_string_literal_backslashes(tpl);
            let mut tera = tera::Tera::default();
            let add_result = tera.add_raw_template("t", shimmed.as_ref());
            let is_loud_error = match add_result {
                Err(_) => true,
                Ok(()) => tera.render("t", &tera::Context::new()).is_err(),
            };
            assert!(
                is_loud_error,
                "expected a loud tera error for 1.x-invalid template {tpl:?}, shimmed to {shimmed:?}"
            );
        }
    }

    #[test]
    fn backslash_shim_double_backslash_immediately_before_close_leaves_trailing_text_untouched() {
        // Two backslashes right before the real closing quote: each
        // backslash still doubles independently (an even run stays even
        // after doubling either way), and the delimiter right after them
        // is the true, unescaped close.
        let tpl = r"{{ 'end\\' }} t \x";
        let out = double_string_literal_backslashes(tpl);
        assert_eq!(out, r"{{ 'end\\\\' }} t \x");
        assert!(out.ends_with(r" t \x"));
    }

    #[test]
    fn backslash_shim_unterminated_literal_ending_in_backslash_at_eof_does_not_panic() {
        let tpl = r#"before text {{ f(a="unterminated\"#;
        let out = double_string_literal_backslashes(tpl);
        assert!(
            out.starts_with("before text {{"),
            "text before the block must stay untouched, got: {out}"
        );
    }

    /// Reference span-finder for the property check below: a second,
    /// independently-written boundary scan that only needs to know "is this
    /// byte inside a `{{ … }}` / `{% … %}` block or not" — it doesn't double
    /// any backslash, so it can't hide a bug that the production function
    /// might share with a copy-pasted version of itself. Text (and comment)
    /// bytes are copied verbatim by the shim no matter what happens to
    /// strings inside blocks, so this walk finds the same boundaries as the
    /// real scan for every non-pathological template (the two 1.x-invalid
    /// inputs covered by their own dedicated error test are excluded here —
    /// an unterminated stray string swallowing the rest of the template
    /// isn't a "text span" in any meaningful sense).
    fn non_block_spans(template: &str) -> Vec<&str> {
        #[derive(PartialEq)]
        enum Region {
            Text,
            Block,
        }

        let mut spans = Vec::new();
        let mut region = Region::Text;
        let mut span_start = 0usize;
        let mut string_delim: Option<char> = None;
        let mut brace_depth: u32 = 0;
        let mut chars = template.char_indices().peekable();

        while let Some((i, c)) = chars.next() {
            match region {
                Region::Text => {
                    if c == '{' && matches!(chars.peek(), Some((_, '{')) | Some((_, '%'))) {
                        spans.push(&template[span_start..i]);
                        chars.next();
                        region = Region::Block;
                        brace_depth = 0;
                        string_delim = None;
                    }
                }
                Region::Block => match string_delim {
                    Some(delim) => {
                        if c == delim {
                            string_delim = None;
                        }
                    }
                    None => match c {
                        '"' | '\'' | '`' => string_delim = Some(c),
                        '{' => brace_depth += 1,
                        '}' if brace_depth > 0 => brace_depth -= 1,
                        '}' | '%' if matches!(chars.peek(), Some((_, '}'))) => {
                            let (j, _) = chars.next().expect("peeked Some above");
                            region = Region::Text;
                            span_start = j + 1;
                        }
                        _ => {}
                    },
                },
            }
        }
        if region == Region::Text {
            spans.push(&template[span_start..]);
        }
        spans
    }

    #[test]
    fn backslash_shim_text_outside_blocks_always_matches_across_adversarial_inputs() {
        // Every non-block byte must survive the shim byte-for-byte, across
        // the module's adversarial templates (the two 1.x-invalid inputs
        // get their own dedicated error-assertion test instead — see
        // `non_block_spans`'s doc comment for why).
        let templates = [
            r#"{{ reReplaceAll(pattern="(\w+) (\w+)", replacement="$2 $1") }}"#,
            r#"{% set x = "a\nb\\c" %}"#,
            r#"{{ f(a='\d', b=`\s`) }}"#,
            r"C:\path\to\thing {{ Version }} more \raw text",
            r#"{# a "\w" comment #}{{ Version }}"#,
            r#"{{ f(a="x}}\w") }} tail \q"#,
            r#"{{ f(m={'a': {'b': 1}}, p='\w+') }}"#,
            r#"{{ f(s="{not a map}", p='\w+') }}"#,
            "{{ Version }} plain",
            "{{ Version }} say \"hi\\there\"",
            r#"{{ A }} says "quo\test" and 'sing\lequote' {{ B }}"#,
            r#"{{ f(m={'a': 1}) }} between {{ g(p="\d+") }}"#,
            r"{{ 'end\\' }} t \x",
            r#"before text {{ f(a="unterminated\"#,
            // Odd-backslash Windows-path shapes — round 4's blind spot: a
            // trailing backslash sitting immediately adjacent to a block
            // delimiter (inside a string literal, or in plain text right
            // at the `{{`/`}}` boundary itself, no separating space).
            r"{{ 'C:\Users\' }} tail \x",
            r"C:\Users\{{ Version }}\tail",
            r"C:\Users\Name\{{ f(p='D:\Data\') }}\Trailing\",
        ];

        for tpl in templates {
            let shimmed = double_string_literal_backslashes(tpl);
            assert_eq!(
                non_block_spans(tpl),
                non_block_spans(shimmed.as_ref()),
                "text/comment spans diverged for template: {tpl:?}"
            );
        }
    }

    #[test]
    fn backslash_shim_and_preprocessor_scanner_agree_on_string_boundaries() {
        // Cross-pin: the expected output is computed from string_lit's scan
        // ALONE — a backslash doubles iff that scan classifies it as inside
        // a string literal. The shim only doubles in-string backslashes, so
        // if either side ever regrows its own boundary rule (escape-aware,
        // two-delimiter, …), the doubling pattern diverges from the
        // string_lit prediction and this fails, instead of the two scanners
        // silently drifting apart.
        use crate::template_preprocess::string_lit::{is_string_delim, raw_string_end};

        let corpus = [
            // mixed delimiters + close right after a backslash + code backslash
            r#""a\" 'b\' \code"#,
            // 1.x-invalid "escaped quote" intent; reopened string swallows the tail
            r"'it\'s' ~ \x",
            // backtick literal containing a quote and a trailing backslash;
            // quote literal containing a backtick
            "`tick\"quote\\` ~ \\ ~ 'q`t'",
            // adjacent literals, each ending in backslash-then-close
            r"'a\''b\'",
            // unterminated literal: everything to EOF is string contents
            r#""never \closed"#,
            // backslash run before a close, then an unterminated reopen
            r"'run\\\' tail' \\",
            // adjacent empty literals around a code-region backslash
            r"'' \gap ''",
        ];
        for body in corpus {
            let template = format!("{{% {body} %}}");
            let bytes = template.as_bytes();

            // Classify every byte via the preprocessor's scanner, starting
            // just past the `{% ` opener (the shim only scans inside blocks).
            let mut in_string = vec![false; bytes.len()];
            let mut i = 3;
            while i < bytes.len() {
                if is_string_delim(bytes[i]) {
                    let end = raw_string_end(bytes, i);
                    for flag in &mut in_string[i..end] {
                        *flag = true;
                    }
                    i = end;
                } else {
                    i += 1;
                }
            }

            let mut expected = String::new();
            for (i, ch) in template.char_indices() {
                if ch == '\\' && in_string[i] {
                    expected.push('\\');
                }
                expected.push(ch);
            }

            assert_eq!(
                double_string_literal_backslashes(&template).as_ref(),
                expected,
                "shim/preprocessor boundary disagreement for body: {body:?}"
            );
        }
    }
}