use std::cell::RefCell;
use std::collections::{BTreeMap, HashSet};
use std::path::Path;
use std::sync::{Mutex, OnceLock};
use crate::value::{VmError, VmValue};
mod assets;
mod ast;
mod error;
mod expr_parser;
mod filters;
mod lexer;
pub mod lint;
mod llm_context;
mod parser;
mod render;
mod sections;
#[cfg(test)]
mod tests;
use assets::parse_cached;
pub(crate) use assets::TemplateAsset;
use error::TemplateError;
pub use llm_context::{
current_llm_render_context, pop_llm_render_context, push_llm_render_context, LlmRenderContext,
LlmRenderContextGuard,
};
use render::{render_nodes, RenderCtx, Scope};
thread_local! {
static PROMPT_REGISTRY: RefCell<Vec<RegisteredPrompt>> = const { RefCell::new(Vec::new()) };
static PROMPT_RENDER_INDICES: RefCell<BTreeMap<String, Vec<u64>>> =
const { RefCell::new(BTreeMap::new()) };
static PROMPT_RENDER_ORDINAL: RefCell<u64> = const { RefCell::new(0) };
}
const PROMPT_REGISTRY_CAP: usize = 64;
#[derive(Debug, Clone)]
pub struct RegisteredPrompt {
pub prompt_id: String,
pub template_uri: String,
pub rendered: String,
pub spans: Vec<PromptSourceSpan>,
}
pub(crate) fn register_prompt(
template_uri: String,
rendered: String,
spans: Vec<PromptSourceSpan>,
) -> String {
let prompt_id = format!("prompt-{}", next_prompt_serial());
PROMPT_REGISTRY.with(|reg| {
let mut reg = reg.borrow_mut();
if reg.len() >= PROMPT_REGISTRY_CAP {
reg.remove(0);
}
reg.push(RegisteredPrompt {
prompt_id: prompt_id.clone(),
template_uri,
rendered,
spans,
});
});
prompt_id
}
thread_local! {
static PROMPT_SERIAL: RefCell<u64> = const { RefCell::new(0) };
}
fn next_prompt_serial() -> u64 {
PROMPT_SERIAL.with(|s| {
let mut s = s.borrow_mut();
*s += 1;
*s
})
}
pub fn lookup_prompt_span(
prompt_id: &str,
output_offset: usize,
) -> Option<(String, PromptSourceSpan)> {
PROMPT_REGISTRY.with(|reg| {
let reg = reg.borrow();
let entry = reg.iter().find(|p| p.prompt_id == prompt_id)?;
let best = entry
.spans
.iter()
.filter(|s| {
output_offset >= s.output_start
&& output_offset < s.output_end.max(s.output_start + 1)
})
.min_by_key(|s| {
let width = s.output_end.saturating_sub(s.output_start);
let kind_weight = match s.kind {
PromptSpanKind::Expr => 0,
PromptSpanKind::LegacyBareInterp => 1,
PromptSpanKind::Text => 2,
PromptSpanKind::Section => 3,
PromptSpanKind::Include => 4,
PromptSpanKind::ForIteration => 5,
PromptSpanKind::If => 6,
};
(kind_weight, width)
})?
.clone();
Some((entry.template_uri.clone(), best))
})
}
pub fn lookup_prompt_consumers(
template_uri: &str,
template_line_start: usize,
template_line_end: usize,
) -> Vec<(String, PromptSourceSpan)> {
PROMPT_REGISTRY.with(|reg| {
let reg = reg.borrow();
reg.iter()
.flat_map(|p| {
let prompt_id = p.prompt_id.clone();
p.spans
.iter()
.filter(move |s| {
let line = s.template_line;
s.template_uri == template_uri
&& line > 0
&& line >= template_line_start
&& line <= template_line_end
})
.cloned()
.map(move |s| (prompt_id.clone(), s))
})
.collect()
})
}
pub fn record_prompt_render_index(prompt_id: &str, event_index: u64) {
PROMPT_RENDER_INDICES.with(|map| {
map.borrow_mut()
.entry(prompt_id.to_string())
.or_default()
.push(event_index);
});
}
pub fn next_prompt_render_ordinal() -> u64 {
PROMPT_RENDER_ORDINAL.with(|c| {
let mut n = c.borrow_mut();
*n += 1;
*n
})
}
pub fn prompt_render_indices(prompt_id: &str) -> Vec<u64> {
PROMPT_RENDER_INDICES.with(|map| map.borrow().get(prompt_id).cloned().unwrap_or_default())
}
pub(crate) fn reset_prompt_registry() {
PROMPT_REGISTRY.with(|reg| reg.borrow_mut().clear());
PROMPT_SERIAL.with(|s| *s.borrow_mut() = 0);
PROMPT_RENDER_INDICES.with(|map| map.borrow_mut().clear());
PROMPT_RENDER_ORDINAL.with(|c| *c.borrow_mut() = 0);
llm_context::reset_llm_render_stack();
if let Some(cache) = LLM_SHADOW_WARN_CACHE.get() {
if let Ok(mut g) = cache.lock() {
g.clear();
}
}
}
static LLM_SHADOW_WARN_CACHE: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
fn augment_bindings_with_llm(
asset: &TemplateAsset,
bindings: Option<&BTreeMap<String, VmValue>>,
) -> Option<BTreeMap<String, VmValue>> {
let ctx = current_llm_render_context()?;
if bindings.is_some_and(|m| m.contains_key("llm")) {
warn_user_llm_shadowed(asset);
return None;
}
let mut merged = bindings.cloned().unwrap_or_default();
merged.insert("llm".to_string(), ctx.to_vm_value());
Some(merged)
}
fn warn_user_llm_shadowed(asset: &TemplateAsset) {
let cache = LLM_SHADOW_WARN_CACHE.get_or_init(|| Mutex::new(HashSet::new()));
let key = asset.uri.clone();
{
let mut guard = match cache.lock() {
Ok(g) => g,
Err(_) => return,
};
if !guard.insert(key.clone()) {
return;
}
}
crate::events::log_warn_meta(
"template.llm_scope",
"user-supplied `llm` binding shadows auto-injected LLM render context; \
rename your key to avoid relying on this back-compat path",
BTreeMap::from([
("template_uri".to_string(), serde_json::Value::String(key)),
(
"reason".to_string(),
serde_json::Value::String("user_binding_shadowed".to_string()),
),
]),
);
}
pub fn validate_template_syntax(src: &str) -> Result<(), String> {
parser::parse(src).map(|_| ()).map_err(|e| e.message())
}
pub(crate) fn render_template_result(
template: &str,
bindings: Option<&BTreeMap<String, VmValue>>,
base: Option<&Path>,
source_path: Option<&Path>,
) -> Result<String, TemplateError> {
let (rendered, _spans) =
render_template_with_provenance(template, bindings, base, source_path, false)?;
Ok(rendered)
}
pub fn render_template_to_string(
template: &str,
bindings: Option<&BTreeMap<String, VmValue>>,
base: Option<&Path>,
source_path: Option<&Path>,
) -> Result<String, String> {
render_template_result(template, bindings, base, source_path).map_err(|error| error.message())
}
#[derive(Debug, Clone)]
pub struct PromptSourceSpan {
pub template_line: usize,
pub template_col: usize,
pub output_start: usize,
pub output_end: usize,
pub kind: PromptSpanKind,
pub bound_value: Option<String>,
pub parent_span: Option<Box<PromptSourceSpan>>,
pub template_uri: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BranchDecision {
pub kind: BranchKind,
pub template_uri: String,
pub line: usize,
pub col: usize,
pub branch_id: String,
pub branch_label: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BranchKind {
If,
Section,
}
impl BranchKind {
pub fn as_str(self) -> &'static str {
match self {
BranchKind::If => "if",
BranchKind::Section => "section",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PromptSpanKind {
Text,
Expr,
LegacyBareInterp,
If,
ForIteration,
Include,
Section,
}
pub(crate) fn render_template_with_provenance(
template: &str,
bindings: Option<&BTreeMap<String, VmValue>>,
base: Option<&Path>,
source_path: Option<&Path>,
collect_provenance: bool,
) -> Result<(String, Vec<PromptSourceSpan>), TemplateError> {
let asset = TemplateAsset::inline(template, base, source_path);
render_asset_with_provenance_result(&asset, bindings, collect_provenance)
}
pub(crate) fn render_asset_result(
asset: &TemplateAsset,
bindings: Option<&BTreeMap<String, VmValue>>,
) -> Result<String, TemplateError> {
let (rendered, _spans) = render_asset_with_provenance_result(asset, bindings, false)?;
Ok(rendered)
}
pub(crate) fn render_stdlib_prompt_asset(
path: &str,
bindings: Option<&BTreeMap<String, VmValue>>,
) -> Result<String, VmError> {
let target = if path.starts_with("std/") {
path.to_string()
} else {
format!("std/{path}")
};
let asset = TemplateAsset::render_target(&target).map_err(VmError::Runtime)?;
render_asset_result(&asset, bindings).map_err(VmError::from)
}
#[cfg(test)]
pub(crate) fn render_template_collect_branch_trace(
template: &str,
) -> Result<(String, Vec<BranchDecision>), TemplateError> {
let asset = TemplateAsset::inline(template, None, None);
let nodes = parse_cached(&asset)?;
let mut out = String::with_capacity(asset.source.len());
let augmented = augment_bindings_with_llm(&asset, None);
let scope_bindings = augmented.as_ref();
let mut scope = Scope::new(scope_bindings);
let mut rc = RenderCtx {
current_asset: asset.clone(),
include_stack: Vec::new(),
current_include_parent: None,
branch_trace: Some(Vec::new()),
};
render_nodes(&nodes, &mut scope, &mut rc, &mut out, None)?;
Ok((out, rc.branch_trace.unwrap_or_default()))
}
pub(crate) fn render_asset_with_provenance_result(
asset: &TemplateAsset,
bindings: Option<&BTreeMap<String, VmValue>>,
collect_provenance: bool,
) -> Result<(String, Vec<PromptSourceSpan>), TemplateError> {
let nodes = parse_cached(asset)?;
let mut out = String::with_capacity(asset.source.len());
let augmented = augment_bindings_with_llm(asset, bindings);
let scope_bindings = augmented.as_ref().or(bindings);
let mut scope = Scope::new(scope_bindings);
let llm_ctx = current_llm_render_context();
let mut rc = RenderCtx {
current_asset: asset.clone(),
include_stack: Vec::new(),
current_include_parent: None,
branch_trace: llm_ctx.as_ref().map(|_| Vec::new()),
};
let mut spans = if collect_provenance {
Some(Vec::new())
} else {
None
};
render_nodes(&nodes, &mut scope, &mut rc, &mut out, spans.as_mut()).map_err(|mut e| {
if e.path.is_none() {
e.path = asset.error_path();
}
if e.uri.is_none() {
e.uri = asset.error_uri();
}
e
})?;
if let (Some(ctx), Some(trace)) = (llm_ctx, rc.branch_trace.take()) {
emit_template_render_event(asset, &ctx, &trace, out.len());
}
Ok((out, spans.unwrap_or_default()))
}
fn emit_template_render_event(
asset: &TemplateAsset,
ctx: &LlmRenderContext,
trace: &[BranchDecision],
rendered_bytes: usize,
) {
crate::llm::agent_observe::record_template_render(
&asset.uri,
asset.template_revision_hash().as_str(),
ctx,
trace,
rendered_bytes,
);
}