Skip to main content

harn_vm/stdlib/template/
mod.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;
27
28use crate::value::{VmError, VmValue};
29
30mod assets;
31mod ast;
32mod error;
33mod expr_parser;
34mod filters;
35mod lexer;
36mod parser;
37mod render;
38
39#[cfg(test)]
40mod tests;
41
42use assets::parse_cached;
43pub(crate) use assets::TemplateAsset;
44use error::TemplateError;
45use render::{render_nodes, RenderCtx, Scope};
46
47// Thread-local registry of recent prompt renders keyed by `prompt_id`.
48// Populated by `render_with_provenance` so the DAP adapter can serve
49// `burin/promptProvenance` and `burin/promptConsumers` reverse queries
50// without forcing the pipeline author to pass the spans dict back up
51// through the bridge. Capped at 64 renders (FIFO) to bound memory.
52thread_local! {
53    static PROMPT_REGISTRY: RefCell<Vec<RegisteredPrompt>> = const { RefCell::new(Vec::new()) };
54    // prompt_id -> [event_index...] where the prompt was consumed by
55    // an LLM call. Populated by emission sites once they thread the
56    // id alongside the rendered text; read by burin/promptConsumers
57    // to power the template gutter's jump-to-next-render action
58    // (#106). A per-session reset is handled by reset_prompt_registry.
59    static PROMPT_RENDER_INDICES: RefCell<BTreeMap<String, Vec<u64>>> =
60        const { RefCell::new(BTreeMap::new()) };
61    // Monotonic render ordinal driven by the prompt_mark_rendered
62    // builtin (#106). A fresh thread-local counter since the IDE
63    // correlates ordinals to event_indices at render time.
64    static PROMPT_RENDER_ORDINAL: RefCell<u64> = const { RefCell::new(0) };
65}
66
67const PROMPT_REGISTRY_CAP: usize = 64;
68
69#[derive(Debug, Clone)]
70pub struct RegisteredPrompt {
71    pub prompt_id: String,
72    pub template_uri: String,
73    pub rendered: String,
74    pub spans: Vec<PromptSourceSpan>,
75}
76
77/// Record a provenance map in the thread-local registry and return the
78/// assigned `prompt_id`. Newest entries push to the back; when the cap
79/// is reached the oldest entry is dropped so the registry never grows
80/// unboundedly over long sessions.
81pub(crate) fn register_prompt(
82    template_uri: String,
83    rendered: String,
84    spans: Vec<PromptSourceSpan>,
85) -> String {
86    let prompt_id = format!("prompt-{}", next_prompt_serial());
87    PROMPT_REGISTRY.with(|reg| {
88        let mut reg = reg.borrow_mut();
89        if reg.len() >= PROMPT_REGISTRY_CAP {
90            reg.remove(0);
91        }
92        reg.push(RegisteredPrompt {
93            prompt_id: prompt_id.clone(),
94            template_uri,
95            rendered,
96            spans,
97        });
98    });
99    prompt_id
100}
101
102thread_local! {
103    static PROMPT_SERIAL: RefCell<u64> = const { RefCell::new(0) };
104}
105
106fn next_prompt_serial() -> u64 {
107    PROMPT_SERIAL.with(|s| {
108        let mut s = s.borrow_mut();
109        *s += 1;
110        *s
111    })
112}
113
114/// Resolve an output byte offset to its originating template span.
115/// Returns the innermost matching `Expr` / `LegacyBareInterp` span when
116/// one exists, falling back to broader structural spans (If / For /
117/// Include) so a click anywhere in a rendered loop iteration still
118/// navigates somewhere useful.
119pub fn lookup_prompt_span(
120    prompt_id: &str,
121    output_offset: usize,
122) -> Option<(String, PromptSourceSpan)> {
123    PROMPT_REGISTRY.with(|reg| {
124        let reg = reg.borrow();
125        let entry = reg.iter().find(|p| p.prompt_id == prompt_id)?;
126        let best = entry
127            .spans
128            .iter()
129            .filter(|s| {
130                output_offset >= s.output_start
131                    && output_offset < s.output_end.max(s.output_start + 1)
132            })
133            .min_by_key(|s| {
134                let width = s.output_end.saturating_sub(s.output_start);
135                let kind_weight = match s.kind {
136                    PromptSpanKind::Expr => 0,
137                    PromptSpanKind::LegacyBareInterp => 1,
138                    PromptSpanKind::Text => 2,
139                    PromptSpanKind::Include => 3,
140                    PromptSpanKind::ForIteration => 4,
141                    PromptSpanKind::If => 5,
142                };
143                (kind_weight, width)
144            })?
145            .clone();
146        Some((entry.template_uri.clone(), best))
147    })
148}
149
150/// Return every span across every registered prompt that overlaps a
151/// template range. Powers the inverse "which rendered ranges consumed
152/// this template region?" navigation.
153pub fn lookup_prompt_consumers(
154    template_uri: &str,
155    template_line_start: usize,
156    template_line_end: usize,
157) -> Vec<(String, PromptSourceSpan)> {
158    PROMPT_REGISTRY.with(|reg| {
159        let reg = reg.borrow();
160        reg.iter()
161            .flat_map(|p| {
162                let prompt_id = p.prompt_id.clone();
163                p.spans
164                    .iter()
165                    .filter(move |s| {
166                        let line = s.template_line;
167                        s.template_uri == template_uri
168                            && line > 0
169                            && line >= template_line_start
170                            && line <= template_line_end
171                    })
172                    .cloned()
173                    .map(move |s| (prompt_id.clone(), s))
174            })
175            .collect()
176    })
177}
178
179/// Record a render event index against a prompt_id (#106). The
180/// scrubber's jump-to-render action walks this map to move the
181/// playhead to the AgentEvent where the template was consumed.
182/// Stored as a Vec so re-renders of the same prompt id accumulate.
183pub fn record_prompt_render_index(prompt_id: &str, event_index: u64) {
184    PROMPT_RENDER_INDICES.with(|map| {
185        map.borrow_mut()
186            .entry(prompt_id.to_string())
187            .or_default()
188            .push(event_index);
189    });
190}
191
192/// Produce the next monotonic ordinal for a render-mark. Pipelines
193/// invoke the `prompt_mark_rendered` builtin which calls this to
194/// obtain a sequence number without having to know about per-session
195/// event counters. The IDE scrubber orders matching consumers by
196/// this ordinal when the emitted_at_ms timestamps collide.
197pub fn next_prompt_render_ordinal() -> u64 {
198    PROMPT_RENDER_ORDINAL.with(|c| {
199        let mut n = c.borrow_mut();
200        *n += 1;
201        *n
202    })
203}
204
205/// Fetch every event index where `prompt_id` was rendered. Called
206/// by the DAP adapter to populate the `eventIndices` list in the
207/// `burin/promptConsumers` response.
208pub fn prompt_render_indices(prompt_id: &str) -> Vec<u64> {
209    PROMPT_RENDER_INDICES.with(|map| map.borrow().get(prompt_id).cloned().unwrap_or_default())
210}
211
212/// Clear the registry. Wired into `reset_thread_local_state` so tests
213/// and serialized adapter sessions start from a clean slate.
214pub(crate) fn reset_prompt_registry() {
215    PROMPT_REGISTRY.with(|reg| reg.borrow_mut().clear());
216    PROMPT_SERIAL.with(|s| *s.borrow_mut() = 0);
217    PROMPT_RENDER_INDICES.with(|map| map.borrow_mut().clear());
218    PROMPT_RENDER_ORDINAL.with(|c| *c.borrow_mut() = 0);
219}
220
221/// Parse-only validation for lint/preflight. Returns a human-readable error
222/// message when the template body is syntactically invalid; `Ok(())` when the
223/// template would parse. Does not resolve `{{ include }}` targets — those are
224/// validated at render time with their own error reporting.
225pub fn validate_template_syntax(src: &str) -> Result<(), String> {
226    parser::parse(src).map(|_| ()).map_err(|e| e.message())
227}
228
229/// Full-featured entrypoint that preserves errors. `base` is the directory
230/// used to resolve `{{ include "..." }}` paths; `source_path` (if known) is
231/// included in error messages.
232pub(crate) fn render_template_result(
233    template: &str,
234    bindings: Option<&BTreeMap<String, VmValue>>,
235    base: Option<&Path>,
236    source_path: Option<&Path>,
237) -> Result<String, TemplateError> {
238    let (rendered, _spans) =
239        render_template_with_provenance(template, bindings, base, source_path, false)?;
240    Ok(rendered)
241}
242
243/// Render a template for callers outside the VM crate that need the same
244/// prompt-template semantics as `render(...)` / `render_prompt(...)`.
245pub fn render_template_to_string(
246    template: &str,
247    bindings: Option<&BTreeMap<String, VmValue>>,
248    base: Option<&Path>,
249    source_path: Option<&Path>,
250) -> Result<String, String> {
251    render_template_result(template, bindings, base, source_path).map_err(|error| error.message())
252}
253
254/// One byte-range in a rendered prompt mapped back to its source
255/// template. Foundation for the prompt-provenance UX (burin-code #93):
256/// hover a chunk of the live prompt in the debugger and jump to the
257/// `.harn.prompt` line that produced it.
258///
259/// `output_start` / `output_end` are byte offsets into the rendered
260/// string. `template_line` / `template_col` are 1-based positions in
261/// the source template. `bound_value` carries a short preview of the
262/// expression's runtime value when it's a scalar; omitted for
263/// structural nodes (if/for/include) so callers don't log a giant
264/// dict display for a single `{% for %}`.
265#[derive(Debug, Clone)]
266pub struct PromptSourceSpan {
267    pub template_line: usize,
268    pub template_col: usize,
269    pub output_start: usize,
270    pub output_end: usize,
271    pub kind: PromptSpanKind,
272    pub bound_value: Option<String>,
273    /// When the span was rendered from inside an `include` (possibly
274    /// transitively), this points at the including call's span in the
275    /// parent template. Chained boxes let the IDE walk `A → B → C`
276    /// cross-template breadcrumbs when a deep render spans three
277    /// files. `None` for top-level spans.
278    pub parent_span: Option<Box<PromptSourceSpan>>,
279    /// Template URI for the file that authored this span. Top-level
280    /// spans carry the root render's template uri; included-child
281    /// spans carry the included file's uri so breadcrumb navigation
282    /// can open the right file when the user clicks through the
283    /// `parent_span` chain. Defaults to empty string for callers that
284    /// don't plumb it through.
285    pub template_uri: String,
286}
287
288#[derive(Debug, Clone, Copy, PartialEq, Eq)]
289pub enum PromptSpanKind {
290    /// Literal template text between directives.
291    Text,
292    /// `{{ expr }}` interpolation — the most common kind the IDE
293    /// wants to highlight on hover.
294    Expr,
295    /// Legacy bare `{{ident}}` fallthrough, surfaced separately so the
296    /// IDE can visually distinguish resolved from pass-through.
297    LegacyBareInterp,
298    /// Conditional branch text that actually rendered (the taken branch).
299    If,
300    /// One loop iteration's rendered body.
301    ForIteration,
302    /// Rendered partial/include expansion. Child spans carry the
303    /// included template's own `template_uri`.
304    Include,
305}
306
307/// Provenance-aware rendering. Returns the rendered string plus — when
308/// `collect_provenance` is true — one `PromptSourceSpan` per node so the
309/// IDE can link rendered byte ranges back to template source offsets.
310/// When `collect_provenance` is false, this degrades to the cheap
311/// non-tracked rendering path that the legacy callers use.
312pub(crate) fn render_template_with_provenance(
313    template: &str,
314    bindings: Option<&BTreeMap<String, VmValue>>,
315    base: Option<&Path>,
316    source_path: Option<&Path>,
317    collect_provenance: bool,
318) -> Result<(String, Vec<PromptSourceSpan>), TemplateError> {
319    let asset = TemplateAsset::inline(template, base, source_path);
320    render_asset_with_provenance_result(&asset, bindings, collect_provenance)
321}
322
323pub(crate) fn render_asset_result(
324    asset: &TemplateAsset,
325    bindings: Option<&BTreeMap<String, VmValue>>,
326) -> Result<String, TemplateError> {
327    let (rendered, _spans) = render_asset_with_provenance_result(asset, bindings, false)?;
328    Ok(rendered)
329}
330
331pub(crate) fn render_stdlib_prompt_asset(
332    path: &str,
333    bindings: Option<&BTreeMap<String, VmValue>>,
334) -> Result<String, VmError> {
335    let target = if path.starts_with("std/") {
336        path.to_string()
337    } else {
338        format!("std/{path}")
339    };
340    let asset = TemplateAsset::render_target(&target).map_err(VmError::Runtime)?;
341    render_asset_result(&asset, bindings).map_err(VmError::from)
342}
343
344pub(crate) fn render_asset_with_provenance_result(
345    asset: &TemplateAsset,
346    bindings: Option<&BTreeMap<String, VmValue>>,
347    collect_provenance: bool,
348) -> Result<(String, Vec<PromptSourceSpan>), TemplateError> {
349    let nodes = parse_cached(asset)?;
350    let mut out = String::with_capacity(asset.source.len());
351    let mut scope = Scope::new(bindings);
352    let mut rc = RenderCtx {
353        current_asset: asset.clone(),
354        include_stack: Vec::new(),
355        current_include_parent: None,
356    };
357    let mut spans = if collect_provenance {
358        Some(Vec::new())
359    } else {
360        None
361    };
362    render_nodes(&nodes, &mut scope, &mut rc, &mut out, spans.as_mut()).map_err(|mut e| {
363        if e.path.is_none() {
364            e.path = asset.error_path();
365        }
366        if e.uri.is_none() {
367            e.uri = asset.error_uri();
368        }
369        e
370    })?;
371    Ok((out, spans.unwrap_or_default()))
372}