use regex::Regex;
use std::borrow::Cow;
use std::sync::LazyLock;
fn static_regex(pattern: &str) -> Regex {
Regex::new(pattern)
.unwrap_or_else(|e| panic!("invalid static regex literal `{}`: {}", pattern, e))
}
static GO_BLOCK_RE: LazyLock<Regex> = LazyLock::new(|| static_regex(r"\{\{.*?\}\}|\{%.*?%\}"));
pub fn preprocess(template: &str) -> String {
let block_converted = preprocess_go_blocks(template);
let dot_stripped = preprocess_strip_dots(&block_converted);
let list_rewritten = preprocess_list_subexpr(&dot_stripped);
let comparison_rewritten = preprocess_go_builtins(&list_rewritten);
let map_rewritten = preprocess_map_syntax(&comparison_rewritten);
let positional_rewritten = preprocess_positional_syntax(&map_rewritten);
preprocess_method_calls(&positional_rewritten)
}
static GO_IF_RE: LazyLock<Regex> =
LazyLock::new(|| static_regex(r"^\{\{(-?)\s*if\s+(.+?)\s*(-?)\}\}"));
static GO_ELSE_IF_RE: LazyLock<Regex> =
LazyLock::new(|| static_regex(r"^\{\{(-?)\s*else\s+if\s+(.+?)\s*(-?)\}\}"));
static GO_ELSE_RE: LazyLock<Regex> = LazyLock::new(|| static_regex(r"^\{\{(-?)\s*else\s*(-?)\}\}"));
static GO_END_RE: LazyLock<Regex> = LazyLock::new(|| static_regex(r"^\{\{(-?)\s*end\s*(-?)\}\}"));
static GO_RANGE_KV_RE: LazyLock<Regex> = LazyLock::new(|| {
static_regex(r"^\{\{(-?)\s*range\s+\$(\w+)\s*,\s*\$(\w+)\s*:=\s*(.+?)\s*(-?)\}\}")
});
static GO_RANGE_V_RE: LazyLock<Regex> = LazyLock::new(|| {
static_regex(r"^\{\{(-?)\s*range\s+(?:\$(\w+)\s*:=\s*)?(.+?)\s*(-?)\}\}")
});
static GO_WITH_RE: LazyLock<Regex> =
LazyLock::new(|| static_regex(r"^\{\{(-?)\s*with\s+(.+?)\s*(-?)\}\}"));
static GO_VAR_ASSIGN_RE: LazyLock<Regex> = LazyLock::new(|| {
static_regex(r"^\{\{(-?)\s*\$(\w+)\s*:=\s*(.+?)\s*(-?)\}\}")
});
static GO_DOT_RE: LazyLock<Regex> = LazyLock::new(|| static_regex(r"^\{\{(-?)\s*\.\s*(-?)\}\}"));
fn tera_block(ltrim: &str, content: &str, rtrim: &str) -> String {
let l = if ltrim == "-" { "{%-" } else { "{%" };
let r = if rtrim == "-" { "-%}" } else { "%}" };
format!("{l} {content} {r}")
}
fn preprocess_go_blocks(template: &str) -> String {
let mut result = String::with_capacity(template.len());
let mut block_stack: Vec<(&str, Option<String>)> = Vec::new();
let mut pos = 0;
let bytes = template.as_bytes();
while pos < bytes.len() {
if pos + 1 < bytes.len() && bytes[pos] == b'{' && bytes[pos + 1] == b'{' {
let remaining = &template[pos..];
if let Some(cap) = GO_DOT_RE.captures(remaining) {
let full = &cap[0];
let ltrim = &cap[1];
let rtrim = &cap[2];
let context_var = block_stack
.iter()
.rev()
.find_map(|(_, var)| var.as_deref())
.unwrap_or(".");
let l = if ltrim == "-" { "{{-" } else { "{{" };
let r = if rtrim == "-" { "-}}" } else { "}}" };
result.push_str(&format!("{l} {context_var} {r}"));
pos += full.len();
continue;
}
if let Some(cap) = GO_VAR_ASSIGN_RE.captures(remaining) {
let full = &cap[0];
let inner_trimmed = remaining[2..].trim_start_matches('-').trim_start();
if !inner_trimmed.starts_with("if ")
&& !inner_trimmed.starts_with("else")
&& !inner_trimmed.starts_with("end")
&& !inner_trimmed.starts_with("range ")
&& !inner_trimmed.starts_with("with ")
{
let ltrim = &cap[1];
let var = &cap[2];
let expr = &cap[3];
let rtrim = &cap[4];
result.push_str(&tera_block(ltrim, &format!("set {var} = {expr}"), rtrim));
pos += full.len();
continue;
}
}
if let Some(cap) = GO_ELSE_IF_RE.captures(remaining) {
let full = &cap[0];
result.push_str(&tera_block(&cap[1], &format!("elif {}", &cap[2]), &cap[3]));
pos += full.len();
continue;
}
if let Some(cap) = GO_IF_RE.captures(remaining) {
let full = &cap[0];
result.push_str(&tera_block(&cap[1], &format!("if {}", &cap[2]), &cap[3]));
block_stack.push(("if", None));
pos += full.len();
continue;
}
if let Some(cap) = GO_ELSE_RE.captures(remaining) {
let full = &cap[0];
result.push_str(&tera_block(&cap[1], "else", &cap[2]));
pos += full.len();
continue;
}
if let Some(cap) = GO_END_RE.captures(remaining) {
let full = &cap[0];
let end_tag = match block_stack.pop() {
Some(("for", _)) => "endfor",
_ => "endif", };
result.push_str(&tera_block(&cap[1], end_tag, &cap[2]));
pos += full.len();
continue;
}
if let Some(cap) = GO_RANGE_KV_RE.captures(remaining) {
let full = &cap[0];
let (key, val, collection) = (&cap[2], &cap[3], &cap[4]);
result.push_str(&tera_block(
&cap[1],
&format!("for {key}, {val} in {collection}"),
&cap[5],
));
block_stack.push(("for", Some(val.to_string())));
pos += full.len();
continue;
}
if let Some(cap) = GO_RANGE_V_RE.captures(remaining) {
let full = &cap[0];
let loop_var = cap.get(2).map(|m| m.as_str()).unwrap_or("val");
let collection = &cap[3];
result.push_str(&tera_block(
&cap[1],
&format!("for {loop_var} in {collection}"),
&cap[4],
));
block_stack.push(("for", Some(loop_var.to_string())));
pos += full.len();
continue;
}
if let Some(cap) = GO_WITH_RE.captures(remaining) {
let full = &cap[0];
let field = cap[2].to_string();
result.push_str(&tera_block(&cap[1], &format!("if {}", &field), &cap[3]));
block_stack.push(("with", Some(field)));
pos += full.len();
continue;
}
}
result.push(bytes[pos] as char);
pos += 1;
}
strip_dollar_vars(&result)
}
fn strip_dollar_vars(template: &str) -> String {
static BLOCK_RE: LazyLock<Regex> = LazyLock::new(|| static_regex(r"\{\{.*?\}\}|\{%.*?%\}"));
BLOCK_RE
.replace_all(template, |caps: ®ex::Captures| {
let block = &caps[0];
let bytes = block.as_bytes();
let mut result = String::with_capacity(block.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'"' || bytes[i] == b'\'' {
let quote = bytes[i];
result.push(quote as char);
i += 1;
while i < bytes.len() && bytes[i] != quote {
if bytes[i] == b'\\' && i + 1 < bytes.len() {
result.push(bytes[i] as char);
result.push(bytes[i + 1] as char);
i += 2;
} else {
result.push(bytes[i] as char);
i += 1;
}
}
if i < bytes.len() {
result.push(bytes[i] as char);
i += 1;
}
continue;
}
if bytes[i] == b'$'
&& i + 1 < bytes.len()
&& (bytes[i + 1].is_ascii_alphabetic() || bytes[i + 1] == b'_')
{
i += 1;
continue;
}
result.push(bytes[i] as char);
i += 1;
}
result
})
.to_string()
}
fn preprocess_strip_dots(template: &str) -> String {
GO_BLOCK_RE
.replace_all(template, |caps: ®ex::Captures| {
let block = &caps[0];
let (open, inner, close) = extract_block_parts(block);
let mut result = String::with_capacity(block.len());
result.push_str(open);
let bytes = inner.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'"' || bytes[i] == b'\'' {
let quote = bytes[i];
result.push(quote as char);
i += 1;
while i < bytes.len() && bytes[i] != quote {
if bytes[i] == b'\\' && i + 1 < bytes.len() {
result.push(bytes[i] as char);
result.push(bytes[i + 1] as char);
i += 2;
} else {
result.push(bytes[i] as char);
i += 1;
}
}
if i < bytes.len() {
result.push(bytes[i] as char); i += 1;
}
continue;
}
if bytes[i] == b'.'
&& i + 1 < bytes.len()
&& (bytes[i + 1].is_ascii_alphanumeric() || bytes[i + 1] == b'_')
{
let prev_is_word =
i > 0 && (bytes[i - 1].is_ascii_alphanumeric() || bytes[i - 1] == b'_');
if prev_is_word {
result.push('.');
}
} else {
result.push(bytes[i] as char);
}
i += 1;
}
result.push_str(close);
result
})
.to_string()
}
static LIST_SUBEXPR_RE: LazyLock<Regex> = LazyLock::new(|| {
let item = r#"(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[a-zA-Z_][a-zA-Z0-9_.]*)"#;
let pattern = format!(r"\(list\s+({item}(?:\s+{item})*)\)");
static_regex(&pattern)
});
fn preprocess_list_subexpr(template: &str) -> String {
GO_BLOCK_RE
.replace_all(template, |caps: ®ex::Captures| {
let block = &caps[0];
if !block.contains("(list ") {
return block.to_string();
}
LIST_SUBEXPR_RE
.replace_all(block, |lcaps: ®ex::Captures| {
let inner = &lcaps[1];
static ITEM_RE: LazyLock<Regex> = LazyLock::new(|| {
static_regex(
r#""(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[a-zA-Z_][a-zA-Z0-9_.]*"#,
)
});
let items: Vec<&str> = ITEM_RE.find_iter(inner).map(|m| m.as_str()).collect();
format!("[{}]", items.join(", "))
})
.to_string()
})
.to_string()
}
const COMPARISON_OPS: &[(&str, &str)] = &[
("eq", "=="),
("ne", "!="),
("gt", ">"),
("lt", "<"),
("ge", ">="),
("le", "<="),
];
fn preprocess_go_builtins(template: &str) -> String {
GO_BLOCK_RE
.replace_all(template, |caps: ®ex::Captures| {
let block = &caps[0];
let (open, inner, close) = extract_block_parts(block);
let needs_rewrite = COMPARISON_OPS.iter().any(|(name, _)| {
let with_space = format!("{} ", name);
inner.contains(&*with_space)
}) || inner.contains("and ")
|| inner.contains("or ")
|| inner.contains("len ");
if !needs_rewrite {
return block.to_string();
}
let rewritten = rewrite_go_builtins_in_expr(inner);
format!("{}{}{}", open, rewritten, close)
})
.to_string()
}
fn rewrite_go_builtins_in_expr(expr: &str) -> String {
let mut result = expr.to_string();
result = rewrite_logical_with_paren_args(&result);
result = rewrite_not_with_paren_comparison(&result);
for (func_name, operator) in COMPARISON_OPS {
result = rewrite_prefix_to_infix(&result, func_name, operator);
}
result = rewrite_prefix_to_infix(&result, "and", "and");
result = rewrite_prefix_to_infix(&result, "or", "or");
result = rewrite_len(&result);
result
}
fn rewrite_logical_with_paren_args(expr: &str) -> String {
static LOGICAL_PAREN_RE: LazyLock<Regex> = LazyLock::new(|| {
let paren_group = r#"\(([^()]*(?:\([^()]*\)[^()]*)*)\)"#;
let pattern = format!(
r"(?:^|(?P<pre>[^a-zA-Z0-9_]))(?P<op>and|or)\s+{}\s+{}",
paren_group, paren_group
);
static_regex(&pattern)
});
LOGICAL_PAREN_RE
.replace_all(expr, |caps: ®ex::Captures| {
let pre = caps.name("pre").map_or("", |m| m.as_str());
let logical_op = caps.name("op").map_or("", |m| m.as_str());
let inner1 = &caps[3]; let inner2 = &caps[4];
let rewritten1 = rewrite_comparison_expr(inner1);
let rewritten2 = rewrite_comparison_expr(inner2);
format!("{}{} {} {}", pre, rewritten1, logical_op, rewritten2)
})
.to_string()
}
fn rewrite_not_with_paren_comparison(expr: &str) -> String {
static NOT_PAREN_RE: LazyLock<Regex> = LazyLock::new(|| {
let paren_group = r#"\(([^()]*)\)"#;
static_regex(&format!(r"not\s+{}", paren_group))
});
NOT_PAREN_RE
.replace_all(expr, |caps: ®ex::Captures| {
let inner = &caps[1];
let rewritten = rewrite_comparison_expr(inner);
if rewritten != inner {
format!("not {}", rewritten)
} else {
caps[0].to_string()
}
})
.to_string()
}
fn rewrite_comparison_expr(expr: &str) -> String {
let mut result = expr.to_string();
for (func_name, operator) in COMPARISON_OPS {
result = rewrite_prefix_to_infix(&result, func_name, operator);
}
result = rewrite_len(&result);
result
}
fn rewrite_prefix_to_infix(expr: &str, func_name: &str, operator: &str) -> String {
use std::collections::HashMap;
use std::sync::Mutex;
static REGEX_CACHE: LazyLock<Mutex<HashMap<String, Regex>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
let arg_pattern = r#"(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|\((?:[^()]*(?:\([^()]*\))*[^()]*)\)|[a-zA-Z_][a-zA-Z0-9_.]*|\d+)"#;
let pattern = format!(
r"(?:^|(?P<pre>[^a-zA-Z0-9_])){}\s+(?P<a1>{})\s+(?P<tail>{}(?:\s+{})*)",
regex::escape(func_name),
arg_pattern,
arg_pattern,
arg_pattern
);
let re = {
let mut cache = REGEX_CACHE
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
cache
.entry(func_name.to_string())
.or_insert_with(|| static_regex(&pattern))
.clone()
};
let split_re = {
static SPLIT_RE: LazyLock<Regex> = LazyLock::new(|| {
let arg = r#"(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|\((?:[^()]*(?:\([^()]*\))*[^()]*)\)|[a-zA-Z_][a-zA-Z0-9_.]*|\d+)"#;
static_regex(arg)
});
&*SPLIT_RE
};
re.replace_all(expr, |caps: ®ex::Captures| {
let pre = caps.name("pre").map_or("", |m| m.as_str());
let arg1 = caps.name("a1").map_or("", |m| m.as_str());
let tail = caps.name("tail").map_or("", |m| m.as_str());
let rest_args: Vec<&str> = split_re.find_iter(tail).map(|m| m.as_str()).collect();
if rest_args.len() == 1 {
format!("{}{} {} {}", pre, arg1, operator, rest_args[0])
} else {
let parts: Vec<String> = rest_args
.iter()
.map(|a| format!("{} {} {}", arg1, operator, a))
.collect();
format!("{}{}", pre, parts.join(" or "))
}
})
.to_string()
}
fn rewrite_len(expr: &str) -> String {
static LEN_RE: LazyLock<Regex> = LazyLock::new(|| {
let arg_pattern =
r#"(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|\([^()]*\)|[a-zA-Z_][a-zA-Z0-9_.]*)"#;
let pattern = format!(
r"(?:^|(?P<pre>[^a-zA-Z0-9_]))len\s+(?P<arg>{})",
arg_pattern
);
static_regex(&pattern)
});
LEN_RE
.replace_all(expr, |caps: ®ex::Captures| {
let pre = caps.name("pre").map_or("", |m| m.as_str());
let arg = caps.name("arg").map_or("", |m| m.as_str());
format!("{}{} | length", pre, arg)
})
.to_string()
}
static MAP_POSITIONAL_RE: LazyLock<Regex> = LazyLock::new(|| {
let item = r#"(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[a-zA-Z_][a-zA-Z0-9_.]*)"#;
let pattern = format!(r"(?:^|(?P<pre>[^a-zA-Z0-9_]))map\s+(?P<args>{item}(?:\s+{item})+)");
static_regex(&pattern)
});
fn preprocess_map_syntax(template: &str) -> String {
GO_BLOCK_RE
.replace_all(template, |caps: ®ex::Captures| {
let block = &caps[0];
if !block.contains("map ") {
return block.to_string();
}
if block.contains("map(") {
return block.to_string();
}
let (open, inner, close) = extract_block_parts(block);
let rewritten = MAP_POSITIONAL_RE
.replace_all(inner, |mcaps: ®ex::Captures| {
let pre = mcaps.name("pre").map_or("", |m| m.as_str());
let args_str = mcaps.name("args").map_or("", |m| m.as_str());
static ITEM_RE: LazyLock<Regex> = LazyLock::new(|| {
static_regex(
r#""(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[a-zA-Z_][a-zA-Z0-9_.]*"#,
)
});
let items: Vec<&str> =
ITEM_RE.find_iter(args_str).map(|m| m.as_str()).collect();
let array_literal = format!("[{}]", items.join(", "));
format!("{}map(pairs={})", pre, array_literal)
})
.to_string();
format!("{}{}{}", open, rewritten, close)
})
.to_string()
}
fn preprocess_positional_syntax(template: &str) -> String {
GO_BLOCK_RE
.replace_all(template, |caps: ®ex::Captures| {
let block = &caps[0];
let (open, inner, close) = extract_block_parts(block);
if block.starts_with("{%") {
if let Some(rewritten) = try_rewrite_control_block(inner) {
return format!("{}{}{}", open, rewritten, close);
}
return block.to_string();
}
let tokens = tokenize_block(inner);
if tokens.is_empty() {
return block.to_string();
}
if let Some(rewritten) = try_rewrite_standalone(&tokens) {
return format!("{}{}{}", open, rewritten, close);
}
if let Some(rewritten) = try_rewrite_piped(&tokens) {
return format!("{}{}{}", open, rewritten, close);
}
block.to_string()
})
.to_string()
}
static NOW_FORMAT_RE: LazyLock<Regex> =
LazyLock::new(|| static_regex(r#"Now\.Format\s+("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')"#));
fn preprocess_method_calls(template: &str) -> String {
GO_BLOCK_RE
.replace_all(template, |caps: ®ex::Captures| {
let block = &caps[0];
if !block.contains("Now.Format") {
return block.to_string();
}
let (open, inner, close) = extract_block_parts(block);
let rewritten = NOW_FORMAT_RE
.replace_all(inner, |mcaps: ®ex::Captures| {
let fmt_arg = &mcaps[1];
format!("Now | now_format(format={})", fmt_arg)
})
.to_string();
format!("{}{}{}", open, rewritten, close)
})
.to_string()
}
fn extract_block_parts(block: &str) -> (&str, &str, &str) {
let open_len = if block.starts_with("{{-") || block.starts_with("{%-") {
3
} else {
2
};
let close_len = if block.ends_with("-}}") || block.ends_with("-%}") {
3
} else {
2
};
let open = &block[..open_len];
let close = &block[block.len() - close_len..];
let inner = &block[open_len..block.len() - close_len];
(open, inner, close)
}
fn try_rewrite_control_block(inner: &str) -> Option<String> {
let tokens = tokenize_block(inner);
let sig = significant_tokens(&tokens);
if sig.is_empty() {
return None;
}
let keyword = match sig.first() {
Some(Token::Ident(k)) => k.as_str(),
_ => return None,
};
if keyword != "if" && keyword != "elif" {
return None;
}
let keyword_end_idx = tokens
.iter()
.position(|t| matches!(t, Token::Ident(k) if k == keyword))
.map(|i| i + 1)?;
let expr_tokens: Vec<Token> = tokens[keyword_end_idx..].to_vec();
if let Some(rewritten) = try_rewrite_standalone(&expr_tokens) {
let prefix: String = tokens[..keyword_end_idx]
.iter()
.map(|t| token_to_str(t))
.collect();
return Some(format!("{}{}", prefix, rewritten));
}
if let Some(rewritten) = try_rewrite_piped(&expr_tokens) {
let prefix: String = tokens[..keyword_end_idx]
.iter()
.map(|t| token_to_str(t))
.collect();
return Some(format!("{}{}", prefix, rewritten));
}
None
}
#[derive(Debug, Clone, PartialEq)]
enum Token {
Ident(String),
Quoted(String),
ArrayLiteral(String),
Pipe,
Space(String),
Other(String),
}
fn tokenize_block(inner: &str) -> Vec<Token> {
let mut tokens = Vec::new();
let bytes = inner.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i].is_ascii_whitespace() {
let start = i;
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
tokens.push(Token::Space(inner[start..i].to_string()));
continue;
}
if bytes[i] == b'"' || bytes[i] == b'\'' {
let quote = bytes[i];
let start = i;
i += 1;
while i < bytes.len() && bytes[i] != quote {
if bytes[i] == b'\\' && i + 1 < bytes.len() {
i += 2;
} else {
i += 1;
}
}
if i < bytes.len() {
i += 1; }
tokens.push(Token::Quoted(inner[start..i].to_string()));
continue;
}
if bytes[i] == b'[' {
let start = i;
let mut depth = 1;
i += 1;
while i < bytes.len() && depth > 0 {
if bytes[i] == b'[' {
depth += 1;
} else if bytes[i] == b']' {
depth -= 1;
} else if bytes[i] == b'"' || bytes[i] == b'\'' {
let quote = bytes[i];
i += 1;
while i < bytes.len() && bytes[i] != quote {
if bytes[i] == b'\\' && i + 1 < bytes.len() {
i += 2;
} else {
i += 1;
}
}
if i < bytes.len() {
i += 1; }
continue;
}
i += 1;
}
tokens.push(Token::ArrayLiteral(inner[start..i].to_string()));
continue;
}
if bytes[i] == b'|' {
tokens.push(Token::Pipe);
i += 1;
continue;
}
if bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_' {
let start = i;
while i < bytes.len()
&& (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_' || bytes[i] == b'.')
{
i += 1;
}
tokens.push(Token::Ident(inner[start..i].to_string()));
continue;
}
let Some(ch) = inner[i..].chars().next() else {
break;
};
tokens.push(Token::Other(ch.to_string()));
i += ch.len_utf8();
}
tokens
}
fn significant_tokens(tokens: &[Token]) -> Vec<&Token> {
tokens
.iter()
.filter(|t| !matches!(t, Token::Space(_)))
.collect()
}
struct PositionalSyntax {
name: &'static str,
arity: usize,
standalone_params: &'static [&'static str],
piped_params: &'static [&'static str],
}
static POSITIONAL_FUNCTIONS: &[PositionalSyntax] = &[
PositionalSyntax {
name: "replace",
arity: 3,
standalone_params: &["s", "old", "new"],
piped_params: &["from", "to"],
},
PositionalSyntax {
name: "split",
arity: 2,
standalone_params: &["s", "sep"],
piped_params: &["sep"],
},
PositionalSyntax {
name: "contains",
arity: 2,
standalone_params: &["s", "substr"],
piped_params: &["substr"],
},
PositionalSyntax {
name: "in",
arity: 2,
standalone_params: &["items", "value"],
piped_params: &["value"],
},
PositionalSyntax {
name: "reReplaceAll",
arity: 3,
standalone_params: &["pattern", "input", "replacement"],
piped_params: &["pattern", "replacement"],
},
PositionalSyntax {
name: "filter",
arity: 2,
standalone_params: &["items", "regexp"],
piped_params: &["regexp"],
},
PositionalSyntax {
name: "reverseFilter",
arity: 2,
standalone_params: &["items", "regexp"],
piped_params: &["regexp"],
},
PositionalSyntax {
name: "readFile",
arity: 1,
standalone_params: &["path"],
piped_params: &[],
},
PositionalSyntax {
name: "mustReadFile",
arity: 1,
standalone_params: &["path"],
piped_params: &[],
},
PositionalSyntax {
name: "index",
arity: 2,
standalone_params: &["collection", "key"],
piped_params: &["key"],
},
];
fn lookup_positional(name: &str) -> Option<&'static PositionalSyntax> {
POSITIONAL_FUNCTIONS.iter().find(|p| p.name == name)
}
fn try_rewrite_standalone(tokens: &[Token]) -> Option<String> {
let sig = significant_tokens(tokens);
if sig.iter().any(|t| matches!(t, Token::Other(s) if s == "(")) {
return None;
}
if sig.iter().any(|t| matches!(t, Token::Pipe)) {
return None;
}
let func_name = match sig.first() {
Some(Token::Ident(name)) => name.as_str(),
_ => return None,
};
let spec = lookup_positional(func_name)?;
if sig.len() != spec.arity + 1 {
return None;
}
let args: Vec<String> = sig[1..]
.iter()
.map(|t| format_arg_value(t))
.collect::<Option<Vec<_>>>()?;
let params_str: String = spec
.standalone_params
.iter()
.zip(args.iter())
.map(|(name, val)| format!("{}={}", name, val))
.collect::<Vec<_>>()
.join(", ");
let leading_ws = tokens
.first()
.and_then(|t| match t {
Token::Space(s) => Some(s.as_str()),
_ => None,
})
.unwrap_or("");
let trailing_ws = tokens
.last()
.and_then(|t| match t {
Token::Space(s) => Some(s.as_str()),
_ => None,
})
.unwrap_or("");
Some(format!(
"{}{}({}){}",
leading_ws, func_name, params_str, trailing_ws
))
}
fn try_rewrite_piped(tokens: &[Token]) -> Option<String> {
let last_pipe_idx = tokens.iter().rposition(|t| matches!(t, Token::Pipe))?;
let before_pipe = &tokens[..last_pipe_idx];
let after_pipe = &tokens[last_pipe_idx + 1..];
if after_pipe
.iter()
.any(|t| matches!(t, Token::Other(s) if s == "("))
{
return None;
}
let sig_after = significant_tokens(after_pipe);
if sig_after.is_empty() {
return None;
}
let func_name = match sig_after.first() {
Some(Token::Ident(name)) => name.as_str(),
_ => return None,
};
let spec = lookup_positional(func_name)?;
let piped_arity = spec.arity - 1;
if sig_after.len() != piped_arity + 1 {
return None;
}
let args: Vec<String> = sig_after[1..]
.iter()
.map(|t| format_arg_value(t))
.collect::<Option<Vec<_>>>()?;
let params_str: String = spec
.piped_params
.iter()
.zip(args.iter())
.map(|(name, val)| format!("{}={}", name, val))
.collect::<Vec<_>>()
.join(", ");
let before_str: String = before_pipe.iter().map(|t| token_to_str(t)).collect();
let trailing_ws = tokens
.last()
.and_then(|t| match t {
Token::Space(s) => Some(s.as_str()),
_ => None,
})
.unwrap_or("");
Some(format!(
"{} | {}({}){}",
before_str.trim_end(),
func_name,
params_str,
trailing_ws
))
}
fn format_arg_value(token: &Token) -> Option<String> {
match token {
Token::Quoted(s) => Some(s.clone()),
Token::Ident(s) => Some(s.clone()),
Token::ArrayLiteral(s) => Some(s.clone()),
_ => None,
}
}
fn token_to_str(token: &Token) -> Cow<'_, str> {
match token {
Token::Ident(s)
| Token::Quoted(s)
| Token::ArrayLiteral(s)
| Token::Space(s)
| Token::Other(s) => Cow::Borrowed(s.as_str()),
Token::Pipe => Cow::Borrowed("|"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_preprocess_positional_replace() {
let input = "{{ replace Version \"v\" \"\" }}";
let result = preprocess(input);
assert_eq!(result, "{{ replace(s=Version, old=\"v\", new=\"\") }}");
}
#[test]
fn test_preprocess_positional_replace_piped() {
let input = "{{ Version | replace \"v\" \"\" }}";
let result = preprocess(input);
assert_eq!(result, "{{ Version | replace(from=\"v\", to=\"\") }}");
}
#[test]
fn test_preprocess_positional_split() {
let input = "{{ split Version \".\" }}";
let result = preprocess(input);
assert_eq!(result, "{{ split(s=Version, sep=\".\") }}");
}
#[test]
fn test_preprocess_positional_contains() {
let input = "{{ contains Version \"rc\" }}";
let result = preprocess(input);
assert_eq!(result, "{{ contains(s=Version, substr=\"rc\") }}");
}
#[test]
fn test_preprocess_positional_piped_split() {
let input = "{{ Version | split \".\" }}";
let result = preprocess(input);
assert_eq!(result, "{{ Version | split(sep=\".\") }}");
}
#[test]
fn test_preprocess_positional_piped_contains() {
let input = "{{ Version | contains \"rc\" }}";
let result = preprocess(input);
assert_eq!(result, "{{ Version | contains(substr=\"rc\") }}");
}
#[test]
fn test_preprocess_named_args_unchanged() {
let input = "{{ replace(s=Version, old=\"v\", new=\"\") }}";
let result = preprocess(input);
assert_eq!(result, input);
}
#[test]
fn test_preprocess_named_filter_unchanged() {
let input = "{{ Version | replace(from=\"v\", to=\"\") }}";
let result = preprocess(input);
assert_eq!(result, input);
}
#[test]
fn test_preprocess_control_block_rewritten() {
let input = "{% if contains Version \"rc\" %}yes{% endif %}";
let result = preprocess(input);
assert_eq!(
result,
"{% if contains(s=Version, substr=\"rc\") %}yes{% endif %}"
);
}
#[test]
fn test_preprocess_control_block_non_positional_unchanged() {
let input = "{% if Version %}yes{% endif %}";
let result = preprocess(input);
assert_eq!(result, input);
}
#[test]
fn test_positional_replace_with_dot_var() {
let input = "{{ replace .Tag \"v\" \"\" }}";
let result = preprocess(input);
assert_eq!(result, "{{ replace(s=Tag, old=\"v\", new=\"\") }}");
}
#[test]
fn test_positional_piped_with_dot_var() {
let input = "{{ .Tag | replace \"v\" \"\" }}";
let result = preprocess(input);
assert_eq!(result, "{{ Tag | replace(from=\"v\", to=\"\") }}");
}
#[test]
fn test_positional_no_spaces_compact() {
let input = "{{replace .Tag \"v\" \"\"}}";
let result = preprocess(input);
assert_eq!(result, "{{replace(s=Tag, old=\"v\", new=\"\")}}");
}
#[test]
fn test_unrelated_expression_unchanged() {
let input = "{{ Version }}";
let result = preprocess(input);
assert_eq!(result, "{{ Version }}");
}
#[test]
fn test_unrelated_filter_unchanged() {
let input = "{{ Version | upper }}";
let result = preprocess(input);
assert_eq!(result, "{{ Version | upper }}");
}
#[test]
fn test_positional_replace_whitespace_control() {
let input = "{{- replace Version \"v\" \"\" -}}";
let result = preprocess(input);
assert_eq!(result, "{{- replace(s=Version, old=\"v\", new=\"\") -}}");
}
#[test]
fn test_positional_replace_whitespace_control_left_only() {
let input = "{{- replace Version \"v\" \"\" }}";
let result = preprocess(input);
assert_eq!(result, "{{- replace(s=Version, old=\"v\", new=\"\") }}");
}
#[test]
fn test_chained_named_filter_then_positional_rewrite() {
let input = "{{ Version | trimprefix(prefix=\"v\") | replace \".\" \"-\" }}";
let result = preprocess(input);
assert_eq!(
result,
"{{ Version | trimprefix(prefix=\"v\") | replace(from=\".\", to=\"-\") }}"
);
}
#[test]
fn test_preprocess_in_with_list_subexpr() {
let input = "{{ in (list \"a\" \"b\" \"c\") \"b\" }}";
let result = preprocess(input);
assert_eq!(result, "{{ in(items=[\"a\", \"b\", \"c\"], value=\"b\") }}");
}
#[test]
fn test_preprocess_in_with_variable() {
let input = "{{ in myList \"b\" }}";
let result = preprocess(input);
assert_eq!(result, "{{ in(items=myList, value=\"b\") }}");
}
#[test]
fn test_preprocess_in_named_args_unchanged() {
let input = "{{ in(items=[\"a\", \"b\"], value=\"a\") }}";
let result = preprocess(input);
assert_eq!(result, input);
}
#[test]
fn test_preprocess_in_with_dot_var() {
let input = "{{ in .MyList \"val\" }}";
let result = preprocess(input);
assert_eq!(result, "{{ in(items=MyList, value=\"val\") }}");
}
#[test]
fn test_preprocess_in_control_block() {
let input = "{% if in myList \"b\" %}yes{% endif %}";
let result = preprocess(input);
assert_eq!(
result,
"{% if in(items=myList, value=\"b\") %}yes{% endif %}"
);
}
#[test]
fn test_preprocess_list_subexpr_rewrite() {
let input = "{{ in (list \"x\" \"y\") \"x\" }}";
let result = preprocess(input);
assert_eq!(result, "{{ in(items=[\"x\", \"y\"], value=\"x\") }}");
}
#[test]
fn test_preprocess_in_control_block_with_list_subexpr() {
let input = "{% if in (list \"a\" \"b\") \"a\" %}yes{% endif %}";
let result = preprocess(input);
assert_eq!(
result,
"{% if in(items=[\"a\", \"b\"], value=\"a\") %}yes{% endif %}"
);
}
#[test]
fn test_preprocess_re_replace_all_positional() {
let input = "{{ reReplaceAll \"(.*)\" \"hello\" \"$1-world\" }}";
let result = preprocess(input);
assert_eq!(
result,
"{{ reReplaceAll(pattern=\"(.*)\", input=\"hello\", replacement=\"$1-world\") }}"
);
}
#[test]
fn test_preprocess_re_replace_all_with_variable() {
let input = "{{ reReplaceAll \"(v)(.*)\" Tag \"prefix-$2\" }}";
let result = preprocess(input);
assert_eq!(
result,
"{{ reReplaceAll(pattern=\"(v)(.*)\", input=Tag, replacement=\"prefix-$2\") }}"
);
}
#[test]
fn test_preprocess_re_replace_all_named_args_unchanged() {
let input = "{{ reReplaceAll(pattern=\"x\", input=\"ax\", replacement=\"y\") }}";
let result = preprocess(input);
assert_eq!(result, input);
}
#[test]
fn test_preprocess_re_replace_all_piped() {
let input = "{{ Message | reReplaceAll \"(.*)\" \"$1-done\" }}";
let result = preprocess(input);
assert_eq!(
result,
"{{ Message | reReplaceAll(pattern=\"(.*)\", replacement=\"$1-done\") }}"
);
}
#[test]
fn test_preprocess_re_replace_all_control_block() {
let input = "{% if reReplaceAll \"v\" Tag \"\" %}yes{% endif %}";
let result = preprocess(input);
assert_eq!(
result,
"{% if reReplaceAll(pattern=\"v\", input=Tag, replacement=\"\") %}yes{% endif %}"
);
}
#[test]
fn test_preprocess_in_piped() {
let input = "{{ myList | in \"val\" }}";
let result = preprocess(input);
assert_eq!(result, "{{ myList | in(value=\"val\") }}");
}
#[test]
fn test_preprocess_list_subexpr_escaped_double_quotes() {
let input = r#"{{ in (list "hello \"world\"" "plain") "plain" }}"#;
let result = preprocess(input);
assert_eq!(
result,
r#"{{ in(items=["hello \"world\"", "plain"], value="plain") }}"#
);
}
#[test]
fn test_preprocess_list_subexpr_escaped_single_quotes() {
let input = "{{ in (list 'it\\'s' 'fine') \"fine\" }}";
let result = preprocess(input);
assert_eq!(result, "{{ in(items=['it\\'s', 'fine'], value=\"fine\") }}");
}
#[test]
fn test_preprocess_list_subexpr_mixed_quote_styles() {
let input = "{{ in (list \"double\" 'single' \"another\") \"double\" }}";
let result = preprocess(input);
assert_eq!(
result,
"{{ in(items=[\"double\", 'single', \"another\"], value=\"double\") }}"
);
}
#[test]
fn test_preprocess_list_subexpr_with_bare_identifier() {
let input = "{{ in (list .Os \"windows\") \"linux\" }}";
let result = preprocess(input);
assert_eq!(result, "{{ in(items=[Os, \"windows\"], value=\"linux\") }}");
}
#[test]
fn test_preprocess_list_subexpr_with_dotted_path() {
let input = "{{ in (list .Env.FOO \"fallback\") \"val\" }}";
let result = preprocess(input);
assert_eq!(
result,
"{{ in(items=[Env.FOO, \"fallback\"], value=\"val\") }}"
);
}
#[test]
fn test_preprocess_list_subexpr_all_bare_identifiers() {
let input = "{{ in (list .Os .Arch) \"linux\" }}";
let result = preprocess(input);
assert_eq!(result, "{{ in(items=[Os, Arch], value=\"linux\") }}");
}
#[test]
fn test_preprocess_list_subexpr_mixed_vars_and_strings() {
let input = "{{ in (list .Os \"windows\" .Arch) \"test\" }}";
let result = preprocess(input);
assert_eq!(
result,
"{{ in(items=[Os, \"windows\", Arch], value=\"test\") }}"
);
}
#[test]
fn test_preprocess_now_format_go_style() {
let input = "{{ .Now.Format \"2006-01-02\" }}";
let result = preprocess(input);
assert_eq!(result, "{{ Now | now_format(format=\"2006-01-02\") }}");
}
#[test]
fn test_preprocess_now_format_no_dot_prefix() {
let input = "{{ Now.Format \"2006-01-02\" }}";
let result = preprocess(input);
assert_eq!(result, "{{ Now | now_format(format=\"2006-01-02\") }}");
}
#[test]
fn test_preprocess_now_format_with_time_pattern() {
let input = "{{ .Now.Format \"2006-01-02 15:04:05\" }}";
let result = preprocess(input);
assert_eq!(
result,
"{{ Now | now_format(format=\"2006-01-02 15:04:05\") }}"
);
}
#[test]
fn test_preprocess_now_format_single_quotes() {
let input = "{{ .Now.Format '2006-01-02' }}";
let result = preprocess(input);
assert_eq!(result, "{{ Now | now_format(format='2006-01-02') }}");
}
#[test]
fn test_preprocess_now_format_whitespace_control() {
let input = "{{- .Now.Format \"2006-01-02\" -}}";
let result = preprocess(input);
assert_eq!(result, "{{- Now | now_format(format=\"2006-01-02\") -}}");
}
#[test]
fn test_preprocess_now_format_compact() {
let input = "{{.Now.Format \"2006-01-02\"}}";
let result = preprocess(input);
assert_eq!(result, "{{Now | now_format(format=\"2006-01-02\")}}");
}
#[test]
fn test_preprocess_now_format_does_not_affect_other_blocks() {
let input = "{{ Version }} - {{ .Now.Format \"2006-01-02\" }}";
let result = preprocess(input);
assert_eq!(
result,
"{{ Version }} - {{ Now | now_format(format=\"2006-01-02\") }}"
);
}
#[test]
fn test_go_if_end() {
let input = "{{ if .IsSnapshot }}pre{{ end }}";
let result = preprocess(input);
assert_eq!(result, "{% if IsSnapshot %}pre{% endif %}");
}
#[test]
fn test_go_if_else_end() {
let input = "{{ if .IsSnapshot }}pre{{ else }}stable{{ end }}";
let result = preprocess(input);
assert_eq!(result, "{% if IsSnapshot %}pre{% else %}stable{% endif %}");
}
#[test]
fn test_go_if_else_if_end() {
let input = "{{ if eq .Os \"windows\" }}win{{ else if eq .Os \"darwin\" }}mac{{ else }}linux{{ end }}";
let result = preprocess(input);
assert_eq!(
result,
"{% if Os == \"windows\" %}win{% elif Os == \"darwin\" %}mac{% else %}linux{% endif %}"
);
}
#[test]
fn test_go_range_bare() {
let input = "{{ range .Maintainers }}# {{ . }}{{ end }}";
let result = preprocess(input);
assert_eq!(
result,
"{% for val in Maintainers %}# {{ val }}{% endfor %}"
);
}
#[test]
fn test_go_range_with_variable() {
let input = "{{ range $release := .Packages }}{{ $release.Name }}{{ end }}";
let result = preprocess(input);
assert_eq!(
result,
"{% for release in Packages %}{{ release.Name }}{% endfor %}"
);
}
#[test]
fn test_go_range_kv() {
let input = "{{ range $key, $value := .Checksums }}{{ $value }} {{ $key }}{{ end }}";
let result = preprocess(input);
assert_eq!(
result,
"{% for key, value in Checksums %}{{ value }} {{ key }}{% endfor %}"
);
}
#[test]
fn test_go_with() {
let input = "{{ with .Arm }}v{{ . }}{{ end }}";
let result = preprocess(input);
assert_eq!(result, "{% if Arm %}v{{ Arm }}{% endif %}");
}
#[test]
fn test_go_var_assignment() {
let input = "{{ $m := map \"a\" \"1\" }}{{ index $m \"a\" }}";
let result = preprocess(input);
assert_eq!(
result,
"{% set m = map(pairs=[\"a\", \"1\"]) %}{{ index(collection=m, key=\"a\") }}"
);
}
#[test]
fn test_go_whitespace_trim() {
let input = "{{- if .Cond -}}yes{{- end -}}";
let result = preprocess(input);
assert_eq!(result, "{%- if Cond -%}yes{%- endif -%}");
}
#[test]
fn test_go_nested_if_range() {
let input = "{{ range .Items }}{{ if .Active }}*{{ end }}{{ end }}";
let result = preprocess(input);
assert_eq!(
result,
"{% for val in Items %}{% if Active %}*{% endif %}{% endfor %}"
);
}
#[test]
fn test_go_blocks_plain_expressions_unchanged() {
let input = "{{ .ProjectName }}_{{ .Version }}";
let result = preprocess(input);
assert_eq!(result, "{{ ProjectName }}_{{ Version }}");
}
#[test]
fn test_go_complex_nfpm_template() {
let input = "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ if not (eq .Amd64 \"v1\") }}{{ .Amd64 }}{{ end }}";
let result = preprocess(input);
assert_eq!(
result,
"{{ ProjectName }}_{{ Version }}_{{ Os }}_{{ Arch }}{% if Arm %}v{{ Arm }}{% endif %}{% if not Amd64 == \"v1\" %}{{ Amd64 }}{% endif %}"
);
}
#[test]
fn test_eq_in_if_block() {
let input = "{% if eq Os \"windows\" %}win{% endif %}";
let result = preprocess(input);
assert_eq!(result, "{% if Os == \"windows\" %}win{% endif %}");
}
#[test]
fn test_eq_variadic_three_args() {
let input = r#"{% if eq Os "linux" "darwin" %}unix{% endif %}"#;
let result = preprocess(input);
assert_eq!(
result,
r#"{% if Os == "linux" or Os == "darwin" %}unix{% endif %}"#
);
}
#[test]
fn test_eq_variadic_four_args() {
let input = r#"{% if eq Arch "amd64" "arm64" "386" %}supported{% endif %}"#;
let result = preprocess(input);
assert_eq!(
result,
r#"{% if Arch == "amd64" or Arch == "arm64" or Arch == "386" %}supported{% endif %}"#
);
}
#[test]
fn test_ne_in_if_block() {
let input = "{% if ne Os \"windows\" %}not-win{% endif %}";
let result = preprocess(input);
assert_eq!(result, "{% if Os != \"windows\" %}not-win{% endif %}");
}
#[test]
fn test_gt_in_if_block() {
let input = "{% if gt Major 1 %}gt1{% endif %}";
let result = preprocess(input);
assert_eq!(result, "{% if Major > 1 %}gt1{% endif %}");
}
#[test]
fn test_lt_in_if_block() {
let input = "{% if lt Minor 5 %}lt5{% endif %}";
let result = preprocess(input);
assert_eq!(result, "{% if Minor < 5 %}lt5{% endif %}");
}
#[test]
fn test_ge_in_if_block() {
let input = "{% if ge Patch 3 %}ge3{% endif %}";
let result = preprocess(input);
assert_eq!(result, "{% if Patch >= 3 %}ge3{% endif %}");
}
#[test]
fn test_le_in_if_block() {
let input = "{% if le Patch 3 %}le3{% endif %}";
let result = preprocess(input);
assert_eq!(result, "{% if Patch <= 3 %}le3{% endif %}");
}
#[test]
fn test_eq_with_string_literal() {
let input = "{% if eq Arch \"amd64\" %}yes{% endif %}";
let result = preprocess(input);
assert_eq!(result, "{% if Arch == \"amd64\" %}yes{% endif %}");
}
#[test]
fn test_eq_with_numeric_literal() {
let input = "{% if eq Major 1 %}v1{% endif %}";
let result = preprocess(input);
assert_eq!(result, "{% if Major == 1 %}v1{% endif %}");
}
#[test]
fn test_eq_parenthesized_not() {
let input = "{% if not (eq Os \"windows\") %}yes{% endif %}";
let result = preprocess(input);
assert_eq!(result, "{% if not Os == \"windows\" %}yes{% endif %}");
}
#[test]
fn test_eq_in_elif_block() {
let input = "{% if eq Os \"linux\" %}lin{% elif eq Os \"darwin\" %}mac{% endif %}";
let result = preprocess(input);
assert_eq!(
result,
"{% if Os == \"linux\" %}lin{% elif Os == \"darwin\" %}mac{% endif %}"
);
}
#[test]
fn test_eq_in_expression_block() {
let input = "{{ eq Os \"linux\" }}";
let result = preprocess(input);
assert_eq!(result, "{{ Os == \"linux\" }}");
}
#[test]
fn test_eq_with_already_stripped_dot_var() {
let input = "{% if eq Os \"windows\" %}win{% endif %}";
let result = preprocess(input);
assert_eq!(result, "{% if Os == \"windows\" %}win{% endif %}");
}
#[test]
fn test_eq_with_dotted_path() {
let input = "{% if eq Env.FOO \"bar\" %}yes{% endif %}";
let result = preprocess(input);
assert_eq!(result, "{% if Env.FOO == \"bar\" %}yes{% endif %}");
}
#[test]
fn test_and_prefix_to_infix() {
let input = "{% if and A B %}yes{% endif %}";
let result = preprocess(input);
assert_eq!(result, "{% if A and B %}yes{% endif %}");
}
#[test]
fn test_or_prefix_to_infix() {
let input = "{% if or A B %}yes{% endif %}";
let result = preprocess(input);
assert_eq!(result, "{% if A or B %}yes{% endif %}");
}
#[test]
fn test_and_with_parenthesized_or() {
let input = "{% if and A (or B C) %}yes{% endif %}";
let result = preprocess(input);
assert_eq!(result, "{% if A and (B or C) %}yes{% endif %}");
}
#[test]
fn test_or_with_parenthesized_eq() {
let input = "{% if or (eq Os \"linux\") (eq Os \"darwin\") %}yes{% endif %}";
let result = preprocess(input);
assert_eq!(
result,
"{% if Os == \"linux\" or Os == \"darwin\" %}yes{% endif %}"
);
}
#[test]
fn test_len_in_expression() {
let input = "{{ len Items }}";
let result = preprocess(input);
assert_eq!(result, "{{ Items | length }}");
}
#[test]
fn test_len_in_if_block() {
let input = "{% if len Items %}has items{% endif %}";
let result = preprocess(input);
assert_eq!(result, "{% if Items | length %}has items{% endif %}");
}
#[test]
fn test_len_with_dotted_path() {
let input = "{{ len Env.PATH }}";
let result = preprocess(input);
assert_eq!(result, "{{ Env.PATH | length }}");
}
#[test]
fn test_len_does_not_match_partial_word() {
let input = "{{ Items | length }}";
let result = preprocess(input);
assert_eq!(result, "{{ Items | length }}");
}
#[test]
fn test_map_positional_two_args() {
let input = "{{ map \"a\" \"1\" }}";
let result = preprocess(input);
assert_eq!(result, "{{ map(pairs=[\"a\", \"1\"]) }}");
}
#[test]
fn test_map_positional_four_args() {
let input = "{{ map \"a\" \"1\" \"b\" \"2\" }}";
let result = preprocess(input);
assert_eq!(result, "{{ map(pairs=[\"a\", \"1\", \"b\", \"2\"]) }}");
}
#[test]
fn test_map_named_args_unchanged() {
let input = "{{ map(pairs=[\"a\", \"1\"]) }}";
let result = preprocess(input);
assert_eq!(result, input);
}
#[test]
fn test_map_in_set_block() {
let input = "{% set m = map \"x\" \"y\" %}";
let result = preprocess(input);
assert_eq!(result, "{% set m = map(pairs=[\"x\", \"y\"]) %}");
}
#[test]
fn test_index_positional_two_args() {
let input = "{{ index myMap \"key\" }}";
let result = preprocess(input);
assert_eq!(result, "{{ index(collection=myMap, key=\"key\") }}");
}
#[test]
fn test_index_named_args_unchanged() {
let input = "{{ index(collection=myMap, key=\"key\") }}";
let result = preprocess(input);
assert_eq!(result, input);
}
#[test]
fn test_index_in_control_block() {
let input = "{% if index myMap \"key\" %}yes{% endif %}";
let result = preprocess(input);
assert_eq!(
result,
"{% if index(collection=myMap, key=\"key\") %}yes{% endif %}"
);
}
#[test]
fn test_go_style_full_pipeline_eq_and_map() {
let input = "{{ $m := map \"a\" \"1\" }}{{ if eq (index $m \"a\") \"1\" }}yes{{ end }}";
let result = preprocess(input);
assert_eq!(
result,
"{% set m = map(pairs=[\"a\", \"1\"]) %}{% if (index m \"a\") == \"1\" %}yes{% endif %}"
);
}
}