Skip to main content

harn_vm/stdlib/
template.rs

1//! Prompt-template engine for `.harn.prompt` assets and the `render` /
2//! `render_prompt` builtins.
3//!
4//! # Surface
5//!
6//! ```text
7//! {{ name }}                                 interpolation
8//! {{ user.name }} / {{ items[0] }}           nested path access
9//! {{ name | upper | default: "anon" }}       filter pipeline
10//! {{ if expr }}..{{ elif expr }}..{{ else }}..{{ end }}
11//! {{ for x in xs }}..{{ else }}..{{ end }}   else = empty-iterable fallback
12//! {{ for k, v in dict }}..{{ end }}
13//! {{ include "partial.harn.prompt" }}
14//! {{ include "partial.harn.prompt" with { x: name } }}
15//! {{# comment — stripped at parse time #}}
16//! {{ raw }}..literal {{braces}}..{{ endraw }}
17//! {{- x -}}                                  whitespace-trim markers
18//! ```
19//!
20//! Back-compat: bare `{{ident}}` resolves silently to the empty fallthrough
21//! (writes back the literal text on miss) — preserving the pre-v2 contract.
22//! All new constructs raise `TemplateError` on parse or evaluation failure.
23
24use std::cell::RefCell;
25use std::collections::BTreeMap;
26use std::path::{Path, PathBuf};
27use std::rc::Rc;
28
29use crate::value::{values_equal, VmError, VmValue};
30
31// Thread-local registry of recent prompt renders keyed by `prompt_id`.
32// Populated by `render_with_provenance` so the DAP adapter can serve
33// `burin/promptProvenance` and `burin/promptConsumers` reverse queries
34// without forcing the pipeline author to pass the spans dict back up
35// through the bridge. Capped at 64 renders (FIFO) to bound memory.
36thread_local! {
37    static PROMPT_REGISTRY: RefCell<Vec<RegisteredPrompt>> = const { RefCell::new(Vec::new()) };
38    // prompt_id -> [event_index...] where the prompt was consumed by
39    // an LLM call. Populated by emission sites once they thread the
40    // id alongside the rendered text; read by burin/promptConsumers
41    // to power the template gutter's jump-to-next-render action
42    // (#106). A per-session reset is handled by reset_prompt_registry.
43    static PROMPT_RENDER_INDICES: RefCell<BTreeMap<String, Vec<u64>>> =
44        const { RefCell::new(BTreeMap::new()) };
45    // Monotonic render ordinal driven by the prompt_mark_rendered
46    // builtin (#106). A fresh thread-local counter since the IDE
47    // correlates ordinals to event_indices at render time.
48    static PROMPT_RENDER_ORDINAL: RefCell<u64> = const { RefCell::new(0) };
49}
50
51const PROMPT_REGISTRY_CAP: usize = 64;
52
53#[derive(Debug, Clone)]
54pub struct RegisteredPrompt {
55    pub prompt_id: String,
56    pub template_uri: String,
57    pub rendered: String,
58    pub spans: Vec<PromptSourceSpan>,
59}
60
61/// Record a provenance map in the thread-local registry and return the
62/// assigned `prompt_id`. Newest entries push to the back; when the cap
63/// is reached the oldest entry is dropped so the registry never grows
64/// unboundedly over long sessions.
65pub(crate) fn register_prompt(
66    template_uri: String,
67    rendered: String,
68    spans: Vec<PromptSourceSpan>,
69) -> String {
70    let prompt_id = format!("prompt-{}", next_prompt_serial());
71    PROMPT_REGISTRY.with(|reg| {
72        let mut reg = reg.borrow_mut();
73        if reg.len() >= PROMPT_REGISTRY_CAP {
74            reg.remove(0);
75        }
76        reg.push(RegisteredPrompt {
77            prompt_id: prompt_id.clone(),
78            template_uri,
79            rendered,
80            spans,
81        });
82    });
83    prompt_id
84}
85
86thread_local! {
87    static PROMPT_SERIAL: RefCell<u64> = const { RefCell::new(0) };
88}
89
90fn next_prompt_serial() -> u64 {
91    PROMPT_SERIAL.with(|s| {
92        let mut s = s.borrow_mut();
93        *s += 1;
94        *s
95    })
96}
97
98/// Resolve an output byte offset to its originating template span.
99/// Returns the innermost matching `Expr` / `LegacyBareInterp` span when
100/// one exists, falling back to broader structural spans (If / For /
101/// Include) so a click anywhere in a rendered loop iteration still
102/// navigates somewhere useful.
103pub fn lookup_prompt_span(
104    prompt_id: &str,
105    output_offset: usize,
106) -> Option<(String, PromptSourceSpan)> {
107    PROMPT_REGISTRY.with(|reg| {
108        let reg = reg.borrow();
109        let entry = reg.iter().find(|p| p.prompt_id == prompt_id)?;
110        let best = entry
111            .spans
112            .iter()
113            .filter(|s| {
114                output_offset >= s.output_start
115                    && output_offset < s.output_end.max(s.output_start + 1)
116            })
117            .min_by_key(|s| {
118                let width = s.output_end.saturating_sub(s.output_start);
119                let kind_weight = match s.kind {
120                    PromptSpanKind::Expr => 0,
121                    PromptSpanKind::LegacyBareInterp => 1,
122                    PromptSpanKind::Text => 2,
123                    PromptSpanKind::Include => 3,
124                    PromptSpanKind::ForIteration => 4,
125                    PromptSpanKind::If => 5,
126                };
127                (kind_weight, width)
128            })?
129            .clone();
130        Some((entry.template_uri.clone(), best))
131    })
132}
133
134/// Return every span across every registered prompt that overlaps a
135/// template range. Powers the inverse "which rendered ranges consumed
136/// this template region?" navigation.
137pub fn lookup_prompt_consumers(
138    template_uri: &str,
139    template_line_start: usize,
140    template_line_end: usize,
141) -> Vec<(String, PromptSourceSpan)> {
142    PROMPT_REGISTRY.with(|reg| {
143        let reg = reg.borrow();
144        reg.iter()
145            .filter(|p| p.template_uri == template_uri)
146            .flat_map(|p| {
147                let prompt_id = p.prompt_id.clone();
148                p.spans
149                    .iter()
150                    .filter(move |s| {
151                        let line = s.template_line;
152                        line > 0 && line >= template_line_start && line <= template_line_end
153                    })
154                    .cloned()
155                    .map(move |s| (prompt_id.clone(), s))
156            })
157            .collect()
158    })
159}
160
161/// Record a render event index against a prompt_id (#106). The
162/// scrubber's jump-to-render action walks this map to move the
163/// playhead to the AgentEvent where the template was consumed.
164/// Stored as a Vec so re-renders of the same prompt id accumulate.
165pub fn record_prompt_render_index(prompt_id: &str, event_index: u64) {
166    PROMPT_RENDER_INDICES.with(|map| {
167        map.borrow_mut()
168            .entry(prompt_id.to_string())
169            .or_default()
170            .push(event_index);
171    });
172}
173
174/// Produce the next monotonic ordinal for a render-mark. Pipelines
175/// invoke the `prompt_mark_rendered` builtin which calls this to
176/// obtain a sequence number without having to know about per-session
177/// event counters. The IDE scrubber orders matching consumers by
178/// this ordinal when the emitted_at_ms timestamps collide.
179pub fn next_prompt_render_ordinal() -> u64 {
180    PROMPT_RENDER_ORDINAL.with(|c| {
181        let mut n = c.borrow_mut();
182        *n += 1;
183        *n
184    })
185}
186
187/// Fetch every event index where `prompt_id` was rendered. Called
188/// by the DAP adapter to populate the `eventIndices` list in the
189/// `burin/promptConsumers` response.
190pub fn prompt_render_indices(prompt_id: &str) -> Vec<u64> {
191    PROMPT_RENDER_INDICES.with(|map| map.borrow().get(prompt_id).cloned().unwrap_or_default())
192}
193
194/// Clear the registry. Wired into `reset_thread_local_state` so tests
195/// and serialized adapter sessions start from a clean slate.
196pub(crate) fn reset_prompt_registry() {
197    PROMPT_REGISTRY.with(|reg| reg.borrow_mut().clear());
198    PROMPT_SERIAL.with(|s| *s.borrow_mut() = 0);
199    PROMPT_RENDER_INDICES.with(|map| map.borrow_mut().clear());
200    PROMPT_RENDER_ORDINAL.with(|c| *c.borrow_mut() = 0);
201}
202
203/// Parse-only validation for lint/preflight. Returns a human-readable error
204/// message when the template body is syntactically invalid; `Ok(())` when the
205/// template would parse. Does not resolve `{{ include }}` targets — those are
206/// validated at render time with their own error reporting.
207pub fn validate_template_syntax(src: &str) -> Result<(), String> {
208    parse(src).map(|_| ()).map_err(|e| e.message())
209}
210
211/// Full-featured entrypoint that preserves errors. `base` is the directory
212/// used to resolve `{{ include "..." }}` paths; `source_path` (if known) is
213/// included in error messages.
214pub(crate) fn render_template_result(
215    template: &str,
216    bindings: Option<&BTreeMap<String, VmValue>>,
217    base: Option<&Path>,
218    source_path: Option<&Path>,
219) -> Result<String, TemplateError> {
220    let (rendered, _spans) =
221        render_template_with_provenance(template, bindings, base, source_path, false)?;
222    Ok(rendered)
223}
224
225/// One byte-range in a rendered prompt mapped back to its source
226/// template. Foundation for the prompt-provenance UX (burin-code #93):
227/// hover a chunk of the live prompt in the debugger and jump to the
228/// `.harn.prompt` line that produced it.
229///
230/// `output_start` / `output_end` are byte offsets into the rendered
231/// string. `template_line` / `template_col` are 1-based positions in
232/// the source template. `bound_value` carries a short preview of the
233/// expression's runtime value when it's a scalar; omitted for
234/// structural nodes (if/for/include) so callers don't log a giant
235/// dict display for a single `{% for %}`.
236#[derive(Debug, Clone)]
237pub struct PromptSourceSpan {
238    pub template_line: usize,
239    pub template_col: usize,
240    pub output_start: usize,
241    pub output_end: usize,
242    pub kind: PromptSpanKind,
243    pub bound_value: Option<String>,
244    /// When the span was rendered from inside an `include` (possibly
245    /// transitively), this points at the including call's span in the
246    /// parent template. Chained boxes let the IDE walk `A → B → C`
247    /// cross-template breadcrumbs when a deep render spans three
248    /// files. `None` for top-level spans.
249    pub parent_span: Option<Box<PromptSourceSpan>>,
250    /// Template URI for the file that authored this span. Top-level
251    /// spans carry the root render's template uri; included-child
252    /// spans carry the included file's uri so breadcrumb navigation
253    /// can open the right file when the user clicks through the
254    /// `parent_span` chain. Defaults to empty string for callers that
255    /// don't plumb it through.
256    pub template_uri: String,
257}
258
259#[derive(Debug, Clone, Copy, PartialEq, Eq)]
260pub enum PromptSpanKind {
261    /// Literal template text between directives.
262    Text,
263    /// `{{ expr }}` interpolation — the most common kind the IDE
264    /// wants to highlight on hover.
265    Expr,
266    /// Legacy bare `{{ident}}` fallthrough, surfaced separately so the
267    /// IDE can visually distinguish resolved from pass-through.
268    LegacyBareInterp,
269    /// Conditional branch text that actually rendered (the taken branch).
270    If,
271    /// One loop iteration's rendered body.
272    ForIteration,
273    /// Rendered partial/include expansion. Child spans still carry
274    /// their own template_uri via a future extension (#96).
275    Include,
276}
277
278/// Provenance-aware rendering. Returns the rendered string plus — when
279/// `collect_provenance` is true — one `PromptSourceSpan` per node so the
280/// IDE can link rendered byte ranges back to template source offsets.
281/// When `collect_provenance` is false, this degrades to the cheap
282/// non-tracked rendering path that the legacy callers use.
283pub(crate) fn render_template_with_provenance(
284    template: &str,
285    bindings: Option<&BTreeMap<String, VmValue>>,
286    base: Option<&Path>,
287    source_path: Option<&Path>,
288    collect_provenance: bool,
289) -> Result<(String, Vec<PromptSourceSpan>), TemplateError> {
290    let nodes = parse(template).map_err(|mut e| {
291        if let Some(p) = source_path {
292            e.path = Some(p.to_path_buf());
293        }
294        e
295    })?;
296    let mut out = String::with_capacity(template.len());
297    let mut scope = Scope::new(bindings);
298    let mut rc = RenderCtx {
299        base: base.map(Path::to_path_buf),
300        include_stack: Vec::new(),
301        current_path: source_path.map(Path::to_path_buf),
302        current_include_parent: None,
303    };
304    let mut spans = if collect_provenance {
305        Some(Vec::new())
306    } else {
307        None
308    };
309    render_nodes(&nodes, &mut scope, &mut rc, &mut out, spans.as_mut()).map_err(|mut e| {
310        if e.path.is_none() {
311            e.path = source_path.map(Path::to_path_buf);
312        }
313        e
314    })?;
315    Ok((out, spans.unwrap_or_default()))
316}
317
318// =========================================================================
319// Errors
320// =========================================================================
321
322#[derive(Debug, Clone)]
323pub(crate) struct TemplateError {
324    pub path: Option<PathBuf>,
325    pub line: usize,
326    pub col: usize,
327    pub kind: String,
328}
329
330impl TemplateError {
331    fn new(line: usize, col: usize, msg: impl Into<String>) -> Self {
332        Self {
333            path: None,
334            line,
335            col,
336            kind: msg.into(),
337        }
338    }
339
340    pub(crate) fn message(&self) -> String {
341        let p = self
342            .path
343            .as_ref()
344            .map(|p| format!("{} ", p.display()))
345            .unwrap_or_default();
346        format!("{}at {}:{}: {}", p, self.line, self.col, self.kind)
347    }
348}
349
350impl From<TemplateError> for VmError {
351    fn from(e: TemplateError) -> Self {
352        VmError::Thrown(VmValue::String(Rc::from(e.message())))
353    }
354}
355
356// =========================================================================
357// Tokenization (source → coarse token stream)
358// =========================================================================
359
360#[derive(Debug, Clone)]
361enum Token {
362    /// Literal text between directives.
363    Text {
364        content: String,
365        /// `{{-` on the following directive — trim trailing whitespace of this text.
366        trim_right: bool,
367        /// `-}}` on the preceding directive — trim leading whitespace of this text.
368        trim_left: bool,
369    },
370    /// Directive body (content between `{{` / `}}`, with `-` markers stripped).
371    Directive {
372        body: String,
373        line: usize,
374        col: usize,
375    },
376    /// Verbatim content of a `{{ raw }}..{{ endraw }}` block.
377    Raw(String),
378}
379
380fn tokenize(src: &str) -> Result<Vec<Token>, TemplateError> {
381    let bytes = src.as_bytes();
382    let mut tokens: Vec<Token> = Vec::new();
383    let mut cursor = 0;
384    let mut pending_trim_left = false;
385    let len = bytes.len();
386
387    while cursor < len {
388        // Look for the next `{{`.
389        let open = find_from(src, cursor, "{{");
390        let text_end = open.unwrap_or(len);
391        let raw_text = &src[cursor..text_end];
392
393        let this_trim_left = pending_trim_left;
394        pending_trim_left = false;
395
396        let mut this_trim_right = false;
397        if let Some(o) = open {
398            // Inspect the directive start for a `-` trim marker.
399            if o + 2 < len && bytes[o + 2] == b'-' {
400                this_trim_right = true;
401            }
402        }
403
404        if !raw_text.is_empty() || this_trim_left || this_trim_right {
405            tokens.push(Token::Text {
406                content: raw_text.to_string(),
407                trim_right: this_trim_right,
408                trim_left: this_trim_left,
409            });
410        }
411
412        let Some(open) = open else {
413            break;
414        };
415
416        // Position after `{{` (and optional `-`).
417        let body_start = open + 2 + if this_trim_right { 1 } else { 0 };
418
419        // Handle `{{# comment #}}`: comments are stripped outright.
420        if body_start < len && bytes[body_start] == b'#' {
421            // Scan for `#}}` — allowing an optional `-` trim marker before `}}`.
422            let after_hash = body_start + 1;
423            let Some(close_hash) = find_from(src, after_hash, "#}}") else {
424                let (line, col) = line_col(src, open);
425                return Err(TemplateError::new(line, col, "unterminated comment"));
426            };
427            cursor = close_hash + 3;
428            // Comments do not consume trim markers that would otherwise apply —
429            // but we already consumed the leading `-`. Keep it simple: comments
430            // don't trim surrounding text.
431            continue;
432        }
433
434        // Handle `{{ raw }}` specially: capture until `{{ endraw }}` verbatim.
435        let body_trim_start = skip_ws(src, body_start);
436        let raw_kw_end = body_trim_start + 3;
437        if raw_kw_end <= len && &src[body_trim_start..raw_kw_end.min(len)] == "raw" && {
438            // Ensure "raw" is its own token; next char must be whitespace or `}}` or `-}}`.
439            let after = raw_kw_end;
440            after >= len
441                || bytes[after] == b' '
442                || bytes[after] == b'\t'
443                || bytes[after] == b'\n'
444                || bytes[after] == b'\r'
445                || (after + 1 < len && &src[after..after + 2] == "}}")
446                || (after + 2 < len && &src[after..after + 3] == "-}}")
447        } {
448            // Find closing of this raw-open directive.
449            let Some(dir_close) = find_from(src, raw_kw_end, "}}") else {
450                let (line, col) = line_col(src, open);
451                return Err(TemplateError::new(line, col, "unterminated directive"));
452            };
453            // Check trailing `-` on `}}`.
454            let raw_body_start = dir_close + 2;
455            let trim_after_open = dir_close > 0 && bytes[dir_close - 1] == b'-';
456            let _ = trim_after_open; // Raw blocks don't honor whitespace trim.
457
458            // Scan for `{{ endraw }}` or `{{-endraw-}}`, whitespace-tolerant.
459            let (raw_end_open, raw_end_close) =
460                find_endraw(src, raw_body_start).ok_or_else(|| {
461                    let (line, col) = line_col(src, open);
462                    TemplateError::new(line, col, "unterminated `{{ raw }}` block")
463                })?;
464            let raw_content = src[raw_body_start..raw_end_open].to_string();
465            tokens.push(Token::Raw(raw_content));
466            cursor = raw_end_close;
467            continue;
468        }
469
470        // Standard directive: scan for `}}`, respecting quoted strings so a
471        // `}}` inside `"..."` doesn't prematurely terminate.
472        let (close_pos, trim_after) = find_directive_close(src, body_start).ok_or_else(|| {
473            let (line, col) = line_col(src, open);
474            TemplateError::new(line, col, "unterminated directive")
475        })?;
476        let body_end = if trim_after { close_pos - 1 } else { close_pos };
477        let body = src[body_start..body_end].trim().to_string();
478        let (line, col) = line_col(src, open);
479        tokens.push(Token::Directive { body, line, col });
480        cursor = close_pos + 2;
481        pending_trim_left = trim_after;
482    }
483
484    Ok(tokens)
485}
486
487fn find_from(s: &str, from: usize, pat: &str) -> Option<usize> {
488    s[from..].find(pat).map(|i| i + from)
489}
490
491fn skip_ws(s: &str, from: usize) -> usize {
492    let bytes = s.as_bytes();
493    let mut i = from;
494    while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
495        i += 1;
496    }
497    i
498}
499
500fn line_col(s: &str, offset: usize) -> (usize, usize) {
501    let mut line = 1usize;
502    let mut col = 1usize;
503    for (i, ch) in s.char_indices() {
504        if i >= offset {
505            break;
506        }
507        if ch == '\n' {
508            line += 1;
509            col = 1;
510        } else {
511            col += 1;
512        }
513    }
514    (line, col)
515}
516
517/// Scan forward from `start` looking for an unquoted `}}`. Returns
518/// `(offset_of_closing_braces, trim_marker_present)` where the trim marker
519/// is the `-` immediately before the `}}`.
520fn find_directive_close(s: &str, start: usize) -> Option<(usize, bool)> {
521    let bytes = s.as_bytes();
522    let mut i = start;
523    let mut in_str = false;
524    let mut str_quote = b'"';
525    while i + 1 < bytes.len() {
526        let b = bytes[i];
527        if in_str {
528            if b == b'\\' {
529                i += 2;
530                continue;
531            }
532            if b == str_quote {
533                in_str = false;
534            }
535            i += 1;
536            continue;
537        }
538        if b == b'"' || b == b'\'' {
539            in_str = true;
540            str_quote = b;
541            i += 1;
542            continue;
543        }
544        if b == b'}' && bytes[i + 1] == b'}' {
545            let trim = i > start && bytes[i - 1] == b'-';
546            return Some((i, trim));
547        }
548        i += 1;
549    }
550    None
551}
552
553/// Find the matching `{{ endraw }}` (whitespace- and trim-marker-tolerant),
554/// returning `(directive_open_offset, directive_close_offset_exclusive)`.
555fn find_endraw(s: &str, from: usize) -> Option<(usize, usize)> {
556    let mut cursor = from;
557    while let Some(open) = find_from(s, cursor, "{{") {
558        let after = open + 2;
559        let body_start = if s.as_bytes().get(after) == Some(&b'-') {
560            after + 1
561        } else {
562            after
563        };
564        let body_trim_start = skip_ws(s, body_start);
565        let close = find_directive_close(s, body_start)?;
566        let body_end = if close.1 { close.0 - 1 } else { close.0 };
567        let body = s[body_trim_start..body_end].trim();
568        if body == "endraw" {
569            return Some((open, close.0 + 2));
570        }
571        cursor = close.0 + 2;
572    }
573    None
574}
575
576// =========================================================================
577// AST
578// =========================================================================
579
580#[derive(Debug, Clone)]
581enum Node {
582    Text(String),
583    Expr {
584        expr: Expr,
585        line: usize,
586        col: usize,
587    },
588    If {
589        branches: Vec<(Expr, Vec<Node>)>,
590        else_branch: Option<Vec<Node>>,
591        line: usize,
592        col: usize,
593    },
594    For {
595        value_var: String,
596        key_var: Option<String>,
597        iter: Expr,
598        body: Vec<Node>,
599        empty: Option<Vec<Node>>,
600        line: usize,
601        col: usize,
602    },
603    Include {
604        path: Expr,
605        with: Option<Vec<(String, Expr)>>,
606        line: usize,
607        col: usize,
608    },
609    /// A legacy bare `{{ident}}` that should silently pass-through its source
610    /// text on miss — preserves pre-v2 semantics for back-compat.
611    LegacyBareInterp {
612        ident: String,
613    },
614}
615
616#[derive(Debug, Clone)]
617enum Expr {
618    Nil,
619    Bool(bool),
620    Int(i64),
621    Float(f64),
622    Str(String),
623    Path(Vec<PathSeg>),
624    Unary(UnOp, Box<Expr>),
625    Binary(BinOp, Box<Expr>, Box<Expr>),
626    Filter(Box<Expr>, String, Vec<Expr>),
627}
628
629#[derive(Debug, Clone)]
630enum PathSeg {
631    Field(String),
632    Index(i64),
633    Key(String),
634}
635
636#[derive(Debug, Clone, Copy)]
637enum UnOp {
638    Not,
639}
640
641#[derive(Debug, Clone, Copy, PartialEq)]
642enum BinOp {
643    Eq,
644    Neq,
645    Lt,
646    Le,
647    Gt,
648    Ge,
649    And,
650    Or,
651}
652
653// =========================================================================
654// Parser (token stream → AST)
655// =========================================================================
656
657fn parse(src: &str) -> Result<Vec<Node>, TemplateError> {
658    let tokens = tokenize(src)?;
659    let mut p = Parser {
660        tokens: &tokens,
661        pos: 0,
662    };
663    let nodes = p.parse_block(&[])?;
664    if p.pos < tokens.len() {
665        // Unclosed block — shouldn't reach here; parse_block returns on EOF.
666    }
667    Ok(nodes)
668}
669
670struct Parser<'a> {
671    tokens: &'a [Token],
672    pos: usize,
673}
674
675impl<'a> Parser<'a> {
676    fn peek(&self) -> Option<&'a Token> {
677        self.tokens.get(self.pos)
678    }
679
680    fn parse_block(&mut self, stops: &[&str]) -> Result<Vec<Node>, TemplateError> {
681        let mut out = Vec::new();
682        while let Some(tok) = self.peek() {
683            match tok {
684                Token::Text {
685                    content,
686                    trim_right,
687                    trim_left,
688                } => {
689                    let mut s = content.clone();
690                    if *trim_left {
691                        s = trim_leading_line(&s);
692                    }
693                    if *trim_right {
694                        s = trim_trailing_line(&s);
695                    }
696                    if !s.is_empty() {
697                        out.push(Node::Text(s));
698                    }
699                    self.pos += 1;
700                }
701                Token::Raw(content) => {
702                    if !content.is_empty() {
703                        out.push(Node::Text(content.clone()));
704                    }
705                    self.pos += 1;
706                }
707                Token::Directive { body, line, col } => {
708                    let (line, col) = (*line, *col);
709                    let body = body.clone();
710                    // Check for terminator tokens first — these are consumed by the caller.
711                    let first_word = first_word(&body);
712                    if stops.contains(&first_word) {
713                        return Ok(out);
714                    }
715                    self.pos += 1;
716
717                    if body == "end" {
718                        return Err(TemplateError::new(line, col, "unexpected `{{ end }}`"));
719                    }
720                    if body == "else" {
721                        return Err(TemplateError::new(line, col, "unexpected `{{ else }}`"));
722                    }
723                    if first_word == "elif" {
724                        return Err(TemplateError::new(line, col, "unexpected `{{ elif }}`"));
725                    }
726
727                    if first_word == "if" {
728                        let cond_src = body[2..].trim();
729                        let cond = parse_expr(cond_src, line, col)?;
730                        let node = self.parse_if(cond, line, col)?;
731                        out.push(node);
732                    } else if first_word == "for" {
733                        let node = self.parse_for(body[3..].trim(), line, col)?;
734                        out.push(node);
735                    } else if first_word == "include" {
736                        let node = parse_include(body[7..].trim(), line, col)?;
737                        out.push(node);
738                    } else if is_bare_ident(&body) {
739                        out.push(Node::LegacyBareInterp { ident: body });
740                    } else {
741                        let expr = parse_expr(&body, line, col)?;
742                        out.push(Node::Expr { expr, line, col });
743                    }
744                }
745            }
746        }
747        Ok(out)
748    }
749
750    fn parse_if(
751        &mut self,
752        first_cond: Expr,
753        line: usize,
754        col: usize,
755    ) -> Result<Node, TemplateError> {
756        let mut branches = Vec::new();
757        let mut else_branch = None;
758        let mut cur_cond = first_cond;
759        loop {
760            let body = self.parse_block(&["end", "else", "elif"])?;
761            branches.push((cur_cond, body));
762            // Consume the terminator directive.
763            let tok = self.peek().cloned();
764            match tok {
765                Some(Token::Directive {
766                    body: tbody,
767                    line: tline,
768                    col: tcol,
769                }) => {
770                    let fw = first_word(&tbody);
771                    self.pos += 1;
772                    match fw {
773                        "end" => break,
774                        "else" => {
775                            let eb = self.parse_block(&["end"])?;
776                            else_branch = Some(eb);
777                            // Consume `{{ end }}`.
778                            match self.peek() {
779                                Some(Token::Directive { body, .. }) if body == "end" => {
780                                    self.pos += 1;
781                                }
782                                _ => {
783                                    return Err(TemplateError::new(
784                                        tline,
785                                        tcol,
786                                        "`{{ else }}` missing matching `{{ end }}`",
787                                    ));
788                                }
789                            }
790                            break;
791                        }
792                        "elif" => {
793                            let cond = parse_expr(tbody[4..].trim(), tline, tcol)?;
794                            cur_cond = cond;
795                            continue;
796                        }
797                        _ => unreachable!(),
798                    }
799                }
800                _ => {
801                    return Err(TemplateError::new(
802                        line,
803                        col,
804                        "`{{ if }}` missing matching `{{ end }}`",
805                    ));
806                }
807            }
808        }
809        Ok(Node::If {
810            branches,
811            else_branch,
812            line,
813            col,
814        })
815    }
816
817    fn parse_for(&mut self, spec: &str, line: usize, col: usize) -> Result<Node, TemplateError> {
818        // Accept "x in expr" or "k, v in expr".
819        let (head, iter_src) = match split_once_keyword(spec, " in ") {
820            Some(p) => p,
821            None => return Err(TemplateError::new(line, col, "expected `in` in for-loop")),
822        };
823        let head = head.trim();
824        let iter_src = iter_src.trim();
825        let (value_var, key_var) = if let Some((a, b)) = head.split_once(',') {
826            let a = a.trim().to_string();
827            let b = b.trim().to_string();
828            if !is_ident(&a) || !is_ident(&b) {
829                return Err(TemplateError::new(line, col, "invalid for-loop variables"));
830            }
831            (b, Some(a)) // `k, v in dict` → value_var = v, key_var = k
832        } else {
833            if !is_ident(head) {
834                return Err(TemplateError::new(line, col, "invalid for-loop variable"));
835            }
836            (head.to_string(), None)
837        };
838        let iter = parse_expr(iter_src, line, col)?;
839        let body = self.parse_block(&["end", "else"])?;
840        let (empty, _) = match self.peek().cloned() {
841            Some(Token::Directive { body: tbody, .. }) => {
842                let fw = first_word(&tbody);
843                self.pos += 1;
844                if fw == "end" {
845                    (None, ())
846                } else if fw == "else" {
847                    let empty_body = self.parse_block(&["end"])?;
848                    match self.peek() {
849                        Some(Token::Directive { body, .. }) if body == "end" => {
850                            self.pos += 1;
851                        }
852                        _ => {
853                            return Err(TemplateError::new(
854                                line,
855                                col,
856                                "`{{ else }}` missing matching `{{ end }}`",
857                            ));
858                        }
859                    }
860                    (Some(empty_body), ())
861                } else {
862                    unreachable!()
863                }
864            }
865            _ => {
866                return Err(TemplateError::new(
867                    line,
868                    col,
869                    "`{{ for }}` missing matching `{{ end }}`",
870                ));
871            }
872        };
873        Ok(Node::For {
874            value_var,
875            key_var,
876            iter,
877            body,
878            empty,
879            line,
880            col,
881        })
882    }
883}
884
885fn parse_include(spec: &str, line: usize, col: usize) -> Result<Node, TemplateError> {
886    // "<path-expr>" or "<path-expr> with { k: v, ... }"
887    let (path_src, with_src) = match split_once_keyword(spec, " with ") {
888        Some((a, b)) => (a.trim(), Some(b.trim())),
889        None => (spec.trim(), None),
890    };
891    let path = parse_expr(path_src, line, col)?;
892    let with = if let Some(src) = with_src {
893        Some(parse_dict_literal(src, line, col)?)
894    } else {
895        None
896    };
897    Ok(Node::Include {
898        path,
899        with,
900        line,
901        col,
902    })
903}
904
905fn parse_dict_literal(
906    src: &str,
907    line: usize,
908    col: usize,
909) -> Result<Vec<(String, Expr)>, TemplateError> {
910    let s = src.trim();
911    if !s.starts_with('{') || !s.ends_with('}') {
912        return Err(TemplateError::new(
913            line,
914            col,
915            "expected `{ ... }` after `with`",
916        ));
917    }
918    let inner = &s[1..s.len() - 1];
919    let mut pairs = Vec::new();
920    for chunk in split_top_level(inner, ',') {
921        let chunk = chunk.trim();
922        if chunk.is_empty() {
923            continue;
924        }
925        let (k, v) = match split_once_top_level(chunk, ':') {
926            Some(p) => p,
927            None => {
928                return Err(TemplateError::new(
929                    line,
930                    col,
931                    "expected `key: value` in include bindings",
932                ));
933            }
934        };
935        let k = k.trim();
936        if !is_ident(k) {
937            return Err(TemplateError::new(line, col, "invalid include binding key"));
938        }
939        let v = parse_expr(v.trim(), line, col)?;
940        pairs.push((k.to_string(), v));
941    }
942    Ok(pairs)
943}
944
945fn first_word(s: &str) -> &str {
946    s.split(|c: char| c.is_whitespace()).next().unwrap_or("")
947}
948
949fn is_ident(s: &str) -> bool {
950    let mut chars = s.chars();
951    match chars.next() {
952        Some(c) if c.is_alphabetic() || c == '_' => {}
953        _ => return false,
954    }
955    chars.all(|c| c.is_alphanumeric() || c == '_')
956}
957
958fn is_bare_ident(s: &str) -> bool {
959    // A single identifier with no dot/bracket/filter — used for back-compat
960    // silent pass-through.
961    is_ident(s)
962}
963
964fn trim_leading_line(s: &str) -> String {
965    // Strip whitespace up to and including the first newline.
966    let mut i = 0;
967    let bytes = s.as_bytes();
968    while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
969        i += 1;
970    }
971    if i < bytes.len() && bytes[i] == b'\n' {
972        return s[i + 1..].to_string();
973    }
974    if i < bytes.len() && bytes[i] == b'\r' {
975        if i + 1 < bytes.len() && bytes[i + 1] == b'\n' {
976            return s[i + 2..].to_string();
977        }
978        return s[i + 1..].to_string();
979    }
980    // No trailing newline — strip leading spaces only.
981    s[i..].to_string()
982}
983
984fn trim_trailing_line(s: &str) -> String {
985    let bytes = s.as_bytes();
986    let mut i = bytes.len();
987    while i > 0 && (bytes[i - 1] == b' ' || bytes[i - 1] == b'\t') {
988        i -= 1;
989    }
990    if i > 0 && bytes[i - 1] == b'\n' {
991        // Remove this newline and the trailing whitespace.
992        let end = i - 1;
993        let end = if end > 0 && bytes[end - 1] == b'\r' {
994            end - 1
995        } else {
996            end
997        };
998        return s[..end].to_string();
999    }
1000    // No newline boundary — strip trailing spaces only.
1001    s[..i].to_string()
1002}
1003
1004// ---- Expression parsing -------------------------------------------------
1005
1006fn parse_expr(src: &str, line: usize, col: usize) -> Result<Expr, TemplateError> {
1007    let tokens = tokenize_expr(src, line, col)?;
1008    let mut p = ExprParser {
1009        toks: &tokens,
1010        pos: 0,
1011        line,
1012        col,
1013    };
1014    let e = p.parse_filter()?;
1015    if p.pos < tokens.len() {
1016        return Err(TemplateError::new(
1017            line,
1018            col,
1019            format!("unexpected token `{:?}` in expression", p.toks[p.pos]),
1020        ));
1021    }
1022    Ok(e)
1023}
1024
1025#[derive(Debug, Clone, PartialEq)]
1026enum EToken {
1027    Ident(String),
1028    Str(String),
1029    Int(i64),
1030    Float(f64),
1031    LParen,
1032    RParen,
1033    LBracket,
1034    RBracket,
1035    Dot,
1036    Comma,
1037    Colon,
1038    Pipe,
1039    Bang,
1040    EqEq,
1041    BangEq,
1042    Lt,
1043    Le,
1044    Gt,
1045    Ge,
1046    AndKw,
1047    OrKw,
1048    NotKw,
1049    True,
1050    False,
1051    Nil,
1052}
1053
1054fn tokenize_expr(src: &str, line: usize, col: usize) -> Result<Vec<EToken>, TemplateError> {
1055    let bytes = src.as_bytes();
1056    let mut toks = Vec::new();
1057    let mut i = 0;
1058    while i < bytes.len() {
1059        let b = bytes[i];
1060        if b.is_ascii_whitespace() {
1061            i += 1;
1062            continue;
1063        }
1064        match b {
1065            b'(' => {
1066                toks.push(EToken::LParen);
1067                i += 1;
1068            }
1069            b')' => {
1070                toks.push(EToken::RParen);
1071                i += 1;
1072            }
1073            b'[' => {
1074                toks.push(EToken::LBracket);
1075                i += 1;
1076            }
1077            b']' => {
1078                toks.push(EToken::RBracket);
1079                i += 1;
1080            }
1081            b'.' => {
1082                toks.push(EToken::Dot);
1083                i += 1;
1084            }
1085            b',' => {
1086                toks.push(EToken::Comma);
1087                i += 1;
1088            }
1089            b':' => {
1090                toks.push(EToken::Colon);
1091                i += 1;
1092            }
1093            b'|' => {
1094                if i + 1 < bytes.len() && bytes[i + 1] == b'|' {
1095                    toks.push(EToken::OrKw);
1096                    i += 2;
1097                } else {
1098                    toks.push(EToken::Pipe);
1099                    i += 1;
1100                }
1101            }
1102            b'&' => {
1103                if i + 1 < bytes.len() && bytes[i + 1] == b'&' {
1104                    toks.push(EToken::AndKw);
1105                    i += 2;
1106                } else {
1107                    return Err(TemplateError::new(line, col, "unexpected `&`"));
1108                }
1109            }
1110            b'!' => {
1111                if i + 1 < bytes.len() && bytes[i + 1] == b'=' {
1112                    toks.push(EToken::BangEq);
1113                    i += 2;
1114                } else {
1115                    toks.push(EToken::Bang);
1116                    i += 1;
1117                }
1118            }
1119            b'=' => {
1120                if i + 1 < bytes.len() && bytes[i + 1] == b'=' {
1121                    toks.push(EToken::EqEq);
1122                    i += 2;
1123                } else {
1124                    return Err(TemplateError::new(line, col, "unexpected `=` (use `==`)"));
1125                }
1126            }
1127            b'<' => {
1128                if i + 1 < bytes.len() && bytes[i + 1] == b'=' {
1129                    toks.push(EToken::Le);
1130                    i += 2;
1131                } else {
1132                    toks.push(EToken::Lt);
1133                    i += 1;
1134                }
1135            }
1136            b'>' => {
1137                if i + 1 < bytes.len() && bytes[i + 1] == b'=' {
1138                    toks.push(EToken::Ge);
1139                    i += 2;
1140                } else {
1141                    toks.push(EToken::Gt);
1142                    i += 1;
1143                }
1144            }
1145            b'"' | b'\'' => {
1146                let quote = b;
1147                let start = i + 1;
1148                let mut j = start;
1149                let mut out = String::new();
1150                while j < bytes.len() && bytes[j] != quote {
1151                    if bytes[j] == b'\\' && j + 1 < bytes.len() {
1152                        match bytes[j + 1] {
1153                            b'n' => out.push('\n'),
1154                            b't' => out.push('\t'),
1155                            b'r' => out.push('\r'),
1156                            b'\\' => out.push('\\'),
1157                            b'"' => out.push('"'),
1158                            b'\'' => out.push('\''),
1159                            c => out.push(c as char),
1160                        }
1161                        j += 2;
1162                        continue;
1163                    }
1164                    out.push(bytes[j] as char);
1165                    j += 1;
1166                }
1167                if j >= bytes.len() {
1168                    return Err(TemplateError::new(line, col, "unterminated string literal"));
1169                }
1170                toks.push(EToken::Str(out));
1171                i = j + 1;
1172            }
1173            b'0'..=b'9' | b'-'
1174                if b != b'-' || (i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit()) =>
1175            {
1176                let start = i;
1177                if bytes[i] == b'-' {
1178                    i += 1;
1179                }
1180                let mut is_float = false;
1181                while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b'.') {
1182                    if bytes[i] == b'.' {
1183                        // Only treat as float if followed by digit — otherwise it's a field access.
1184                        if i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit() {
1185                            is_float = true;
1186                            i += 1;
1187                            continue;
1188                        } else {
1189                            break;
1190                        }
1191                    }
1192                    i += 1;
1193                }
1194                let lex = &src[start..i];
1195                if is_float {
1196                    let v: f64 = lex.parse().map_err(|_| {
1197                        TemplateError::new(line, col, format!("invalid number `{lex}`"))
1198                    })?;
1199                    toks.push(EToken::Float(v));
1200                } else {
1201                    let v: i64 = lex.parse().map_err(|_| {
1202                        TemplateError::new(line, col, format!("invalid integer `{lex}`"))
1203                    })?;
1204                    toks.push(EToken::Int(v));
1205                }
1206            }
1207            c if c.is_ascii_alphabetic() || c == b'_' => {
1208                let start = i;
1209                while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
1210                    i += 1;
1211                }
1212                let word = &src[start..i];
1213                match word {
1214                    "true" => toks.push(EToken::True),
1215                    "false" => toks.push(EToken::False),
1216                    "nil" => toks.push(EToken::Nil),
1217                    "and" => toks.push(EToken::AndKw),
1218                    "or" => toks.push(EToken::OrKw),
1219                    "not" => toks.push(EToken::NotKw),
1220                    other => toks.push(EToken::Ident(other.to_string())),
1221                }
1222            }
1223            _ => {
1224                return Err(TemplateError::new(
1225                    line,
1226                    col,
1227                    format!("unexpected character `{}` in expression", b as char),
1228                ));
1229            }
1230        }
1231    }
1232    Ok(toks)
1233}
1234
1235struct ExprParser<'a> {
1236    toks: &'a [EToken],
1237    pos: usize,
1238    line: usize,
1239    col: usize,
1240}
1241
1242impl<'a> ExprParser<'a> {
1243    fn peek(&self) -> Option<&EToken> {
1244        self.toks.get(self.pos)
1245    }
1246    fn eat(&mut self, t: &EToken) -> bool {
1247        if self.peek() == Some(t) {
1248            self.pos += 1;
1249            true
1250        } else {
1251            false
1252        }
1253    }
1254    fn err(&self, m: impl Into<String>) -> TemplateError {
1255        TemplateError::new(self.line, self.col, m)
1256    }
1257
1258    fn parse_filter(&mut self) -> Result<Expr, TemplateError> {
1259        let mut left = self.parse_or()?;
1260        while self.eat(&EToken::Pipe) {
1261            let name = match self.peek() {
1262                Some(EToken::Ident(n)) => n.clone(),
1263                _ => return Err(self.err("expected filter name after `|`")),
1264            };
1265            self.pos += 1;
1266            let mut args = Vec::new();
1267            if self.eat(&EToken::Colon) {
1268                loop {
1269                    let a = self.parse_or()?;
1270                    args.push(a);
1271                    if !self.eat(&EToken::Comma) {
1272                        break;
1273                    }
1274                }
1275            }
1276            left = Expr::Filter(Box::new(left), name, args);
1277        }
1278        Ok(left)
1279    }
1280
1281    fn parse_or(&mut self) -> Result<Expr, TemplateError> {
1282        let mut left = self.parse_and()?;
1283        while self.eat(&EToken::OrKw) {
1284            let right = self.parse_and()?;
1285            left = Expr::Binary(BinOp::Or, Box::new(left), Box::new(right));
1286        }
1287        Ok(left)
1288    }
1289
1290    fn parse_and(&mut self) -> Result<Expr, TemplateError> {
1291        let mut left = self.parse_not()?;
1292        while self.eat(&EToken::AndKw) {
1293            let right = self.parse_not()?;
1294            left = Expr::Binary(BinOp::And, Box::new(left), Box::new(right));
1295        }
1296        Ok(left)
1297    }
1298
1299    fn parse_not(&mut self) -> Result<Expr, TemplateError> {
1300        if self.eat(&EToken::Bang) || self.eat(&EToken::NotKw) {
1301            let inner = self.parse_not()?;
1302            return Ok(Expr::Unary(UnOp::Not, Box::new(inner)));
1303        }
1304        self.parse_cmp()
1305    }
1306
1307    fn parse_cmp(&mut self) -> Result<Expr, TemplateError> {
1308        let left = self.parse_unary()?;
1309        let op = match self.peek() {
1310            Some(EToken::EqEq) => Some(BinOp::Eq),
1311            Some(EToken::BangEq) => Some(BinOp::Neq),
1312            Some(EToken::Lt) => Some(BinOp::Lt),
1313            Some(EToken::Le) => Some(BinOp::Le),
1314            Some(EToken::Gt) => Some(BinOp::Gt),
1315            Some(EToken::Ge) => Some(BinOp::Ge),
1316            _ => None,
1317        };
1318        if let Some(op) = op {
1319            self.pos += 1;
1320            let right = self.parse_unary()?;
1321            return Ok(Expr::Binary(op, Box::new(left), Box::new(right)));
1322        }
1323        Ok(left)
1324    }
1325
1326    fn parse_unary(&mut self) -> Result<Expr, TemplateError> {
1327        self.parse_primary()
1328    }
1329
1330    fn parse_primary(&mut self) -> Result<Expr, TemplateError> {
1331        let tok = self
1332            .peek()
1333            .cloned()
1334            .ok_or_else(|| self.err("expected expression"))?;
1335        self.pos += 1;
1336        let base = match tok {
1337            EToken::Nil => Expr::Nil,
1338            EToken::True => Expr::Bool(true),
1339            EToken::False => Expr::Bool(false),
1340            EToken::Int(n) => Expr::Int(n),
1341            EToken::Float(f) => Expr::Float(f),
1342            EToken::Str(s) => Expr::Str(s),
1343            EToken::LParen => {
1344                let e = self.parse_or()?;
1345                if !self.eat(&EToken::RParen) {
1346                    return Err(self.err("expected `)`"));
1347                }
1348                e
1349            }
1350            EToken::Ident(name) => self.parse_path(name)?,
1351            EToken::Bang | EToken::NotKw => {
1352                let inner = self.parse_primary()?;
1353                Expr::Unary(UnOp::Not, Box::new(inner))
1354            }
1355            other => return Err(self.err(format!("unexpected token `{:?}`", other))),
1356        };
1357        Ok(base)
1358    }
1359
1360    fn parse_path(&mut self, head: String) -> Result<Expr, TemplateError> {
1361        let mut segs = vec![PathSeg::Field(head)];
1362        loop {
1363            match self.peek() {
1364                Some(EToken::Dot) => {
1365                    self.pos += 1;
1366                    match self.peek().cloned() {
1367                        Some(EToken::Ident(n)) => {
1368                            self.pos += 1;
1369                            segs.push(PathSeg::Field(n));
1370                        }
1371                        _ => return Err(self.err("expected identifier after `.`")),
1372                    }
1373                }
1374                Some(EToken::LBracket) => {
1375                    self.pos += 1;
1376                    match self.peek().cloned() {
1377                        Some(EToken::Int(n)) => {
1378                            self.pos += 1;
1379                            segs.push(PathSeg::Index(n));
1380                        }
1381                        Some(EToken::Str(s)) => {
1382                            self.pos += 1;
1383                            segs.push(PathSeg::Key(s));
1384                        }
1385                        _ => return Err(self.err("expected integer or string inside `[...]`")),
1386                    }
1387                    if !self.eat(&EToken::RBracket) {
1388                        return Err(self.err("expected `]`"));
1389                    }
1390                }
1391                _ => break,
1392            }
1393        }
1394        Ok(Expr::Path(segs))
1395    }
1396}
1397
1398// =========================================================================
1399// Evaluation
1400// =========================================================================
1401
1402#[derive(Default, Debug, Clone)]
1403struct Scope<'a> {
1404    /// Root bindings passed by the caller.
1405    root: Option<&'a BTreeMap<String, VmValue>>,
1406    /// Override stack — pushed for `for`-loop variables and `include with`.
1407    overrides: Vec<BTreeMap<String, VmValue>>,
1408}
1409
1410impl<'a> Scope<'a> {
1411    fn new(root: Option<&'a BTreeMap<String, VmValue>>) -> Self {
1412        Self {
1413            root,
1414            overrides: Vec::new(),
1415        }
1416    }
1417
1418    fn lookup(&self, name: &str) -> Option<VmValue> {
1419        for layer in self.overrides.iter().rev() {
1420            if let Some(v) = layer.get(name) {
1421                return Some(v.clone());
1422            }
1423        }
1424        self.root.and_then(|m| m.get(name)).cloned()
1425    }
1426
1427    fn push(&mut self, layer: BTreeMap<String, VmValue>) {
1428        self.overrides.push(layer);
1429    }
1430
1431    fn pop(&mut self) {
1432        self.overrides.pop();
1433    }
1434
1435    /// Materialize a flat BTreeMap merging root + all overrides. Used when
1436    /// passing a fresh snapshot into an included partial.
1437    fn flatten(&self) -> BTreeMap<String, VmValue> {
1438        let mut out = BTreeMap::new();
1439        if let Some(r) = self.root {
1440            for (k, v) in r.iter() {
1441                out.insert(k.clone(), v.clone());
1442            }
1443        }
1444        for layer in &self.overrides {
1445            for (k, v) in layer {
1446                out.insert(k.clone(), v.clone());
1447            }
1448        }
1449        out
1450    }
1451}
1452
1453struct RenderCtx {
1454    base: Option<PathBuf>,
1455    include_stack: Vec<PathBuf>,
1456    current_path: Option<PathBuf>,
1457    /// When inside an `{% include %}`, this holds the include-call's
1458    /// span (in the *parent* template). Every span emitted during the
1459    /// recursive render points at this as its `parent_span`, so the
1460    /// IDE can walk a breadcrumb back through nested includes
1461    /// (#96). `None` at the top level.
1462    current_include_parent: Option<Box<PromptSourceSpan>>,
1463}
1464
1465/// Template URI reported alongside every span — the absolute path of
1466/// the currently-rendering `.harn.prompt` file. Empty string when the
1467/// renderer doesn't know (inline template arg or synthetic snippet).
1468fn current_template_uri(rc: &RenderCtx) -> String {
1469    rc.current_path
1470        .as_deref()
1471        .and_then(|p| p.to_str().map(|s| s.to_string()))
1472        .unwrap_or_default()
1473}
1474
1475fn render_nodes(
1476    nodes: &[Node],
1477    scope: &mut Scope<'_>,
1478    rc: &mut RenderCtx,
1479    out: &mut String,
1480    mut spans: Option<&mut Vec<PromptSourceSpan>>,
1481) -> Result<(), TemplateError> {
1482    for n in nodes {
1483        render_node(n, scope, rc, out, spans.as_deref_mut())?;
1484    }
1485    Ok(())
1486}
1487
1488fn render_node(
1489    node: &Node,
1490    scope: &mut Scope<'_>,
1491    rc: &mut RenderCtx,
1492    out: &mut String,
1493    mut spans: Option<&mut Vec<PromptSourceSpan>>,
1494) -> Result<(), TemplateError> {
1495    // Capture the output cursor before the node writes so we can
1496    // record the exact byte range it produced. Nodes that delegate to
1497    // `render_nodes` (If / For / Include) record a span only after
1498    // their children finish so the range covers everything.
1499    let start = out.len();
1500    match node {
1501        Node::Text(s) => {
1502            out.push_str(s);
1503            if let Some(spans) = spans.as_deref_mut() {
1504                // Text nodes don't carry their own line/col in the AST;
1505                // attribute them to the *start* of the next directive
1506                // (line/col 0 is a sentinel meaning "unknown"). The IDE
1507                // uses Text spans only to fill gaps between directive
1508                // spans, so column precision here is not load-bearing.
1509                spans.push(PromptSourceSpan {
1510                    template_line: 0,
1511                    template_col: 0,
1512                    output_start: start,
1513                    output_end: out.len(),
1514                    kind: PromptSpanKind::Text,
1515                    parent_span: rc.current_include_parent.clone(),
1516                    template_uri: current_template_uri(rc),
1517                    bound_value: None,
1518                });
1519            }
1520        }
1521        Node::Expr { expr, line, col } => {
1522            let v = eval_expr(expr, scope, *line, *col)?;
1523            let rendered = display_value(&v);
1524            out.push_str(&rendered);
1525            if let Some(spans) = spans.as_deref_mut() {
1526                spans.push(PromptSourceSpan {
1527                    template_line: *line,
1528                    template_col: *col,
1529                    output_start: start,
1530                    output_end: out.len(),
1531                    kind: PromptSpanKind::Expr,
1532                    parent_span: rc.current_include_parent.clone(),
1533                    template_uri: current_template_uri(rc),
1534                    bound_value: Some(truncate_for_preview(&rendered)),
1535                });
1536            }
1537        }
1538        Node::LegacyBareInterp { ident } => {
1539            let (rendered, preview) = match scope.lookup(ident) {
1540                Some(v) => {
1541                    let s = display_value(&v);
1542                    (s.clone(), Some(truncate_for_preview(&s)))
1543                }
1544                None => (format!("{{{{{ident}}}}}"), None),
1545            };
1546            out.push_str(&rendered);
1547            if let Some(spans) = spans.as_deref_mut() {
1548                spans.push(PromptSourceSpan {
1549                    template_line: 0,
1550                    template_col: 0,
1551                    output_start: start,
1552                    output_end: out.len(),
1553                    kind: PromptSpanKind::LegacyBareInterp,
1554                    parent_span: rc.current_include_parent.clone(),
1555                    template_uri: current_template_uri(rc),
1556                    bound_value: preview,
1557                });
1558            }
1559        }
1560        Node::If {
1561            branches,
1562            else_branch,
1563            line,
1564            col,
1565        } => {
1566            let mut matched = false;
1567            for (cond, body) in branches {
1568                let v = eval_expr(cond, scope, *line, *col)?;
1569                if truthy(&v) {
1570                    render_nodes(body, scope, rc, out, spans.as_deref_mut())?;
1571                    matched = true;
1572                    break;
1573                }
1574            }
1575            if !matched {
1576                if let Some(eb) = else_branch {
1577                    render_nodes(eb, scope, rc, out, spans.as_deref_mut())?;
1578                }
1579            }
1580            if let Some(spans) = spans.as_deref_mut() {
1581                spans.push(PromptSourceSpan {
1582                    template_line: *line,
1583                    template_col: *col,
1584                    output_start: start,
1585                    output_end: out.len(),
1586                    kind: PromptSpanKind::If,
1587                    parent_span: rc.current_include_parent.clone(),
1588                    template_uri: current_template_uri(rc),
1589                    bound_value: None,
1590                });
1591            }
1592        }
1593        Node::For {
1594            value_var,
1595            key_var,
1596            iter,
1597            body,
1598            empty,
1599            line,
1600            col,
1601        } => {
1602            let v = eval_expr(iter, scope, *line, *col)?;
1603            let items: Vec<(VmValue, VmValue)> =
1604                iterable_items(&v).map_err(|m| TemplateError::new(*line, *col, m))?;
1605            if items.is_empty() {
1606                if let Some(eb) = empty {
1607                    render_nodes(eb, scope, rc, out, spans.as_deref_mut())?;
1608                }
1609            } else {
1610                let length = items.len() as i64;
1611                for (idx, (k, val)) in items.iter().enumerate() {
1612                    let mut layer: BTreeMap<String, VmValue> = BTreeMap::new();
1613                    layer.insert(value_var.clone(), val.clone());
1614                    if let Some(kv) = key_var {
1615                        layer.insert(kv.clone(), k.clone());
1616                    }
1617                    let mut loop_map: BTreeMap<String, VmValue> = BTreeMap::new();
1618                    loop_map.insert("index".into(), VmValue::Int(idx as i64 + 1));
1619                    loop_map.insert("index0".into(), VmValue::Int(idx as i64));
1620                    loop_map.insert("first".into(), VmValue::Bool(idx == 0));
1621                    loop_map.insert("last".into(), VmValue::Bool(idx as i64 == length - 1));
1622                    loop_map.insert("length".into(), VmValue::Int(length));
1623                    layer.insert("loop".into(), VmValue::Dict(Rc::new(loop_map)));
1624                    scope.push(layer);
1625                    let iter_start = out.len();
1626                    let res = render_nodes(body, scope, rc, out, spans.as_deref_mut());
1627                    scope.pop();
1628                    res?;
1629                    if let Some(spans) = spans.as_deref_mut() {
1630                        spans.push(PromptSourceSpan {
1631                            template_line: *line,
1632                            template_col: *col,
1633                            output_start: iter_start,
1634                            output_end: out.len(),
1635                            kind: PromptSpanKind::ForIteration,
1636                            parent_span: rc.current_include_parent.clone(),
1637                            template_uri: current_template_uri(rc),
1638                            bound_value: None,
1639                        });
1640                    }
1641                }
1642            }
1643        }
1644        Node::Include {
1645            path,
1646            with,
1647            line,
1648            col,
1649        } => {
1650            let path_val = eval_expr(path, scope, *line, *col)?;
1651            let path_str = match path_val {
1652                VmValue::String(s) => s.to_string(),
1653                other => {
1654                    return Err(TemplateError::new(
1655                        *line,
1656                        *col,
1657                        format!("include path must be a string (got {})", other.type_name()),
1658                    ))
1659                }
1660            };
1661            // Resolve relative to the including file's directory, falling back
1662            // to the asset-root resolver used by render(...).
1663            let resolved: PathBuf = if Path::new(&path_str).is_absolute() {
1664                PathBuf::from(&path_str)
1665            } else if let Some(base) = &rc.base {
1666                base.join(&path_str)
1667            } else {
1668                crate::stdlib::process::resolve_source_asset_path(&path_str)
1669            };
1670            let canonical = resolved.canonicalize().unwrap_or(resolved.clone());
1671            if rc.include_stack.iter().any(|p| p == &canonical) {
1672                let chain = rc
1673                    .include_stack
1674                    .iter()
1675                    .map(|p| p.display().to_string())
1676                    .collect::<Vec<_>>()
1677                    .join(" → ");
1678                return Err(TemplateError::new(
1679                    *line,
1680                    *col,
1681                    format!(
1682                        "circular include detected: {chain} → {}",
1683                        canonical.display()
1684                    ),
1685                ));
1686            }
1687            if rc.include_stack.len() > 32 {
1688                return Err(TemplateError::new(
1689                    *line,
1690                    *col,
1691                    "include depth exceeded (32 levels)",
1692                ));
1693            }
1694            let contents = std::fs::read_to_string(&resolved).map_err(|e| {
1695                TemplateError::new(
1696                    *line,
1697                    *col,
1698                    format!(
1699                        "failed to read included template {}: {e}",
1700                        resolved.display()
1701                    ),
1702                )
1703            })?;
1704            let new_base = resolved.parent().map(Path::to_path_buf);
1705            // Build child scope: flatten current + apply `with { }` overrides.
1706            let mut child_bindings = scope.flatten();
1707            if let Some(pairs) = with {
1708                for (k, e) in pairs {
1709                    let v = eval_expr(e, scope, *line, *col)?;
1710                    child_bindings.insert(k.clone(), v);
1711                }
1712            }
1713            let child_nodes = parse(&contents).map_err(|mut e| {
1714                if e.path.is_none() {
1715                    e.path = Some(resolved.clone());
1716                }
1717                e
1718            })?;
1719            let mut child_scope = Scope::new(Some(&child_bindings));
1720            let saved_base = rc.base.clone();
1721            let saved_current = rc.current_path.clone();
1722            let saved_parent = rc.current_include_parent.clone();
1723            // Build the include-call's own span (#96): points at the
1724            // include directive in the parent template. Every span
1725            // emitted inside the recursive render links back to this
1726            // as its parent_span, composing with any already-present
1727            // chain to give A → B → C breadcrumbs on nested includes.
1728            let include_call_span = PromptSourceSpan {
1729                template_line: *line,
1730                template_col: *col,
1731                output_start: start,
1732                output_end: start,
1733                kind: PromptSpanKind::Include,
1734                bound_value: None,
1735                parent_span: saved_parent.clone(),
1736                template_uri: current_template_uri(rc),
1737            };
1738            rc.base = new_base;
1739            rc.current_path = Some(resolved.clone());
1740            rc.current_include_parent = Some(Box::new(include_call_span));
1741            rc.include_stack.push(canonical);
1742            let res = render_nodes(
1743                &child_nodes,
1744                &mut child_scope,
1745                rc,
1746                out,
1747                spans.as_deref_mut(),
1748            );
1749            rc.include_stack.pop();
1750            rc.base = saved_base;
1751            rc.current_path = saved_current;
1752            rc.current_include_parent = saved_parent;
1753            res?;
1754            if let Some(spans) = spans.as_mut() {
1755                spans.push(PromptSourceSpan {
1756                    template_line: *line,
1757                    template_col: *col,
1758                    output_start: start,
1759                    output_end: out.len(),
1760                    kind: PromptSpanKind::Include,
1761                    parent_span: rc.current_include_parent.clone(),
1762                    template_uri: current_template_uri(rc),
1763                    bound_value: None,
1764                });
1765            }
1766        }
1767    }
1768    Ok(())
1769}
1770
1771/// Cap a rendered value's preview at 80 chars so span records don't
1772/// carry kilobyte prompt chunks. The IDE can fetch the full text by
1773/// reading the rendered string at `output_start..output_end`.
1774fn truncate_for_preview(s: &str) -> String {
1775    const MAX: usize = 80;
1776    if s.chars().count() <= MAX {
1777        return s.to_string();
1778    }
1779    let truncated: String = s.chars().take(MAX - 1).collect();
1780    format!("{truncated}…")
1781}
1782
1783fn eval_expr(
1784    expr: &Expr,
1785    scope: &Scope<'_>,
1786    line: usize,
1787    col: usize,
1788) -> Result<VmValue, TemplateError> {
1789    match expr {
1790        Expr::Nil => Ok(VmValue::Nil),
1791        Expr::Bool(b) => Ok(VmValue::Bool(*b)),
1792        Expr::Int(n) => Ok(VmValue::Int(*n)),
1793        Expr::Float(f) => Ok(VmValue::Float(*f)),
1794        Expr::Str(s) => Ok(VmValue::String(Rc::from(s.as_str()))),
1795        Expr::Path(segs) => Ok(resolve_path(segs, scope)),
1796        Expr::Unary(UnOp::Not, inner) => {
1797            let v = eval_expr(inner, scope, line, col)?;
1798            Ok(VmValue::Bool(!truthy(&v)))
1799        }
1800        Expr::Binary(op, a, b) => {
1801            // Short-circuit boolean ops.
1802            match op {
1803                BinOp::And => {
1804                    let av = eval_expr(a, scope, line, col)?;
1805                    if !truthy(&av) {
1806                        return Ok(av);
1807                    }
1808                    return eval_expr(b, scope, line, col);
1809                }
1810                BinOp::Or => {
1811                    let av = eval_expr(a, scope, line, col)?;
1812                    if truthy(&av) {
1813                        return Ok(av);
1814                    }
1815                    return eval_expr(b, scope, line, col);
1816                }
1817                _ => {}
1818            }
1819            let av = eval_expr(a, scope, line, col)?;
1820            let bv = eval_expr(b, scope, line, col)?;
1821            Ok(apply_cmp(*op, &av, &bv))
1822        }
1823        Expr::Filter(inner, name, args) => {
1824            let v = eval_expr(inner, scope, line, col)?;
1825            let arg_vals = args
1826                .iter()
1827                .map(|e| eval_expr(e, scope, line, col))
1828                .collect::<Result<Vec<_>, _>>()?;
1829            apply_filter(name, &v, &arg_vals, line, col)
1830        }
1831    }
1832}
1833
1834fn resolve_path(segs: &[PathSeg], scope: &Scope<'_>) -> VmValue {
1835    let mut cur: VmValue = match segs.first() {
1836        Some(PathSeg::Field(n)) => match scope.lookup(n) {
1837            Some(v) => v,
1838            None => return VmValue::Nil,
1839        },
1840        _ => return VmValue::Nil,
1841    };
1842    for seg in &segs[1..] {
1843        cur = match (seg, &cur) {
1844            (PathSeg::Field(n), VmValue::Dict(d)) => d.get(n).cloned().unwrap_or(VmValue::Nil),
1845            (PathSeg::Key(k), VmValue::Dict(d)) => d.get(k).cloned().unwrap_or(VmValue::Nil),
1846            (PathSeg::Index(i), VmValue::List(items)) => {
1847                let idx = if *i < 0 { items.len() as i64 + *i } else { *i };
1848                if idx < 0 || (idx as usize) >= items.len() {
1849                    VmValue::Nil
1850                } else {
1851                    items[idx as usize].clone()
1852                }
1853            }
1854            (PathSeg::Index(i), VmValue::String(s)) => {
1855                let chars: Vec<char> = s.chars().collect();
1856                let idx = if *i < 0 { chars.len() as i64 + *i } else { *i };
1857                if idx < 0 || (idx as usize) >= chars.len() {
1858                    VmValue::Nil
1859                } else {
1860                    VmValue::String(Rc::from(chars[idx as usize].to_string()))
1861                }
1862            }
1863            _ => VmValue::Nil,
1864        };
1865    }
1866    cur
1867}
1868
1869fn truthy(v: &VmValue) -> bool {
1870    match v {
1871        VmValue::Nil => false,
1872        VmValue::Bool(b) => *b,
1873        VmValue::Int(n) => *n != 0,
1874        VmValue::Float(f) => *f != 0.0,
1875        VmValue::String(s) => !s.trim().is_empty(),
1876        VmValue::List(items) => !items.is_empty(),
1877        VmValue::Dict(d) => !d.is_empty(),
1878        _ => true,
1879    }
1880}
1881
1882fn apply_cmp(op: BinOp, a: &VmValue, b: &VmValue) -> VmValue {
1883    match op {
1884        BinOp::Eq => VmValue::Bool(values_equal(a, b)),
1885        BinOp::Neq => VmValue::Bool(!values_equal(a, b)),
1886        BinOp::Lt | BinOp::Le | BinOp::Gt | BinOp::Ge => {
1887            let ord = compare(a, b);
1888            match (op, ord) {
1889                (BinOp::Lt, Some(o)) => VmValue::Bool(o == std::cmp::Ordering::Less),
1890                (BinOp::Le, Some(o)) => VmValue::Bool(o != std::cmp::Ordering::Greater),
1891                (BinOp::Gt, Some(o)) => VmValue::Bool(o == std::cmp::Ordering::Greater),
1892                (BinOp::Ge, Some(o)) => VmValue::Bool(o != std::cmp::Ordering::Less),
1893                _ => VmValue::Bool(false),
1894            }
1895        }
1896        BinOp::And | BinOp::Or => unreachable!(),
1897    }
1898}
1899
1900fn compare(a: &VmValue, b: &VmValue) -> Option<std::cmp::Ordering> {
1901    match (a, b) {
1902        (VmValue::Int(x), VmValue::Int(y)) => Some(x.cmp(y)),
1903        (VmValue::Float(x), VmValue::Float(y)) => x.partial_cmp(y),
1904        (VmValue::Int(x), VmValue::Float(y)) => (*x as f64).partial_cmp(y),
1905        (VmValue::Float(x), VmValue::Int(y)) => x.partial_cmp(&(*y as f64)),
1906        (VmValue::String(x), VmValue::String(y)) => Some(x.as_ref().cmp(y.as_ref())),
1907        _ => None,
1908    }
1909}
1910
1911fn iterable_items(v: &VmValue) -> Result<Vec<(VmValue, VmValue)>, String> {
1912    match v {
1913        VmValue::List(items) => Ok(items
1914            .iter()
1915            .enumerate()
1916            .map(|(i, it)| (VmValue::Int(i as i64), it.clone()))
1917            .collect()),
1918        VmValue::Dict(d) => Ok(d
1919            .iter()
1920            .map(|(k, v)| (VmValue::String(Rc::from(k.as_str())), v.clone()))
1921            .collect()),
1922        VmValue::Set(items) => Ok(items
1923            .iter()
1924            .enumerate()
1925            .map(|(i, it)| (VmValue::Int(i as i64), it.clone()))
1926            .collect()),
1927        VmValue::Range(r) => {
1928            let mut out = Vec::new();
1929            let len = r.len();
1930            for i in 0..len {
1931                if let Some(v) = r.get(i) {
1932                    out.push((VmValue::Int(i), VmValue::Int(v)));
1933                }
1934            }
1935            Ok(out)
1936        }
1937        VmValue::Nil => Ok(Vec::new()),
1938        other => Err(format!(
1939            "cannot iterate over {} — expected list, dict, set, or range",
1940            other.type_name()
1941        )),
1942    }
1943}
1944
1945fn display_value(v: &VmValue) -> String {
1946    match v {
1947        VmValue::Nil => String::new(), // empty string — don't render "nil" literal
1948        other => other.display(),
1949    }
1950}
1951
1952// =========================================================================
1953// Filters
1954// =========================================================================
1955
1956fn apply_filter(
1957    name: &str,
1958    v: &VmValue,
1959    args: &[VmValue],
1960    line: usize,
1961    col: usize,
1962) -> Result<VmValue, TemplateError> {
1963    let bad_arity = || {
1964        TemplateError::new(
1965            line,
1966            col,
1967            format!("filter `{name}` got wrong number of arguments"),
1968        )
1969    };
1970    let need = |n: usize, args: &[VmValue]| -> Result<(), TemplateError> {
1971        if args.len() == n {
1972            Ok(())
1973        } else {
1974            Err(bad_arity())
1975        }
1976    };
1977    let str_of = |v: &VmValue| -> String { display_value(v) };
1978    match name {
1979        "upper" => {
1980            need(0, args)?;
1981            Ok(VmValue::String(Rc::from(str_of(v).to_uppercase())))
1982        }
1983        "lower" => {
1984            need(0, args)?;
1985            Ok(VmValue::String(Rc::from(str_of(v).to_lowercase())))
1986        }
1987        "trim" => {
1988            need(0, args)?;
1989            Ok(VmValue::String(Rc::from(str_of(v).trim())))
1990        }
1991        "capitalize" => {
1992            need(0, args)?;
1993            let s = str_of(v);
1994            let mut out = String::with_capacity(s.len());
1995            let mut chars = s.chars();
1996            if let Some(c) = chars.next() {
1997                out.extend(c.to_uppercase());
1998            }
1999            for c in chars {
2000                out.extend(c.to_lowercase());
2001            }
2002            Ok(VmValue::String(Rc::from(out)))
2003        }
2004        "title" => {
2005            need(0, args)?;
2006            let s = str_of(v);
2007            let mut out = String::with_capacity(s.len());
2008            let mut at_start = true;
2009            for c in s.chars() {
2010                if c.is_whitespace() {
2011                    at_start = true;
2012                    out.push(c);
2013                } else if at_start {
2014                    out.extend(c.to_uppercase());
2015                    at_start = false;
2016                } else {
2017                    out.extend(c.to_lowercase());
2018                }
2019            }
2020            Ok(VmValue::String(Rc::from(out)))
2021        }
2022        "length" => {
2023            need(0, args)?;
2024            let n: i64 = match v {
2025                VmValue::String(s) => s.chars().count() as i64,
2026                VmValue::List(items) => items.len() as i64,
2027                VmValue::Set(items) => items.len() as i64,
2028                VmValue::Dict(d) => d.len() as i64,
2029                VmValue::Range(r) => r.len(),
2030                VmValue::Nil => 0,
2031                other => {
2032                    return Err(TemplateError::new(
2033                        line,
2034                        col,
2035                        format!("`length` not defined for {}", other.type_name()),
2036                    ))
2037                }
2038            };
2039            Ok(VmValue::Int(n))
2040        }
2041        "first" => {
2042            need(0, args)?;
2043            Ok(match v {
2044                VmValue::List(items) => items.first().cloned().unwrap_or(VmValue::Nil),
2045                VmValue::Set(items) => items.first().cloned().unwrap_or(VmValue::Nil),
2046                VmValue::String(s) => s
2047                    .chars()
2048                    .next()
2049                    .map(|c| VmValue::String(Rc::from(c.to_string())))
2050                    .unwrap_or(VmValue::Nil),
2051                _ => VmValue::Nil,
2052            })
2053        }
2054        "last" => {
2055            need(0, args)?;
2056            Ok(match v {
2057                VmValue::List(items) => items.last().cloned().unwrap_or(VmValue::Nil),
2058                VmValue::Set(items) => items.last().cloned().unwrap_or(VmValue::Nil),
2059                VmValue::String(s) => s
2060                    .chars()
2061                    .last()
2062                    .map(|c| VmValue::String(Rc::from(c.to_string())))
2063                    .unwrap_or(VmValue::Nil),
2064                _ => VmValue::Nil,
2065            })
2066        }
2067        "reverse" => {
2068            need(0, args)?;
2069            Ok(match v {
2070                VmValue::List(items) => {
2071                    let mut out: Vec<VmValue> = items.as_ref().clone();
2072                    out.reverse();
2073                    VmValue::List(Rc::new(out))
2074                }
2075                VmValue::String(s) => {
2076                    VmValue::String(Rc::from(s.chars().rev().collect::<String>()))
2077                }
2078                _ => v.clone(),
2079            })
2080        }
2081        "join" => {
2082            need(1, args)?;
2083            let sep = str_of(&args[0]);
2084            let parts: Vec<String> = match v {
2085                VmValue::List(items) => items.iter().map(str_of).collect(),
2086                VmValue::Set(items) => items.iter().map(str_of).collect(),
2087                VmValue::String(s) => return Ok(VmValue::String(s.clone())),
2088                _ => {
2089                    return Err(TemplateError::new(
2090                        line,
2091                        col,
2092                        format!("`join` requires a list (got {})", v.type_name()),
2093                    ))
2094                }
2095            };
2096            Ok(VmValue::String(Rc::from(parts.join(&sep))))
2097        }
2098        "default" => {
2099            need(1, args)?;
2100            if truthy(v) {
2101                Ok(v.clone())
2102            } else {
2103                Ok(args[0].clone())
2104            }
2105        }
2106        "json" => {
2107            if args.len() > 1 {
2108                return Err(bad_arity());
2109            }
2110            let pretty = args.first().map(truthy).unwrap_or(false);
2111            let jv = crate::llm::helpers::vm_value_to_json(v);
2112            let s = if pretty {
2113                serde_json::to_string_pretty(&jv)
2114            } else {
2115                serde_json::to_string(&jv)
2116            }
2117            .map_err(|e| TemplateError::new(line, col, format!("json serialization: {e}")))?;
2118            Ok(VmValue::String(Rc::from(s)))
2119        }
2120        "indent" => {
2121            if args.is_empty() || args.len() > 2 {
2122                return Err(bad_arity());
2123            }
2124            let n = match &args[0] {
2125                VmValue::Int(n) => (*n).max(0) as usize,
2126                _ => {
2127                    return Err(TemplateError::new(
2128                        line,
2129                        col,
2130                        "`indent` requires an integer width",
2131                    ))
2132                }
2133            };
2134            let indent_first = args.get(1).map(truthy).unwrap_or(false);
2135            let pad: String = " ".repeat(n);
2136            let s = str_of(v);
2137            let mut out = String::with_capacity(s.len() + n * 4);
2138            for (i, line) in s.split('\n').enumerate() {
2139                if i > 0 {
2140                    out.push('\n');
2141                }
2142                if !line.is_empty() && (i > 0 || indent_first) {
2143                    out.push_str(&pad);
2144                }
2145                out.push_str(line);
2146            }
2147            Ok(VmValue::String(Rc::from(out)))
2148        }
2149        "lines" => {
2150            need(0, args)?;
2151            let s = str_of(v);
2152            let list: Vec<VmValue> = s
2153                .split('\n')
2154                .map(|p| VmValue::String(Rc::from(p)))
2155                .collect();
2156            Ok(VmValue::List(Rc::new(list)))
2157        }
2158        "escape_md" => {
2159            need(0, args)?;
2160            let s = str_of(v);
2161            let mut out = String::with_capacity(s.len() + 8);
2162            for c in s.chars() {
2163                match c {
2164                    '\\' | '`' | '*' | '_' | '{' | '}' | '[' | ']' | '(' | ')' | '#' | '+'
2165                    | '-' | '.' | '!' | '|' | '<' | '>' => {
2166                        out.push('\\');
2167                        out.push(c);
2168                    }
2169                    _ => out.push(c),
2170                }
2171            }
2172            Ok(VmValue::String(Rc::from(out)))
2173        }
2174        "replace" => {
2175            need(2, args)?;
2176            let s = str_of(v);
2177            let from = str_of(&args[0]);
2178            let to = str_of(&args[1]);
2179            Ok(VmValue::String(Rc::from(s.replace(&from, &to))))
2180        }
2181        other => Err(TemplateError::new(
2182            line,
2183            col,
2184            format!("unknown filter `{other}`"),
2185        )),
2186    }
2187}
2188
2189// =========================================================================
2190// Small helpers for token/expr splitting
2191// =========================================================================
2192
2193fn split_top_level(s: &str, delim: char) -> Vec<&str> {
2194    let mut out = Vec::new();
2195    let mut depth = 0i32;
2196    let mut in_str = false;
2197    let mut quote = '"';
2198    let bytes = s.as_bytes();
2199    let mut start = 0;
2200    let mut i = 0;
2201    while i < bytes.len() {
2202        let b = bytes[i] as char;
2203        if in_str {
2204            if b == '\\' {
2205                i += 2;
2206                continue;
2207            }
2208            if b == quote {
2209                in_str = false;
2210            }
2211            i += 1;
2212            continue;
2213        }
2214        match b {
2215            '"' | '\'' => {
2216                in_str = true;
2217                quote = b;
2218            }
2219            '(' | '[' | '{' => depth += 1,
2220            ')' | ']' | '}' => depth -= 1,
2221            c if c == delim && depth == 0 => {
2222                out.push(&s[start..i]);
2223                start = i + 1;
2224            }
2225            _ => {}
2226        }
2227        i += 1;
2228    }
2229    out.push(&s[start..]);
2230    out
2231}
2232
2233fn split_once_top_level(s: &str, delim: char) -> Option<(&str, &str)> {
2234    let mut depth = 0i32;
2235    let mut in_str = false;
2236    let mut quote = '"';
2237    let bytes = s.as_bytes();
2238    let mut i = 0;
2239    while i < bytes.len() {
2240        let b = bytes[i] as char;
2241        if in_str {
2242            if b == '\\' {
2243                i += 2;
2244                continue;
2245            }
2246            if b == quote {
2247                in_str = false;
2248            }
2249            i += 1;
2250            continue;
2251        }
2252        match b {
2253            '"' | '\'' => {
2254                in_str = true;
2255                quote = b;
2256            }
2257            '(' | '[' | '{' => depth += 1,
2258            ')' | ']' | '}' => depth -= 1,
2259            c if c == delim && depth == 0 => {
2260                return Some((&s[..i], &s[i + 1..]));
2261            }
2262            _ => {}
2263        }
2264        i += 1;
2265    }
2266    None
2267}
2268
2269fn split_once_keyword<'a>(s: &'a str, kw: &str) -> Option<(&'a str, &'a str)> {
2270    // Match `kw` only at top level, outside strings and bracket groups.
2271    let mut depth = 0i32;
2272    let mut in_str = false;
2273    let mut quote = '"';
2274    let bytes = s.as_bytes();
2275    let kw_bytes = kw.as_bytes();
2276    let mut i = 0;
2277    while i + kw_bytes.len() <= bytes.len() {
2278        let b = bytes[i] as char;
2279        if in_str {
2280            if b == '\\' {
2281                i += 2;
2282                continue;
2283            }
2284            if b == quote {
2285                in_str = false;
2286            }
2287            i += 1;
2288            continue;
2289        }
2290        match b {
2291            '"' | '\'' => {
2292                in_str = true;
2293                quote = b;
2294                i += 1;
2295                continue;
2296            }
2297            '(' | '[' | '{' => {
2298                depth += 1;
2299                i += 1;
2300                continue;
2301            }
2302            ')' | ']' | '}' => {
2303                depth -= 1;
2304                i += 1;
2305                continue;
2306            }
2307            _ => {}
2308        }
2309        if depth == 0 && &bytes[i..i + kw_bytes.len()] == kw_bytes {
2310            return Some((&s[..i], &s[i + kw_bytes.len()..]));
2311        }
2312        i += 1;
2313    }
2314    None
2315}
2316
2317// =========================================================================
2318// Tests
2319// =========================================================================
2320
2321#[cfg(test)]
2322mod tests {
2323    use super::*;
2324
2325    fn dict(pairs: &[(&str, VmValue)]) -> BTreeMap<String, VmValue> {
2326        pairs
2327            .iter()
2328            .map(|(k, v)| (k.to_string(), v.clone()))
2329            .collect()
2330    }
2331
2332    fn s(v: &str) -> VmValue {
2333        VmValue::String(Rc::from(v))
2334    }
2335
2336    fn render(tpl: &str, b: &BTreeMap<String, VmValue>) -> String {
2337        render_template_result(tpl, Some(b), None, None).unwrap()
2338    }
2339
2340    fn render_with_spans(
2341        tpl: &str,
2342        b: &BTreeMap<String, VmValue>,
2343    ) -> (String, Vec<PromptSourceSpan>) {
2344        render_template_with_provenance(tpl, Some(b), None, None, true).unwrap()
2345    }
2346
2347    #[test]
2348    fn bare_interp() {
2349        let b = dict(&[("name", s("Alice"))]);
2350        assert_eq!(render("hi {{name}}!", &b), "hi Alice!");
2351    }
2352
2353    #[test]
2354    fn provenance_expr_span_matches_output_range() {
2355        // Use dotted-path and filter forms so the parser emits
2356        // `Expr` (not `LegacyBareInterp`) — different kinds carry
2357        // different span types in the provenance map.
2358        let mut user = BTreeMap::new();
2359        user.insert("name".to_string(), s("alice"));
2360        let b = dict(&[
2361            ("user", VmValue::Dict(Rc::new(user))),
2362            ("count", VmValue::Int(42)),
2363        ]);
2364        let (out, spans) =
2365            render_with_spans("hello {{ user.name }} ({{ count | default: 0 }})", &b);
2366        assert_eq!(out, "hello alice (42)");
2367
2368        let expr_spans: Vec<_> = spans
2369            .iter()
2370            .filter(|s| s.kind == PromptSpanKind::Expr)
2371            .collect();
2372        assert_eq!(expr_spans.len(), 2);
2373
2374        // Every expr span's output range must slice back to the
2375        // rendered value it produced — the property the IDE relies on.
2376        let user_span = expr_spans
2377            .iter()
2378            .find(|s| &out[s.output_start..s.output_end] == "alice")
2379            .expect("user expr span");
2380        assert!(user_span.template_line >= 1);
2381        assert_eq!(user_span.bound_value.as_deref(), Some("alice"));
2382
2383        let count_span = expr_spans
2384            .iter()
2385            .find(|s| &out[s.output_start..s.output_end] == "42")
2386            .expect("count expr span");
2387        assert_eq!(count_span.bound_value.as_deref(), Some("42"));
2388    }
2389
2390    #[test]
2391    fn provenance_legacy_bare_interp_span_tracked() {
2392        let b = dict(&[("name", s("Alice"))]);
2393        let (out, spans) = render_with_spans("hi {{name}}!", &b);
2394        assert_eq!(out, "hi Alice!");
2395
2396        let bare = spans
2397            .iter()
2398            .find(|s| s.kind == PromptSpanKind::LegacyBareInterp)
2399            .expect("legacy bare span");
2400        assert_eq!(&out[bare.output_start..bare.output_end], "Alice");
2401        assert_eq!(bare.bound_value.as_deref(), Some("Alice"));
2402    }
2403
2404    #[test]
2405    fn provenance_includes_loop_iterations() {
2406        let b = dict(&[(
2407            "items",
2408            VmValue::List(Rc::new(vec![s("a"), s("b"), s("c")])),
2409        )]);
2410        let tpl = "{{for x in items}}[{{x}}]{{end}}";
2411        let (out, spans) = render_with_spans(tpl, &b);
2412        assert_eq!(out, "[a][b][c]");
2413        let iter_spans: Vec<_> = spans
2414            .iter()
2415            .filter(|s| s.kind == PromptSpanKind::ForIteration)
2416            .collect();
2417        assert_eq!(iter_spans.len(), 3);
2418        // Each iteration span should slice to its bracketed item.
2419        let slices: Vec<&str> = iter_spans
2420            .iter()
2421            .map(|s| &out[s.output_start..s.output_end])
2422            .collect();
2423        assert_eq!(slices, ["[a]", "[b]", "[c]"]);
2424    }
2425
2426    #[test]
2427    fn provenance_preview_is_truncated() {
2428        // Long expression values shouldn't balloon the span record.
2429        // Use the dotted form so it parses as Expr rather than legacy.
2430        let mut wrap = BTreeMap::new();
2431        wrap.insert("val".to_string(), s(&"x".repeat(500)));
2432        let b = dict(&[("blob", VmValue::Dict(Rc::new(wrap)))]);
2433        let (_, spans) = render_with_spans("{{blob.val}}", &b);
2434        let expr = spans
2435            .iter()
2436            .find(|s| s.kind == PromptSpanKind::Expr)
2437            .expect("expr span");
2438        let preview = expr.bound_value.as_deref().unwrap();
2439        assert!(preview.chars().count() <= 80, "preview too long: {preview}");
2440        assert!(preview.ends_with('…'));
2441    }
2442
2443    #[test]
2444    fn provenance_off_returns_empty_spans() {
2445        let b = dict(&[("x", s("y"))]);
2446        let (_, spans) =
2447            render_template_with_provenance("{{x}}", Some(&b), None, None, false).unwrap();
2448        assert!(spans.is_empty());
2449    }
2450
2451    #[test]
2452    fn bare_interp_missing_passthrough() {
2453        let b = dict(&[]);
2454        assert_eq!(render("hi {{name}}!", &b), "hi {{name}}!");
2455    }
2456
2457    #[test]
2458    fn legacy_if_truthy() {
2459        let b = dict(&[("x", VmValue::Bool(true))]);
2460        assert_eq!(render("{{if x}}yes{{end}}", &b), "yes");
2461    }
2462
2463    #[test]
2464    fn legacy_if_falsey() {
2465        let b = dict(&[("x", VmValue::Bool(false))]);
2466        assert_eq!(render("{{if x}}yes{{end}}", &b), "");
2467    }
2468
2469    #[test]
2470    fn if_else() {
2471        let b = dict(&[("x", VmValue::Bool(false))]);
2472        assert_eq!(render("{{if x}}A{{else}}B{{end}}", &b), "B");
2473    }
2474
2475    #[test]
2476    fn if_elif_else() {
2477        let b = dict(&[("n", VmValue::Int(2))]);
2478        let tpl = "{{if n == 1}}one{{elif n == 2}}two{{elif n == 3}}three{{else}}many{{end}}";
2479        assert_eq!(render(tpl, &b), "two");
2480    }
2481
2482    #[test]
2483    fn for_loop_basic() {
2484        let items = VmValue::List(Rc::new(vec![s("a"), s("b"), s("c")]));
2485        let b = dict(&[("xs", items)]);
2486        assert_eq!(render("{{for x in xs}}{{x}},{{end}}", &b), "a,b,c,");
2487    }
2488
2489    #[test]
2490    fn for_loop_vars() {
2491        let items = VmValue::List(Rc::new(vec![s("a"), s("b")]));
2492        let b = dict(&[("xs", items)]);
2493        let tpl = "{{for x in xs}}{{loop.index}}:{{x}}{{if !loop.last}},{{end}}{{end}}";
2494        assert_eq!(render(tpl, &b), "1:a,2:b");
2495    }
2496
2497    #[test]
2498    fn for_empty_else() {
2499        let b = dict(&[("xs", VmValue::List(Rc::new(vec![])))]);
2500        assert_eq!(render("{{for x in xs}}A{{else}}empty{{end}}", &b), "empty");
2501    }
2502
2503    #[test]
2504    fn for_dict_kv() {
2505        let mut d: BTreeMap<String, VmValue> = BTreeMap::new();
2506        d.insert("a".into(), VmValue::Int(1));
2507        d.insert("b".into(), VmValue::Int(2));
2508        let b = dict(&[("m", VmValue::Dict(Rc::new(d)))]);
2509        assert_eq!(
2510            render("{{for k, v in m}}{{k}}={{v}};{{end}}", &b),
2511            "a=1;b=2;"
2512        );
2513    }
2514
2515    #[test]
2516    fn nested_path() {
2517        let mut inner: BTreeMap<String, VmValue> = BTreeMap::new();
2518        inner.insert("name".into(), s("Alice"));
2519        let b = dict(&[("user", VmValue::Dict(Rc::new(inner)))]);
2520        assert_eq!(render("{{user.name}}", &b), "Alice");
2521    }
2522
2523    #[test]
2524    fn list_index() {
2525        let b = dict(&[("xs", VmValue::List(Rc::new(vec![s("a"), s("b"), s("c")])))]);
2526        assert_eq!(render("{{xs[1]}}", &b), "b");
2527    }
2528
2529    #[test]
2530    fn filter_upper() {
2531        let b = dict(&[("n", s("alice"))]);
2532        assert_eq!(render("{{n | upper}}", &b), "ALICE");
2533    }
2534
2535    #[test]
2536    fn filter_default() {
2537        let b = dict(&[("n", s(""))]);
2538        assert_eq!(render("{{n | default: \"anon\"}}", &b), "anon");
2539    }
2540
2541    #[test]
2542    fn filter_join() {
2543        let b = dict(&[("xs", VmValue::List(Rc::new(vec![s("a"), s("b")])))]);
2544        assert_eq!(render("{{xs | join: \", \"}}", &b), "a, b");
2545    }
2546
2547    #[test]
2548    fn comparison_ops() {
2549        let b = dict(&[("n", VmValue::Int(5))]);
2550        assert_eq!(render("{{if n > 3}}big{{end}}", &b), "big");
2551        assert_eq!(render("{{if n >= 5 and n < 10}}ok{{end}}", &b), "ok");
2552    }
2553
2554    #[test]
2555    fn bool_not() {
2556        let b = dict(&[("x", VmValue::Bool(false))]);
2557        assert_eq!(render("{{if not x}}yes{{end}}", &b), "yes");
2558        assert_eq!(render("{{if !x}}yes{{end}}", &b), "yes");
2559    }
2560
2561    #[test]
2562    fn raw_block() {
2563        let b = dict(&[]);
2564        assert_eq!(
2565            render("A {{ raw }}{{not-a-directive}}{{ endraw }} B", &b),
2566            "A {{not-a-directive}} B"
2567        );
2568    }
2569
2570    #[test]
2571    fn comment_stripped() {
2572        let b = dict(&[("x", s("hi"))]);
2573        assert_eq!(render("A{{# hidden #}}B{{x}}", &b), "ABhi");
2574    }
2575
2576    #[test]
2577    fn whitespace_trim() {
2578        let b = dict(&[("x", s("v"))]);
2579        // Trailing -}} eats newline after it; leading {{- eats newline before it.
2580        let tpl = "line1\n  {{- x -}}  \nline2";
2581        assert_eq!(render(tpl, &b), "line1vline2");
2582    }
2583
2584    #[test]
2585    fn filter_json() {
2586        let b = dict(&[(
2587            "x",
2588            VmValue::Dict(Rc::new({
2589                let mut m = BTreeMap::new();
2590                m.insert("a".into(), VmValue::Int(1));
2591                m
2592            })),
2593        )]);
2594        assert_eq!(render("{{x | json}}", &b), r#"{"a":1}"#);
2595    }
2596
2597    #[test]
2598    fn error_unterminated_if() {
2599        let b = dict(&[("x", VmValue::Bool(true))]);
2600        let r = render_template_result("{{if x}}open", Some(&b), None, None);
2601        assert!(r.is_err());
2602    }
2603
2604    #[test]
2605    fn error_unknown_filter() {
2606        let b = dict(&[("x", s("a"))]);
2607        let r = render_template_result("{{x | bogus}}", Some(&b), None, None);
2608        assert!(r.is_err());
2609    }
2610
2611    #[test]
2612    fn include_with() {
2613        use std::fs;
2614        let dir = tempdir();
2615        let partial = dir.join("p.prompt");
2616        fs::write(&partial, "[{{name}}]").unwrap();
2617        let parent = dir.join("main.prompt");
2618        fs::write(
2619            &parent,
2620            r#"hello {{ include "p.prompt" with { name: who } }}!"#,
2621        )
2622        .unwrap();
2623        let b = dict(&[("who", s("world"))]);
2624        let src = fs::read_to_string(&parent).unwrap();
2625        let out = render_template_result(&src, Some(&b), Some(&dir), Some(&parent)).unwrap();
2626        assert_eq!(out, "hello [world]!");
2627    }
2628
2629    #[test]
2630    fn prompt_render_indices_accumulate_in_order() {
2631        reset_prompt_registry();
2632        record_prompt_render_index("p-1", 5);
2633        record_prompt_render_index("p-1", 9);
2634        record_prompt_render_index("p-2", 7);
2635        let p1 = prompt_render_indices("p-1");
2636        assert_eq!(p1, vec![5, 9]);
2637        let p2 = prompt_render_indices("p-2");
2638        assert_eq!(p2, vec![7]);
2639        assert!(prompt_render_indices("unknown").is_empty());
2640        reset_prompt_registry();
2641        assert!(
2642            prompt_render_indices("p-1").is_empty(),
2643            "reset clears the map"
2644        );
2645    }
2646
2647    #[test]
2648    fn include_propagates_parent_span_chain() {
2649        use std::fs;
2650        let dir = tempdir();
2651        // Three-level include chain: top → mid → leaf. Every span
2652        // emitted inside `leaf` must chain back through mid's include
2653        // call to top's include call so the IDE can render a
2654        // breadcrumb.
2655        let leaf = dir.join("leaf.prompt");
2656        fs::write(&leaf, "LEAF:{{v}}").unwrap();
2657        let mid = dir.join("mid.prompt");
2658        fs::write(&mid, r#"MID:{{ include "leaf.prompt" }}"#).unwrap();
2659        let top = dir.join("top.prompt");
2660        fs::write(&top, r#"TOP:{{ include "mid.prompt" }}"#).unwrap();
2661        let b = dict(&[("v", s("ok"))]);
2662        let src = fs::read_to_string(&top).unwrap();
2663        let (rendered, spans) =
2664            render_template_with_provenance(&src, Some(&b), Some(&dir), Some(&top), true).unwrap();
2665        assert_eq!(rendered, "TOP:MID:LEAF:ok");
2666
2667        // Locate the interpolation span for `{{v}}` — it lives at
2668        // depth 2 (inside leaf, inside mid, inside top). Its
2669        // parent_span chain must be length 2: leaf's span has mid's
2670        // include as parent, which has top's include as grandparent.
2671        // Bare `{{ident}}` is LegacyBareInterp, not Expr.
2672        let leaf_expr = spans
2673            .iter()
2674            .find(|s| {
2675                matches!(
2676                    s.kind,
2677                    PromptSpanKind::Expr | PromptSpanKind::LegacyBareInterp
2678                ) && s.parent_span.is_some()
2679            })
2680            .expect("interpolation span emitted");
2681        let mid_parent = leaf_expr
2682            .parent_span
2683            .as_deref()
2684            .expect("leaf span must have mid's include as parent");
2685        assert_eq!(mid_parent.kind, PromptSpanKind::Include);
2686        let top_parent = mid_parent
2687            .parent_span
2688            .as_deref()
2689            .expect("mid's include must chain up to top's include");
2690        assert_eq!(top_parent.kind, PromptSpanKind::Include);
2691        assert!(top_parent.parent_span.is_none(), "chain bottoms out at top");
2692
2693        // templateUri on each level points at the authoring file so
2694        // IDE breadcrumbs can open the right source.
2695        assert!(leaf_expr.template_uri.ends_with("leaf.prompt"));
2696        assert!(mid_parent.template_uri.ends_with("mid.prompt"));
2697        assert!(top_parent.template_uri.ends_with("top.prompt"));
2698    }
2699
2700    #[test]
2701    fn include_cycle_detected() {
2702        use std::fs;
2703        let dir = tempdir();
2704        let a = dir.join("a.prompt");
2705        let b = dir.join("b.prompt");
2706        fs::write(&a, r#"A{{ include "b.prompt" }}"#).unwrap();
2707        fs::write(&b, r#"B{{ include "a.prompt" }}"#).unwrap();
2708        let src = fs::read_to_string(&a).unwrap();
2709        let r = render_template_result(&src, None, Some(&dir), Some(&a));
2710        assert!(r.is_err());
2711        assert!(r.unwrap_err().kind.contains("circular include"));
2712    }
2713
2714    fn tempdir() -> PathBuf {
2715        let base = std::env::temp_dir().join(format!("harn-tpl-{}", nanoid()));
2716        std::fs::create_dir_all(&base).unwrap();
2717        base
2718    }
2719
2720    fn nanoid() -> String {
2721        use std::time::{SystemTime, UNIX_EPOCH};
2722        format!(
2723            "{}",
2724            SystemTime::now()
2725                .duration_since(UNIX_EPOCH)
2726                .unwrap()
2727                .as_nanos()
2728        )
2729    }
2730}