Skip to content

Commit d9cb8d0

Browse files
feat: add template validation using askama_parser (#3940)
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 9344da5 commit d9cb8d0

File tree

17 files changed

+1404
-37
lines changed

17 files changed

+1404
-37
lines changed

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,7 @@ sentry = "=0.42.0"
272272
vergen-gix = "1"
273273

274274
askama = "0.15"
275+
askama_parser = "0.15"
275276
markdown = "1"
276277
mdast_util_to_markdown = "0.0.2"
277278
minijinja = "2.7.0"

apps/api/openapi.gen.json

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -729,6 +729,15 @@
729729
"status": {
730730
"$ref": "#/components/schemas/PipelineStatus"
731731
},
732+
"tokens": {
733+
"type": [
734+
"array",
735+
"null"
736+
],
737+
"items": {
738+
"$ref": "#/components/schemas/TranscriptToken"
739+
}
740+
},
732741
"transcript": {
733742
"type": [
734743
"string",
@@ -737,6 +746,37 @@
737746
}
738747
}
739748
},
749+
"TranscriptToken": {
750+
"type": "object",
751+
"required": [
752+
"text",
753+
"startMs",
754+
"endMs"
755+
],
756+
"properties": {
757+
"endMs": {
758+
"type": "integer",
759+
"format": "int64",
760+
"minimum": 0
761+
},
762+
"speaker": {
763+
"type": [
764+
"integer",
765+
"null"
766+
],
767+
"format": "int32",
768+
"minimum": 0
769+
},
770+
"startMs": {
771+
"type": "integer",
772+
"format": "int64",
773+
"minimum": 0
774+
},
775+
"text": {
776+
"type": "string"
777+
}
778+
}
779+
},
740780
"WebhookResponse": {
741781
"type": "object",
742782
"required": [

crates/askama-utils/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ edition = "2024"
55

66
[dependencies]
77
askama = { workspace = true }
8+
askama_parser = { workspace = true }
89

910
[dev-dependencies]
1011
insta = { workspace = true }

crates/askama-utils/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
mod validate;
2+
pub use validate::{TemplateUsage, extract};
3+
14
#[macro_export]
25
macro_rules! tpl_snapshot {
36
($name:ident, $input:expr, @$($expected:tt)*) => {
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
use std::collections::HashSet;
2+
3+
use askama_parser::{Ast, Expr, Node, Syntax};
4+
5+
pub struct TemplateUsage {
6+
pub variables: HashSet<String>,
7+
pub filters: HashSet<String>,
8+
}
9+
10+
pub fn extract(src: &str) -> Result<TemplateUsage, askama_parser::ParseError> {
11+
let syntax = Syntax::default();
12+
let ast = Ast::from_str(src, None, &syntax)?;
13+
14+
let mut usage = TemplateUsage {
15+
variables: HashSet::new(),
16+
filters: HashSet::new(),
17+
};
18+
19+
for node in ast.nodes() {
20+
visit_node(node, &mut usage);
21+
}
22+
23+
Ok(usage)
24+
}
25+
26+
fn visit_node(node: &Node<'_>, usage: &mut TemplateUsage) {
27+
match node {
28+
Node::Expr(_, expr) => visit_expr(expr, usage),
29+
Node::If(if_node) => {
30+
for branch in &if_node.branches {
31+
if let Some(cond) = &branch.cond {
32+
visit_expr(&cond.expr, usage);
33+
}
34+
for n in &branch.nodes {
35+
visit_node(n, usage);
36+
}
37+
}
38+
}
39+
Node::Loop(loop_node) => {
40+
visit_expr(&loop_node.iter, usage);
41+
if let Some(cond) = &loop_node.cond {
42+
visit_expr(cond, usage);
43+
}
44+
for n in &loop_node.body {
45+
visit_node(n, usage);
46+
}
47+
for n in &loop_node.else_nodes {
48+
visit_node(n, usage);
49+
}
50+
}
51+
Node::Match(match_node) => {
52+
visit_expr(&match_node.expr, usage);
53+
for arm in &match_node.arms {
54+
for n in &arm.nodes {
55+
visit_node(n, usage);
56+
}
57+
}
58+
}
59+
Node::Let(let_node) => {
60+
if let Some(val) = &let_node.val {
61+
visit_expr(val, usage);
62+
}
63+
}
64+
Node::Call(call_node) => {
65+
if let Some(args) = &call_node.args {
66+
for arg in args {
67+
visit_expr(arg, usage);
68+
}
69+
}
70+
for n in &call_node.nodes {
71+
visit_node(n, usage);
72+
}
73+
}
74+
Node::FilterBlock(fb) => {
75+
visit_filter(&fb.filters, usage);
76+
for n in &fb.nodes {
77+
visit_node(n, usage);
78+
}
79+
}
80+
Node::BlockDef(block) => {
81+
for n in &block.nodes {
82+
visit_node(n, usage);
83+
}
84+
}
85+
Node::Macro(m) => {
86+
for n in &m.nodes {
87+
visit_node(n, usage);
88+
}
89+
}
90+
Node::Lit(_)
91+
| Node::Comment(_)
92+
| Node::Raw(_)
93+
| Node::Break(_)
94+
| Node::Continue(_)
95+
| Node::Declare(_)
96+
| Node::Extends(_)
97+
| Node::Include(_)
98+
| Node::Import(_) => {}
99+
}
100+
}
101+
102+
fn visit_filter(filter: &askama_parser::Filter<'_>, usage: &mut TemplateUsage) {
103+
if let askama_parser::PathOrIdentifier::Identifier(ident) = &filter.name {
104+
let name: &str = ident;
105+
usage.filters.insert(name.to_string());
106+
}
107+
for arg in &filter.arguments {
108+
visit_expr(arg, usage);
109+
}
110+
}
111+
112+
fn visit_expr(expr: &Expr<'_>, usage: &mut TemplateUsage) {
113+
match expr {
114+
Expr::Var(name) => {
115+
usage.variables.insert(name.to_string());
116+
}
117+
Expr::Filter(filter) => {
118+
visit_filter(filter, usage);
119+
}
120+
Expr::BinOp(binop) => {
121+
visit_expr(&binop.lhs, usage);
122+
visit_expr(&binop.rhs, usage);
123+
}
124+
Expr::Unary(_, expr) => visit_expr(expr, usage),
125+
Expr::Call(call) => {
126+
visit_expr(&call.path, usage);
127+
for arg in &call.args {
128+
visit_expr(arg, usage);
129+
}
130+
}
131+
Expr::Index(obj, idx) => {
132+
visit_expr(obj, usage);
133+
visit_expr(idx, usage);
134+
}
135+
Expr::Group(expr) => visit_expr(expr, usage),
136+
Expr::Tuple(exprs) | Expr::Array(exprs) | Expr::Concat(exprs) => {
137+
for e in exprs {
138+
visit_expr(e, usage);
139+
}
140+
}
141+
Expr::ArrayRepeat(val, len) => {
142+
visit_expr(val, usage);
143+
visit_expr(len, usage);
144+
}
145+
Expr::Range(range) => {
146+
if let Some(lhs) = &range.lhs {
147+
visit_expr(lhs, usage);
148+
}
149+
if let Some(rhs) = &range.rhs {
150+
visit_expr(rhs, usage);
151+
}
152+
}
153+
Expr::As(expr, _) => visit_expr(expr, usage),
154+
Expr::NamedArgument(_, expr) => visit_expr(expr, usage),
155+
Expr::Try(expr) => visit_expr(expr, usage),
156+
Expr::AssociatedItem(expr, _) => visit_expr(expr, usage),
157+
Expr::Struct(s) => {
158+
for field in &s.fields {
159+
if let Some(val) = &field.value {
160+
visit_expr(val, usage);
161+
}
162+
}
163+
}
164+
Expr::LetCond(cond_test) => {
165+
visit_expr(&cond_test.expr, usage);
166+
}
167+
Expr::BoolLit(_)
168+
| Expr::NumLit(_, _)
169+
| Expr::StrLit(_)
170+
| Expr::CharLit(_)
171+
| Expr::Path(_)
172+
| Expr::RustMacro(_, _)
173+
| Expr::FilterSource
174+
| Expr::IsDefined(_)
175+
| Expr::IsNotDefined(_)
176+
| Expr::ArgumentPlaceholder => {}
177+
}
178+
}
179+
180+
#[cfg(test)]
181+
mod tests {
182+
use super::*;
183+
184+
#[test]
185+
fn test_extract_variables() {
186+
let usage = extract("Hello {{ name }}, welcome to {{ place }}!").unwrap();
187+
assert!(usage.variables.contains("name"));
188+
assert!(usage.variables.contains("place"));
189+
assert!(usage.filters.is_empty());
190+
}
191+
192+
#[test]
193+
fn test_extract_filters() {
194+
let usage = extract(r#"{{ ""|current_date }} {{ lang|language }}"#).unwrap();
195+
assert!(usage.filters.contains("current_date"));
196+
assert!(usage.filters.contains("language"));
197+
assert!(usage.variables.contains("lang"));
198+
}
199+
200+
#[test]
201+
fn test_extract_conditionals() {
202+
let usage =
203+
extract("{% if lang|is_english %}English{% else %}{{ lang|language }}{% endif %}")
204+
.unwrap();
205+
assert!(usage.variables.contains("lang"));
206+
assert!(usage.filters.contains("is_english"));
207+
assert!(usage.filters.contains("language"));
208+
}
209+
210+
#[test]
211+
fn test_extract_for_loop() {
212+
let usage = extract("{% for item in items %}{{ item.name|upper }}{% endfor %}").unwrap();
213+
assert!(usage.variables.contains("items"));
214+
assert!(usage.variables.contains("item"));
215+
assert!(usage.filters.contains("upper"));
216+
}
217+
218+
#[test]
219+
fn test_syntax_error() {
220+
let result = extract("{{ unclosed");
221+
assert!(result.is_err());
222+
}
223+
224+
#[test]
225+
fn test_empty_template() {
226+
let usage = extract("Hello, world!").unwrap();
227+
assert!(usage.variables.is_empty());
228+
assert!(usage.filters.is_empty());
229+
}
230+
}

crates/llm-proxy/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ edition = "2024"
66
[dependencies]
77
hypr-analytics = { workspace = true }
88
hypr-api-env = { workspace = true }
9+
hypr-openrouter = { workspace = true }
910

1011
async-stream = { workspace = true }
1112
axum = { workspace = true }

0 commit comments

Comments
 (0)