use std::collections::HashSet;
use std::path::Path;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TemplateToken {
Text { content: String, line: u32 },
Action {
content: String,
line: u32,
trim_left: bool,
trim_right: bool,
},
Comment { content: String, line: u32 },
}
impl TemplateToken {
pub fn line(&self) -> u32 {
match self {
Self::Text { line, .. } => *line,
Self::Action { line, .. } => *line,
Self::Comment { line, .. } => *line,
}
}
pub fn is_action(&self) -> bool {
matches!(self, Self::Action { .. })
}
pub fn content(&self) -> &str {
match self {
Self::Text { content, .. } => content,
Self::Action { content, .. } => content,
Self::Comment { content, .. } => content,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ControlStructure {
If,
Else,
ElseIf,
Range,
With,
Define,
Block,
Template,
End,
}
impl ControlStructure {
pub fn parse(content: &str) -> Option<Self> {
let trimmed = content.trim();
let first_word = trimmed.split_whitespace().next()?;
match first_word {
"if" => Some(Self::If),
"else" => {
if trimmed.starts_with("else if") {
Some(Self::ElseIf)
} else {
Some(Self::Else)
}
}
"range" => Some(Self::Range),
"with" => Some(Self::With),
"define" => Some(Self::Define),
"block" => Some(Self::Block),
"template" => Some(Self::Template),
"end" => Some(Self::End),
_ => None,
}
}
pub fn starts_block(&self) -> bool {
matches!(
self,
Self::If | Self::Range | Self::With | Self::Define | Self::Block
)
}
pub fn ends_block(&self) -> bool {
matches!(self, Self::End)
}
}
#[derive(Debug, Clone)]
pub struct ParsedTemplate {
pub path: String,
pub tokens: Vec<TemplateToken>,
pub variables_used: HashSet<String>,
pub functions_called: HashSet<String>,
pub defined_templates: HashSet<String>,
pub referenced_templates: HashSet<String>,
pub unclosed_blocks: Vec<(ControlStructure, u32)>,
pub errors: Vec<TemplateParseError>,
}
impl ParsedTemplate {
pub fn values_references(&self) -> Vec<&str> {
self.variables_used
.iter()
.filter(|v| v.starts_with(".Values."))
.map(|s| s.as_str())
.collect()
}
pub fn release_references(&self) -> Vec<&str> {
self.variables_used
.iter()
.filter(|v| v.starts_with(".Release."))
.map(|s| s.as_str())
.collect()
}
pub fn has_unclosed_blocks(&self) -> bool {
!self.unclosed_blocks.is_empty()
}
pub fn calls_function(&self, name: &str) -> bool {
self.functions_called.contains(name)
}
pub fn uses_lookup(&self) -> bool {
self.functions_called.contains("lookup")
}
pub fn uses_tpl(&self) -> bool {
self.functions_called.contains("tpl")
}
}
#[derive(Debug, Clone)]
pub struct TemplateParseError {
pub message: String,
pub line: u32,
}
impl std::fmt::Display for TemplateParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "line {}: {}", self.line, self.message)
}
}
pub fn parse_template(content: &str, path: &str) -> ParsedTemplate {
let mut tokens = Vec::new();
let mut variables_used = HashSet::new();
let mut functions_called = HashSet::new();
let mut defined_templates = HashSet::new();
let mut referenced_templates = HashSet::new();
let mut errors = Vec::new();
let mut block_stack: Vec<(ControlStructure, u32)> = Vec::new();
let mut line_num: u32 = 1;
let mut chars = content.chars().peekable();
let mut current_text = String::new();
let mut text_start_line = 1;
while let Some(c) = chars.next() {
if c == '\n' {
current_text.push(c);
line_num += 1;
continue;
}
if c == '{' && chars.peek() == Some(&'{') {
chars.next();
if !current_text.is_empty() {
tokens.push(TemplateToken::Text {
content: std::mem::take(&mut current_text),
line: text_start_line,
});
}
let action_start_line = line_num;
let trim_left = chars.peek() == Some(&'-');
if trim_left {
chars.next();
}
let is_comment = chars.peek() == Some(&'/');
let mut action_content = String::new();
let mut found_end = false;
let mut trim_right = false;
while let Some(c) = chars.next() {
if c == '\n' {
line_num += 1;
action_content.push(c);
} else if c == '-' && chars.peek() == Some(&'}') {
trim_right = true;
chars.next(); if chars.peek() == Some(&'}') {
chars.next(); found_end = true;
break;
}
} else if c == '}' && chars.peek() == Some(&'}') {
chars.next(); found_end = true;
break;
} else {
action_content.push(c);
}
}
if !found_end {
errors.push(TemplateParseError {
message: "Unclosed template action".to_string(),
line: action_start_line,
});
}
let trimmed_content = action_content.trim();
if is_comment {
let comment = trimmed_content
.trim_start_matches('/')
.trim_start_matches('*')
.trim_end_matches('*')
.trim_end_matches('/')
.trim();
tokens.push(TemplateToken::Comment {
content: comment.to_string(),
line: action_start_line,
});
} else {
tokens.push(TemplateToken::Action {
content: trimmed_content.to_string(),
line: action_start_line,
trim_left,
trim_right,
});
analyze_action(
trimmed_content,
action_start_line,
&mut variables_used,
&mut functions_called,
&mut defined_templates,
&mut referenced_templates,
&mut block_stack,
);
}
text_start_line = line_num;
} else {
if current_text.is_empty() {
text_start_line = line_num;
}
current_text.push(c);
}
}
if !current_text.is_empty() {
tokens.push(TemplateToken::Text {
content: current_text,
line: text_start_line,
});
}
for (structure, line) in &block_stack {
errors.push(TemplateParseError {
message: format!("Unclosed {:?} block", structure),
line: *line,
});
}
ParsedTemplate {
path: path.to_string(),
tokens,
variables_used,
functions_called,
defined_templates,
referenced_templates,
unclosed_blocks: block_stack,
errors,
}
}
pub fn parse_template_file(path: &Path) -> Result<ParsedTemplate, std::io::Error> {
let content = std::fs::read_to_string(path)?;
Ok(parse_template(&content, &path.display().to_string()))
}
fn analyze_action(
content: &str,
line: u32,
variables: &mut HashSet<String>,
functions: &mut HashSet<String>,
defined: &mut HashSet<String>,
referenced: &mut HashSet<String>,
block_stack: &mut Vec<(ControlStructure, u32)>,
) {
let trimmed = content.trim();
if let Some(structure) = ControlStructure::parse(trimmed) {
match &structure {
ControlStructure::Define | ControlStructure::Block => {
if let Some(name) = extract_template_name(trimmed) {
defined.insert(name);
}
block_stack.push((structure, line));
}
ControlStructure::Template => {
if let Some(name) = extract_template_name(trimmed) {
referenced.insert(name);
}
}
ControlStructure::End => {
block_stack.pop();
}
s if s.starts_block() => {
block_stack.push((structure, line));
}
_ => {}
}
}
extract_variables(trimmed, variables);
extract_functions(trimmed, functions, referenced);
}
fn extract_variables(content: &str, variables: &mut HashSet<String>) {
let chars = content.chars();
let mut current_var = String::new();
let mut in_var = false;
for c in chars {
if c == '.' && !in_var {
in_var = true;
current_var.push(c);
} else if in_var {
if c.is_alphanumeric() || c == '_' || c == '.' {
current_var.push(c);
} else {
if !current_var.is_empty() && current_var.len() > 1 {
variables.insert(std::mem::take(&mut current_var));
}
current_var.clear();
in_var = false;
}
}
}
if !current_var.is_empty() && current_var.len() > 1 {
variables.insert(current_var);
}
}
fn extract_functions(
content: &str,
functions: &mut HashSet<String>,
referenced: &mut HashSet<String>,
) {
let known_functions = [
"include",
"tpl",
"lookup",
"required",
"default",
"empty",
"coalesce",
"toYaml",
"toJson",
"fromYaml",
"fromJson",
"indent",
"nindent",
"trim",
"trimAll",
"trimPrefix",
"trimSuffix",
"quote",
"squote",
"upper",
"lower",
"title",
"untitle",
"substr",
"replace",
"trunc",
"list",
"dict",
"get",
"set",
"unset",
"hasKey",
"keys",
"values",
"merge",
"mergeOverwrite",
"append",
"prepend",
"concat",
"first",
"last",
"printf",
"print",
"println",
"fail",
"kindOf",
"typeOf",
"deepEqual",
"b64enc",
"b64dec",
"sha256sum",
"randAlphaNum",
"randAlpha",
"now",
"date",
"dateModify",
"toDate",
"env",
"expandenv",
];
for func in known_functions {
if content.contains(func) {
functions.insert(func.to_string());
}
}
if content.contains("include") || content.contains("template") {
let parts: Vec<&str> = content.split('"').collect();
if parts.len() >= 2 {
let name = parts[1].trim();
if !name.is_empty() {
referenced.insert(name.to_string());
}
}
}
}
fn extract_template_name(content: &str) -> Option<String> {
let parts: Vec<&str> = content.split('"').collect();
if parts.len() >= 2 {
let name = parts[1].trim();
if !name.is_empty() {
return Some(name.to_string());
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_simple_template() {
let content = r#"apiVersion: v1
kind: ConfigMap
metadata:
name: {{ .Release.Name }}-config
data:
value: {{ .Values.config.value }}
"#;
let parsed = parse_template(content, "configmap.yaml");
assert!(parsed.errors.is_empty());
assert!(parsed.variables_used.contains(".Release.Name"));
assert!(parsed.variables_used.contains(".Values.config.value"));
}
#[test]
fn test_parse_control_structures() {
let content = r#"{{- if .Values.enabled }}
apiVersion: v1
kind: Service
{{- end }}
"#;
let parsed = parse_template(content, "service.yaml");
assert!(parsed.errors.is_empty());
assert!(parsed.unclosed_blocks.is_empty());
}
#[test]
fn test_unclosed_block() {
let content = r#"{{- if .Values.enabled }}
apiVersion: v1
kind: Service
"#;
let parsed = parse_template(content, "service.yaml");
assert!(!parsed.errors.is_empty());
assert!(parsed.has_unclosed_blocks());
}
#[test]
fn test_detect_functions() {
let content = r#"
{{ include "mychart.labels" . }}
{{ .Values.name | default "default-name" | quote }}
{{ toYaml .Values.config | nindent 4 }}
"#;
let parsed = parse_template(content, "deployment.yaml");
assert!(parsed.calls_function("include"));
assert!(parsed.calls_function("default"));
assert!(parsed.calls_function("quote"));
assert!(parsed.calls_function("toYaml"));
assert!(parsed.calls_function("nindent"));
}
#[test]
fn test_detect_lookup() {
let content = r#"
{{- $secret := lookup "v1" "Secret" .Release.Namespace "my-secret" }}
"#;
let parsed = parse_template(content, "secret.yaml");
assert!(parsed.uses_lookup());
}
#[test]
fn test_detect_tpl() {
let content = r#"
{{ tpl .Values.customTemplate . }}
"#;
let parsed = parse_template(content, "custom.yaml");
assert!(parsed.uses_tpl());
}
#[test]
fn test_parse_define() {
let content = r#"
{{- define "mychart.name" -}}
{{ .Chart.Name }}
{{- end -}}
"#;
let parsed = parse_template(content, "_helpers.tpl");
assert!(parsed.errors.is_empty());
assert!(parsed.defined_templates.contains("mychart.name"));
}
#[test]
fn test_parse_comment() {
let content = r#"
{{/* This is a comment */}}
apiVersion: v1
"#;
let parsed = parse_template(content, "test.yaml");
let comments: Vec<_> = parsed
.tokens
.iter()
.filter(|t| matches!(t, TemplateToken::Comment { .. }))
.collect();
assert_eq!(comments.len(), 1);
}
#[test]
fn test_values_references() {
let content = r#"
image: {{ .Values.image.repository }}:{{ .Values.image.tag }}
replicas: {{ .Values.replicaCount }}
"#;
let parsed = parse_template(content, "deployment.yaml");
let refs = parsed.values_references();
assert!(refs.contains(&".Values.image.repository"));
assert!(refs.contains(&".Values.image.tag"));
assert!(refs.contains(&".Values.replicaCount"));
}
#[test]
fn test_unclosed_action() {
let content = "{{ .Values.name";
let parsed = parse_template(content, "test.yaml");
assert!(!parsed.errors.is_empty());
assert!(parsed.errors[0].message.contains("Unclosed"));
}
#[test]
fn test_trim_markers() {
let content = "{{- .Values.name -}}";
let parsed = parse_template(content, "test.yaml");
if let Some(TemplateToken::Action {
trim_left,
trim_right,
..
}) = parsed.tokens.first()
{
assert!(*trim_left);
assert!(*trim_right);
} else {
panic!("Expected Action token");
}
}
}