1use std::cell::RefCell;
26use std::collections::{BTreeMap, HashSet};
27use std::path::Path;
28use std::sync::{Mutex, OnceLock};
29
30use crate::value::{VmError, VmValue};
31
32mod assets;
33mod ast;
34mod error;
35mod expr_parser;
36mod filters;
37mod lexer;
38pub mod lint;
39mod llm_context;
40mod parser;
41mod render;
42mod sections;
43
44#[cfg(test)]
45mod tests;
46
47use assets::parse_cached;
48pub(crate) use assets::TemplateAsset;
49use error::TemplateError;
50pub use llm_context::{
51 current_llm_render_context, pop_llm_render_context, push_llm_render_context, LlmRenderContext,
52 LlmRenderContextGuard,
53};
54use render::{render_nodes, RenderCtx, Scope};
55
56thread_local! {
62 static PROMPT_REGISTRY: RefCell<Vec<RegisteredPrompt>> = const { RefCell::new(Vec::new()) };
63 static PROMPT_RENDER_INDICES: RefCell<BTreeMap<String, Vec<u64>>> =
69 const { RefCell::new(BTreeMap::new()) };
70 static PROMPT_RENDER_ORDINAL: RefCell<u64> = const { RefCell::new(0) };
74}
75
76const PROMPT_REGISTRY_CAP: usize = 64;
77
78#[derive(Debug, Clone)]
79pub struct RegisteredPrompt {
80 pub prompt_id: String,
81 pub template_uri: String,
82 pub rendered: String,
83 pub spans: Vec<PromptSourceSpan>,
84}
85
86pub(crate) fn register_prompt(
91 template_uri: String,
92 rendered: String,
93 spans: Vec<PromptSourceSpan>,
94) -> String {
95 let prompt_id = format!("prompt-{}", next_prompt_serial());
96 PROMPT_REGISTRY.with(|reg| {
97 let mut reg = reg.borrow_mut();
98 if reg.len() >= PROMPT_REGISTRY_CAP {
99 reg.remove(0);
100 }
101 reg.push(RegisteredPrompt {
102 prompt_id: prompt_id.clone(),
103 template_uri,
104 rendered,
105 spans,
106 });
107 });
108 prompt_id
109}
110
111thread_local! {
112 static PROMPT_SERIAL: RefCell<u64> = const { RefCell::new(0) };
113}
114
115fn next_prompt_serial() -> u64 {
116 PROMPT_SERIAL.with(|s| {
117 let mut s = s.borrow_mut();
118 *s += 1;
119 *s
120 })
121}
122
123pub fn lookup_prompt_span(
129 prompt_id: &str,
130 output_offset: usize,
131) -> Option<(String, PromptSourceSpan)> {
132 PROMPT_REGISTRY.with(|reg| {
133 let reg = reg.borrow();
134 let entry = reg.iter().find(|p| p.prompt_id == prompt_id)?;
135 let best = entry
136 .spans
137 .iter()
138 .filter(|s| {
139 output_offset >= s.output_start
140 && output_offset < s.output_end.max(s.output_start + 1)
141 })
142 .min_by_key(|s| {
143 let width = s.output_end.saturating_sub(s.output_start);
144 let kind_weight = match s.kind {
145 PromptSpanKind::Expr => 0,
146 PromptSpanKind::LegacyBareInterp => 1,
147 PromptSpanKind::Text => 2,
148 PromptSpanKind::Section => 3,
149 PromptSpanKind::Include => 4,
150 PromptSpanKind::ForIteration => 5,
151 PromptSpanKind::If => 6,
152 };
153 (kind_weight, width)
154 })?
155 .clone();
156 Some((entry.template_uri.clone(), best))
157 })
158}
159
160pub fn lookup_prompt_consumers(
164 template_uri: &str,
165 template_line_start: usize,
166 template_line_end: usize,
167) -> Vec<(String, PromptSourceSpan)> {
168 PROMPT_REGISTRY.with(|reg| {
169 let reg = reg.borrow();
170 reg.iter()
171 .flat_map(|p| {
172 let prompt_id = p.prompt_id.clone();
173 p.spans
174 .iter()
175 .filter(move |s| {
176 let line = s.template_line;
177 s.template_uri == template_uri
178 && line > 0
179 && line >= template_line_start
180 && line <= template_line_end
181 })
182 .cloned()
183 .map(move |s| (prompt_id.clone(), s))
184 })
185 .collect()
186 })
187}
188
189pub fn record_prompt_render_index(prompt_id: &str, event_index: u64) {
194 PROMPT_RENDER_INDICES.with(|map| {
195 map.borrow_mut()
196 .entry(prompt_id.to_string())
197 .or_default()
198 .push(event_index);
199 });
200}
201
202pub fn next_prompt_render_ordinal() -> u64 {
208 PROMPT_RENDER_ORDINAL.with(|c| {
209 let mut n = c.borrow_mut();
210 *n += 1;
211 *n
212 })
213}
214
215pub fn prompt_render_indices(prompt_id: &str) -> Vec<u64> {
219 PROMPT_RENDER_INDICES.with(|map| map.borrow().get(prompt_id).cloned().unwrap_or_default())
220}
221
222pub(crate) fn reset_prompt_registry() {
225 PROMPT_REGISTRY.with(|reg| reg.borrow_mut().clear());
226 PROMPT_SERIAL.with(|s| *s.borrow_mut() = 0);
227 PROMPT_RENDER_INDICES.with(|map| map.borrow_mut().clear());
228 PROMPT_RENDER_ORDINAL.with(|c| *c.borrow_mut() = 0);
229 llm_context::reset_llm_render_stack();
230 if let Some(cache) = LLM_SHADOW_WARN_CACHE.get() {
231 if let Ok(mut g) = cache.lock() {
232 g.clear();
233 }
234 }
235}
236
237static LLM_SHADOW_WARN_CACHE: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
241
242fn augment_bindings_with_llm(
249 asset: &TemplateAsset,
250 bindings: Option<&BTreeMap<String, VmValue>>,
251) -> Option<BTreeMap<String, VmValue>> {
252 let ctx = current_llm_render_context()?;
253 if bindings.is_some_and(|m| m.contains_key("llm")) {
254 warn_user_llm_shadowed(asset);
255 return None;
256 }
257 let mut merged = bindings.cloned().unwrap_or_default();
258 merged.insert("llm".to_string(), ctx.to_vm_value());
259 Some(merged)
260}
261
262fn warn_user_llm_shadowed(asset: &TemplateAsset) {
263 let cache = LLM_SHADOW_WARN_CACHE.get_or_init(|| Mutex::new(HashSet::new()));
264 let key = asset.uri.clone();
265 {
266 let mut guard = match cache.lock() {
267 Ok(g) => g,
268 Err(_) => return,
269 };
270 if !guard.insert(key.clone()) {
271 return;
272 }
273 }
274 crate::events::log_warn_meta(
275 "template.llm_scope",
276 "user-supplied `llm` binding shadows auto-injected LLM render context; \
277 rename your key to avoid relying on this back-compat path",
278 BTreeMap::from([
279 ("template_uri".to_string(), serde_json::Value::String(key)),
280 (
281 "reason".to_string(),
282 serde_json::Value::String("user_binding_shadowed".to_string()),
283 ),
284 ]),
285 );
286}
287
288pub fn validate_template_syntax(src: &str) -> Result<(), String> {
293 parser::parse(src).map(|_| ()).map_err(|e| e.message())
294}
295
296pub(crate) fn render_template_result(
300 template: &str,
301 bindings: Option<&BTreeMap<String, VmValue>>,
302 base: Option<&Path>,
303 source_path: Option<&Path>,
304) -> Result<String, TemplateError> {
305 let (rendered, _spans) =
306 render_template_with_provenance(template, bindings, base, source_path, false)?;
307 Ok(rendered)
308}
309
310pub fn render_template_to_string(
313 template: &str,
314 bindings: Option<&BTreeMap<String, VmValue>>,
315 base: Option<&Path>,
316 source_path: Option<&Path>,
317) -> Result<String, String> {
318 render_template_result(template, bindings, base, source_path).map_err(|error| error.message())
319}
320
321#[derive(Debug, Clone)]
333pub struct PromptSourceSpan {
334 pub template_line: usize,
335 pub template_col: usize,
336 pub output_start: usize,
337 pub output_end: usize,
338 pub kind: PromptSpanKind,
339 pub bound_value: Option<String>,
340 pub parent_span: Option<Box<PromptSourceSpan>>,
346 pub template_uri: String,
353}
354
355#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
362pub struct BranchDecision {
363 pub kind: BranchKind,
364 pub template_uri: String,
365 pub line: usize,
366 pub col: usize,
367 pub branch_id: String,
373 pub branch_label: Option<String>,
377}
378
379#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
380#[serde(rename_all = "snake_case")]
381pub enum BranchKind {
382 If,
383 Section,
384}
385
386impl BranchKind {
387 pub fn as_str(self) -> &'static str {
388 match self {
389 BranchKind::If => "if",
390 BranchKind::Section => "section",
391 }
392 }
393}
394
395#[derive(Debug, Clone, Copy, PartialEq, Eq)]
396pub enum PromptSpanKind {
397 Text,
399 Expr,
402 LegacyBareInterp,
405 If,
407 ForIteration,
409 Include,
412 Section,
414}
415
416pub(crate) fn render_template_with_provenance(
422 template: &str,
423 bindings: Option<&BTreeMap<String, VmValue>>,
424 base: Option<&Path>,
425 source_path: Option<&Path>,
426 collect_provenance: bool,
427) -> Result<(String, Vec<PromptSourceSpan>), TemplateError> {
428 let asset = TemplateAsset::inline(template, base, source_path);
429 render_asset_with_provenance_result(&asset, bindings, collect_provenance)
430}
431
432pub(crate) fn render_asset_result(
433 asset: &TemplateAsset,
434 bindings: Option<&BTreeMap<String, VmValue>>,
435) -> Result<String, TemplateError> {
436 let (rendered, _spans) = render_asset_with_provenance_result(asset, bindings, false)?;
437 Ok(rendered)
438}
439
440pub(crate) fn render_stdlib_prompt_asset(
441 path: &str,
442 bindings: Option<&BTreeMap<String, VmValue>>,
443) -> Result<String, VmError> {
444 let target = if path.starts_with("std/") {
445 path.to_string()
446 } else {
447 format!("std/{path}")
448 };
449 let asset = TemplateAsset::render_target(&target).map_err(VmError::Runtime)?;
450 render_asset_result(&asset, bindings).map_err(VmError::from)
451}
452
453#[cfg(test)]
460pub(crate) fn render_template_collect_branch_trace(
461 template: &str,
462) -> Result<(String, Vec<BranchDecision>), TemplateError> {
463 let asset = TemplateAsset::inline(template, None, None);
464 render_asset_with_provenance_and_trace_result(&asset, None, false, true)
465 .map(|(rendered, _spans, trace)| (rendered, trace))
466}
467
468pub(crate) fn render_asset_with_provenance_result(
469 asset: &TemplateAsset,
470 bindings: Option<&BTreeMap<String, VmValue>>,
471 collect_provenance: bool,
472) -> Result<(String, Vec<PromptSourceSpan>), TemplateError> {
473 let (rendered, spans, _trace) =
474 render_asset_with_provenance_and_trace_result(asset, bindings, collect_provenance, false)?;
475 Ok((rendered, spans))
476}
477
478fn render_asset_with_provenance_and_trace_result(
479 asset: &TemplateAsset,
480 bindings: Option<&BTreeMap<String, VmValue>>,
481 collect_provenance: bool,
482 force_branch_trace: bool,
483) -> Result<(String, Vec<PromptSourceSpan>, Vec<BranchDecision>), TemplateError> {
484 let nodes = parse_cached(asset)?;
485 let mut out = String::with_capacity(asset.source.len());
486 let augmented = augment_bindings_with_llm(asset, bindings);
492 let scope_bindings = augmented.as_ref().or(bindings);
493 let mut scope = Scope::new(scope_bindings);
494 let llm_ctx = current_llm_render_context();
499 let mut rc = RenderCtx {
500 current_asset: asset.clone(),
501 include_stack: Vec::new(),
502 current_include_parent: None,
503 branch_trace: (force_branch_trace || llm_ctx.is_some()).then(Vec::new),
504 };
505 let mut spans = if collect_provenance {
506 Some(Vec::new())
507 } else {
508 None
509 };
510 render_nodes(&nodes, &mut scope, &mut rc, &mut out, spans.as_mut()).map_err(|mut e| {
511 if e.path.is_none() {
512 e.path = asset.error_path();
513 }
514 if e.uri.is_none() {
515 e.uri = asset.error_uri();
516 }
517 e
518 })?;
519 let trace = rc.branch_trace.take().unwrap_or_default();
520 if let Some(ctx) = llm_ctx {
521 emit_template_render_event(asset, &ctx, &trace, out.len());
522 }
523 Ok((out, spans.unwrap_or_default(), trace))
524}
525
526pub fn render_template_to_string_with_branch_trace(
531 template: &str,
532 bindings: Option<&BTreeMap<String, VmValue>>,
533 base: Option<&Path>,
534 source_path: Option<&Path>,
535) -> Result<(String, Vec<BranchDecision>), String> {
536 let asset = TemplateAsset::inline(template, base, source_path);
537 render_asset_with_provenance_and_trace_result(&asset, bindings, false, true)
538 .map(|(rendered, _spans, trace)| (rendered, trace))
539 .map_err(|error| error.message())
540}
541
542fn emit_template_render_event(
547 asset: &TemplateAsset,
548 ctx: &LlmRenderContext,
549 trace: &[BranchDecision],
550 rendered_bytes: usize,
551) {
552 crate::llm::agent_observe::record_template_render(
553 &asset.uri,
554 asset.template_revision_hash().as_str(),
555 ctx,
556 trace,
557 rendered_bytes,
558 );
559}