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