use super::blocks::{raw_span_at, raw_spans};
use super::dots_dollars::strip_dollar_vars;
use super::positional::rewrite_expr_tokens;
use super::static_regex;
use super::tokens::{Token, significant_tokens, token_to_str, tokenize_block};
use regex::Regex;
use std::sync::LazyLock;
static GO_IF_RE: LazyLock<Regex> =
LazyLock::new(|| static_regex(r"(?s)^\{\{(-?)\s*if\s+(.+?)\s*(-?)\}\}"));
static GO_ELSE_IF_RE: LazyLock<Regex> =
LazyLock::new(|| static_regex(r"(?s)^\{\{(-?)\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)^\{\{(-?)\s*range\s+\$(\w+)\s*,\s*\$(\w+)\s*:=\s*(.+?)\s*(-?)\}\}")
});
static GO_RANGE_V_RE: LazyLock<Regex> = LazyLock::new(|| {
static_regex(r"(?s)^\{\{(-?)\s*range\s+(?:\$(\w+)\s*:=\s*)?(.+?)\s*(-?)\}\}")
});
static GO_WITH_RE: LazyLock<Regex> =
LazyLock::new(|| static_regex(r"(?s)^\{\{(-?)\s*with\s+(.+?)\s*(-?)\}\}"));
static GO_VAR_ASSIGN_RE: LazyLock<Regex> = LazyLock::new(|| {
static_regex(r"(?s)^\{\{(-?)\s*\$(\w+)\s*:=\s*(.+?)\s*(-?)\}\}")
});
static GO_DOT_RE: LazyLock<Regex> = LazyLock::new(|| static_regex(r"^\{\{(-?)\s*\.\s*(-?)\}\}"));
pub(super) fn push_char_at(out: &mut String, s: &str, i: usize) -> usize {
let ch = s[i..].chars().next().expect("byte index at char boundary");
out.push(ch);
ch.len_utf8()
}
fn tera_block(ltrim: &str, content: &str, rtrim: &str) -> String {
let l = if ltrim == "-" { "{%-" } else { "{%" };
let r = if rtrim == "-" { "-%}" } else { "%}" };
format!("{l} {content} {r}")
}
pub(super) 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();
let raw = raw_spans(template);
while pos < bytes.len() {
if let Some(span) = raw_span_at(&raw, pos) {
result.push_str(&template[pos..span.end]);
pos = span.end;
continue;
}
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;
}
}
pos += push_char_at(&mut result, template, pos);
}
strip_dollar_vars(&result)
}
pub(super) 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)
}
pub(super) 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,
};
let keyword_end_idx = tokens
.iter()
.position(|t| matches!(t, Token::Ident(k) if k == keyword))
.map(|i| i + 1)?;
let expr_start = match keyword {
"if" | "elif" => keyword_end_idx,
"for" => index_after(
&tokens,
keyword_end_idx,
|t| matches!(t, Token::Ident(k) if k == "in"),
)?,
"set" | "set_global" => index_after(
&tokens,
keyword_end_idx,
|t| matches!(t, Token::Other(s) if s == "="),
)?,
_ => return None,
};
let expr_tokens: Vec<Token> = tokens[expr_start..].to_vec();
let rewritten = rewrite_expr_tokens(&expr_tokens)?;
let prefix: String = tokens[..expr_start]
.iter()
.map(|t| token_to_str(t))
.collect();
Some(format!("{}{}", prefix, rewritten))
}
fn index_after(tokens: &[Token], from: usize, pred: impl Fn(&Token) -> bool) -> Option<usize> {
tokens
.iter()
.skip(from)
.position(pred)
.map(|offset| from + offset + 1)
}
#[cfg(test)]
mod tests {
use super::preprocess_go_blocks;
#[test]
fn multiline_go_if_and_else_if_convert() {
assert_eq!(
preprocess_go_blocks(
"{{ if eq .Os\n\"linux\" }}L{{ else if eq .Os\n\"darwin\" }}D{{ end }}"
),
"{% if eq .Os\n\"linux\" %}L{% elif eq .Os\n\"darwin\" %}D{% endif %}"
);
}
#[test]
fn multiline_go_range_converts() {
assert_eq!(
preprocess_go_blocks("{{ range $v := index .M\n\"k\" }}{{ $v }}{{ end }}"),
"{% for v in index .M\n\"k\" %}{{ v }}{% endfor %}"
);
}
#[test]
fn multiline_go_with_converts() {
assert_eq!(
preprocess_go_blocks("{{ with index .M\n\"k\" }}{{ . }}{{ end }}"),
"{% if index .M\n\"k\" %}{{ index .M\n\"k\" }}{% endif %}"
);
}
#[test]
fn multiline_go_var_assign_converts() {
assert_eq!(
preprocess_go_blocks("{{ $v := printf \"%s\"\n.Name }}"),
"{% set v = printf \"%s\"\n.Name %}"
);
}
#[test]
fn adjacent_multiline_blocks_do_not_merge() {
assert_eq!(
preprocess_go_blocks("{{ if eq .A\n1 }}x{{ end }}\n{{ if eq .B\n2 }}y{{ end }}"),
"{% if eq .A\n1 %}x{% endif %}\n{% if eq .B\n2 %}y{% endif %}"
);
}
}