1use std::cell::RefCell;
26use std::collections::{BTreeMap, HashSet};
27use std::path::Path;
28use std::sync::{Mutex, OnceLock};
29
30use crate::value::VmError;
31
32mod assets;
33mod ast;
34mod error;
35mod expr_parser;
36pub mod filters;
37mod lexer;
38pub mod lint;
39pub(crate) mod llm_context;
40pub mod outline;
41mod parser;
42mod render;
43mod sections;
44pub mod vocabulary;
45
46#[cfg(test)]
47mod tests;
48
49use assets::parse_cached;
50pub(crate) use assets::TemplateAsset;
51use error::TemplateError;
52pub use error::TemplateParseError;
53pub use llm_context::{
54 current_llm_render_context, pop_llm_render_context, push_llm_render_context, LlmRenderContext,
55 LlmRenderContextGuard,
56};
57use render::{render_nodes, RenderCtx, Scope};
58
59thread_local! {
65 static PROMPT_REGISTRY: RefCell<Vec<RegisteredPrompt>> = const { RefCell::new(Vec::new()) };
66 static PROMPT_RENDER_INDICES: RefCell<BTreeMap<String, Vec<u64>>> =
72 const { RefCell::new(BTreeMap::new()) };
73 static PROMPT_RENDER_ORDINAL: RefCell<u64> = const { RefCell::new(0) };
77}
78
79const PROMPT_REGISTRY_CAP: usize = 64;
80
81#[derive(Debug, Clone)]
82pub struct RegisteredPrompt {
83 pub prompt_id: String,
84 pub template_uri: String,
85 pub rendered: String,
86 pub spans: Vec<PromptSourceSpan>,
87}
88
89pub(crate) fn register_prompt(
94 template_uri: String,
95 rendered: String,
96 spans: Vec<PromptSourceSpan>,
97) -> String {
98 let prompt_id = format!("prompt-{}", next_prompt_serial());
99 PROMPT_REGISTRY.with(|reg| {
100 let mut reg = reg.borrow_mut();
101 if reg.len() >= PROMPT_REGISTRY_CAP {
102 reg.remove(0);
103 }
104 reg.push(RegisteredPrompt {
105 prompt_id: prompt_id.clone(),
106 template_uri,
107 rendered,
108 spans,
109 });
110 });
111 prompt_id
112}
113
114thread_local! {
115 static PROMPT_SERIAL: RefCell<u64> = const { RefCell::new(0) };
116}
117
118fn next_prompt_serial() -> u64 {
119 PROMPT_SERIAL.with(|s| {
120 let mut s = s.borrow_mut();
121 *s += 1;
122 *s
123 })
124}
125
126pub fn lookup_prompt_span(
132 prompt_id: &str,
133 output_offset: usize,
134) -> Option<(String, PromptSourceSpan)> {
135 PROMPT_REGISTRY.with(|reg| {
136 let reg = reg.borrow();
137 let entry = reg.iter().find(|p| p.prompt_id == prompt_id)?;
138 let best = entry
139 .spans
140 .iter()
141 .filter(|s| {
142 output_offset >= s.output_start
143 && output_offset < s.output_end.max(s.output_start + 1)
144 })
145 .min_by_key(|s| {
146 let width = s.output_end.saturating_sub(s.output_start);
147 let kind_weight = match s.kind {
148 PromptSpanKind::Expr => 0,
149 PromptSpanKind::LegacyBareInterp => 1,
150 PromptSpanKind::Text => 2,
151 PromptSpanKind::Section => 3,
152 PromptSpanKind::Include => 4,
153 PromptSpanKind::ForIteration => 5,
154 PromptSpanKind::If => 6,
155 };
156 (kind_weight, width)
157 })?
158 .clone();
159 Some((entry.template_uri.clone(), best))
160 })
161}
162
163pub fn lookup_prompt_consumers(
167 template_uri: &str,
168 template_line_start: usize,
169 template_line_end: usize,
170) -> Vec<(String, PromptSourceSpan)> {
171 PROMPT_REGISTRY.with(|reg| {
172 let reg = reg.borrow();
173 reg.iter()
174 .flat_map(|p| {
175 let prompt_id = p.prompt_id.clone();
176 p.spans
177 .iter()
178 .filter(move |s| {
179 let line = s.template_line;
180 s.template_uri == template_uri
181 && line > 0
182 && line >= template_line_start
183 && line <= template_line_end
184 })
185 .cloned()
186 .map(move |s| (prompt_id.clone(), s))
187 })
188 .collect()
189 })
190}
191
192pub fn record_prompt_render_index(prompt_id: &str, event_index: u64) {
197 PROMPT_RENDER_INDICES.with(|map| {
198 map.borrow_mut()
199 .entry(prompt_id.to_string())
200 .or_default()
201 .push(event_index);
202 });
203}
204
205pub fn next_prompt_render_ordinal() -> u64 {
211 PROMPT_RENDER_ORDINAL.with(|c| {
212 let mut n = c.borrow_mut();
213 *n += 1;
214 *n
215 })
216}
217
218pub fn prompt_render_indices(prompt_id: &str) -> Vec<u64> {
222 PROMPT_RENDER_INDICES.with(|map| map.borrow().get(prompt_id).cloned().unwrap_or_default())
223}
224
225pub(crate) fn reset_prompt_registry() {
228 PROMPT_REGISTRY.with(|reg| reg.borrow_mut().clear());
229 PROMPT_SERIAL.with(|s| *s.borrow_mut() = 0);
230 PROMPT_RENDER_INDICES.with(|map| map.borrow_mut().clear());
231 PROMPT_RENDER_ORDINAL.with(|c| *c.borrow_mut() = 0);
232 llm_context::reset_llm_render_stack();
233 if let Some(cache) = LLM_SHADOW_WARN_CACHE.get() {
234 if let Ok(mut g) = cache.lock() {
235 g.clear();
236 }
237 }
238}
239
240static LLM_SHADOW_WARN_CACHE: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
244
245fn augment_bindings_with_llm(
252 asset: &TemplateAsset,
253 bindings: Option<&crate::value::DictMap>,
254) -> Option<crate::value::DictMap> {
255 let ctx = current_llm_render_context()?;
256 if bindings.is_some_and(|m| m.contains_key("llm")) {
257 warn_user_llm_shadowed(asset);
258 return None;
259 }
260 let mut merged = bindings.cloned().unwrap_or_default();
261 merged.insert(crate::value::intern_key("llm"), ctx.to_vm_value());
262 Some(merged)
263}
264
265fn warn_user_llm_shadowed(asset: &TemplateAsset) {
266 let cache = LLM_SHADOW_WARN_CACHE.get_or_init(|| Mutex::new(HashSet::new()));
267 let key = asset.uri.clone();
268 {
269 let mut guard = match cache.lock() {
270 Ok(g) => g,
271 Err(_) => return,
272 };
273 if !guard.insert(key.clone()) {
274 return;
275 }
276 }
277 crate::events::log_warn_meta(
278 "template.llm_scope",
279 "user-supplied `llm` binding shadows auto-injected LLM render context; \
280 rename your key to avoid relying on this back-compat path",
281 BTreeMap::from([
282 ("template_uri".to_string(), serde_json::Value::String(key)),
283 (
284 "reason".to_string(),
285 serde_json::Value::String("user_binding_shadowed".to_string()),
286 ),
287 ]),
288 );
289}
290
291pub fn validate_template_syntax(src: &str) -> Result<(), String> {
296 parser::parse(src).map(|_| ()).map_err(|e| e.message())
297}
298
299pub(crate) fn render_template_result(
303 template: &str,
304 bindings: Option<&crate::value::DictMap>,
305 base: Option<&Path>,
306 source_path: Option<&Path>,
307) -> Result<String, TemplateError> {
308 let (rendered, _spans) =
309 render_template_with_provenance(template, bindings, base, source_path, false)?;
310 Ok(rendered)
311}
312
313pub fn render_template_to_string(
316 template: &str,
317 bindings: Option<&crate::value::DictMap>,
318 base: Option<&Path>,
319 source_path: Option<&Path>,
320) -> Result<String, String> {
321 render_template_result(template, bindings, base, source_path).map_err(|error| error.message())
322}
323
324#[derive(Debug, Clone)]
336pub struct PromptSourceSpan {
337 pub template_line: usize,
338 pub template_col: usize,
339 pub output_start: usize,
340 pub output_end: usize,
341 pub kind: PromptSpanKind,
342 pub bound_value: Option<String>,
343 pub parent_span: Option<Box<PromptSourceSpan>>,
349 pub template_uri: String,
356}
357
358#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
365pub struct BranchDecision {
366 pub kind: BranchKind,
367 pub template_uri: String,
368 pub line: usize,
369 pub col: usize,
370 pub branch_id: String,
376 pub branch_label: Option<String>,
380}
381
382#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
383#[serde(rename_all = "snake_case")]
384pub enum BranchKind {
385 If,
386 Section,
387}
388
389impl BranchKind {
390 pub fn as_str(self) -> &'static str {
391 match self {
392 BranchKind::If => "if",
393 BranchKind::Section => "section",
394 }
395 }
396}
397
398#[derive(Debug, Clone, Copy, PartialEq, Eq)]
399pub enum PromptSpanKind {
400 Text,
402 Expr,
405 LegacyBareInterp,
408 If,
410 ForIteration,
412 Include,
415 Section,
417}
418
419pub(crate) fn render_template_with_provenance(
425 template: &str,
426 bindings: Option<&crate::value::DictMap>,
427 base: Option<&Path>,
428 source_path: Option<&Path>,
429 collect_provenance: bool,
430) -> Result<(String, Vec<PromptSourceSpan>), TemplateError> {
431 let asset = TemplateAsset::inline(template, base, source_path);
432 render_asset_with_provenance_result(&asset, bindings, collect_provenance)
433}
434
435pub(crate) fn render_asset_result(
436 asset: &TemplateAsset,
437 bindings: Option<&crate::value::DictMap>,
438) -> Result<String, TemplateError> {
439 let (rendered, _spans) = render_asset_with_provenance_result(asset, bindings, false)?;
440 Ok(rendered)
441}
442
443pub(crate) fn render_stdlib_prompt_asset(
444 path: &str,
445 bindings: Option<&crate::value::DictMap>,
446) -> Result<String, VmError> {
447 let target = if path.starts_with("std/") {
448 path.to_string()
449 } else {
450 format!("std/{path}")
451 };
452 let asset = TemplateAsset::render_target(&target).map_err(VmError::Runtime)?;
453 render_asset_result(&asset, bindings).map_err(VmError::from)
454}
455
456#[cfg(test)]
463pub(crate) fn render_template_collect_branch_trace(
464 template: &str,
465) -> Result<(String, Vec<BranchDecision>), TemplateError> {
466 let asset = TemplateAsset::inline(template, None, None);
467 render_asset_with_provenance_and_trace_result(&asset, None, false, true)
468 .map(|(rendered, _spans, trace)| (rendered, trace))
469}
470
471pub(crate) fn render_asset_with_provenance_result(
472 asset: &TemplateAsset,
473 bindings: Option<&crate::value::DictMap>,
474 collect_provenance: bool,
475) -> Result<(String, Vec<PromptSourceSpan>), TemplateError> {
476 let (rendered, spans, _trace) =
477 render_asset_with_provenance_and_trace_result(asset, bindings, collect_provenance, false)?;
478 Ok((rendered, spans))
479}
480
481fn render_asset_with_provenance_and_trace_result(
482 asset: &TemplateAsset,
483 bindings: Option<&crate::value::DictMap>,
484 collect_provenance: bool,
485 force_branch_trace: bool,
486) -> Result<(String, Vec<PromptSourceSpan>, Vec<BranchDecision>), TemplateError> {
487 let nodes = parse_cached(asset)?;
488 let mut out = String::with_capacity(asset.source.len());
489 let augmented = augment_bindings_with_llm(asset, bindings);
495 let scope_bindings = augmented.as_ref().or(bindings);
496 let mut scope = Scope::new(scope_bindings);
497 let llm_ctx = current_llm_render_context();
502 let mut rc = RenderCtx {
503 current_asset: asset.clone(),
504 include_stack: Vec::new(),
505 current_include_parent: None,
506 branch_trace: (force_branch_trace || llm_ctx.is_some()).then(Vec::new),
507 };
508 let mut spans = if collect_provenance {
509 Some(Vec::new())
510 } else {
511 None
512 };
513 render_nodes(&nodes, &mut scope, &mut rc, &mut out, spans.as_mut()).map_err(|mut e| {
514 if e.path.is_none() {
515 e.path = asset.error_path();
516 }
517 if e.uri.is_none() {
518 e.uri = asset.error_uri();
519 }
520 e
521 })?;
522 let trace = rc.branch_trace.take().unwrap_or_default();
523 if let Some(ctx) = llm_ctx {
524 emit_template_render_event(asset, &ctx, &trace, out.len());
525 }
526 Ok((out, spans.unwrap_or_default(), trace))
527}
528
529pub fn render_template_to_string_with_branch_trace(
534 template: &str,
535 bindings: Option<&crate::value::DictMap>,
536 base: Option<&Path>,
537 source_path: Option<&Path>,
538) -> Result<(String, Vec<BranchDecision>), String> {
539 let asset = TemplateAsset::inline(template, base, source_path);
540 render_asset_with_provenance_and_trace_result(&asset, bindings, false, true)
541 .map(|(rendered, _spans, trace)| (rendered, trace))
542 .map_err(|error| error.message())
543}
544
545fn emit_template_render_event(
550 asset: &TemplateAsset,
551 ctx: &LlmRenderContext,
552 trace: &[BranchDecision],
553 rendered_bytes: usize,
554) {
555 crate::llm::agent_observe::record_template_render(
556 &asset.uri,
557 asset.template_revision_hash().as_str(),
558 ctx,
559 trace,
560 rendered_bytes,
561 );
562}