1use std::collections::HashSet;
6use std::path::Path;
7
8#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum TemplateToken {
11 Text { content: String, line: u32 },
13 Action {
15 content: String,
16 line: u32,
17 trim_left: bool,
18 trim_right: bool,
19 },
20 Comment { content: String, line: u32 },
22}
23
24impl TemplateToken {
25 pub fn line(&self) -> u32 {
27 match self {
28 Self::Text { line, .. } => *line,
29 Self::Action { line, .. } => *line,
30 Self::Comment { line, .. } => *line,
31 }
32 }
33
34 pub fn is_action(&self) -> bool {
36 matches!(self, Self::Action { .. })
37 }
38
39 pub fn content(&self) -> &str {
41 match self {
42 Self::Text { content, .. } => content,
43 Self::Action { content, .. } => content,
44 Self::Comment { content, .. } => content,
45 }
46 }
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum ControlStructure {
52 If,
53 Else,
54 ElseIf,
55 Range,
56 With,
57 Define,
58 Block,
59 Template,
60 End,
61}
62
63impl ControlStructure {
64 pub fn parse(content: &str) -> Option<Self> {
66 let trimmed = content.trim();
67 let first_word = trimmed.split_whitespace().next()?;
68
69 match first_word {
70 "if" => Some(Self::If),
71 "else" => {
72 if trimmed.starts_with("else if") {
73 Some(Self::ElseIf)
74 } else {
75 Some(Self::Else)
76 }
77 }
78 "range" => Some(Self::Range),
79 "with" => Some(Self::With),
80 "define" => Some(Self::Define),
81 "block" => Some(Self::Block),
82 "template" => Some(Self::Template),
83 "end" => Some(Self::End),
84 _ => None,
85 }
86 }
87
88 pub fn starts_block(&self) -> bool {
90 matches!(
91 self,
92 Self::If | Self::Range | Self::With | Self::Define | Self::Block
93 )
94 }
95
96 pub fn ends_block(&self) -> bool {
98 matches!(self, Self::End)
99 }
100}
101
102#[derive(Debug, Clone)]
104pub struct ParsedTemplate {
105 pub path: String,
107 pub tokens: Vec<TemplateToken>,
109 pub variables_used: HashSet<String>,
111 pub functions_called: HashSet<String>,
113 pub defined_templates: HashSet<String>,
115 pub referenced_templates: HashSet<String>,
117 pub unclosed_blocks: Vec<(ControlStructure, u32)>,
119 pub errors: Vec<TemplateParseError>,
121}
122
123impl ParsedTemplate {
124 pub fn values_references(&self) -> Vec<&str> {
126 self.variables_used
127 .iter()
128 .filter(|v| v.starts_with(".Values."))
129 .map(|s| s.as_str())
130 .collect()
131 }
132
133 pub fn release_references(&self) -> Vec<&str> {
135 self.variables_used
136 .iter()
137 .filter(|v| v.starts_with(".Release."))
138 .map(|s| s.as_str())
139 .collect()
140 }
141
142 pub fn has_unclosed_blocks(&self) -> bool {
144 !self.unclosed_blocks.is_empty()
145 }
146
147 pub fn calls_function(&self, name: &str) -> bool {
149 self.functions_called.contains(name)
150 }
151
152 pub fn uses_lookup(&self) -> bool {
154 self.functions_called.contains("lookup")
155 }
156
157 pub fn uses_tpl(&self) -> bool {
159 self.functions_called.contains("tpl")
160 }
161}
162
163#[derive(Debug, Clone)]
165pub struct TemplateParseError {
166 pub message: String,
167 pub line: u32,
168}
169
170impl std::fmt::Display for TemplateParseError {
171 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
172 write!(f, "line {}: {}", self.line, self.message)
173 }
174}
175
176pub fn parse_template(content: &str, path: &str) -> ParsedTemplate {
178 let mut tokens = Vec::new();
179 let mut variables_used = HashSet::new();
180 let mut functions_called = HashSet::new();
181 let mut defined_templates = HashSet::new();
182 let mut referenced_templates = HashSet::new();
183 let mut errors = Vec::new();
184 let mut block_stack: Vec<(ControlStructure, u32)> = Vec::new();
185
186 let mut line_num: u32 = 1;
187 let mut chars = content.chars().peekable();
188 let mut current_text = String::new();
189 let mut text_start_line = 1;
190
191 while let Some(c) = chars.next() {
192 if c == '\n' {
193 current_text.push(c);
194 line_num += 1;
195 continue;
196 }
197
198 if c == '{' && chars.peek() == Some(&'{') {
199 chars.next(); if !current_text.is_empty() {
203 tokens.push(TemplateToken::Text {
204 content: std::mem::take(&mut current_text),
205 line: text_start_line,
206 });
207 }
208
209 let action_start_line = line_num;
210
211 let trim_left = chars.peek() == Some(&'-');
213 if trim_left {
214 chars.next();
215 }
216
217 let is_comment = chars.peek() == Some(&'/');
218
219 let mut action_content = String::new();
221 let mut found_end = false;
222 let mut trim_right = false;
223
224 while let Some(c) = chars.next() {
225 if c == '\n' {
226 line_num += 1;
227 action_content.push(c);
228 } else if c == '-' && chars.peek() == Some(&'}') {
229 trim_right = true;
230 chars.next(); if chars.peek() == Some(&'}') {
232 chars.next(); found_end = true;
234 break;
235 }
236 } else if c == '}' && chars.peek() == Some(&'}') {
237 chars.next(); found_end = true;
239 break;
240 } else {
241 action_content.push(c);
242 }
243 }
244
245 if !found_end {
246 errors.push(TemplateParseError {
247 message: "Unclosed template action".to_string(),
248 line: action_start_line,
249 });
250 }
251
252 let trimmed_content = action_content.trim();
254
255 if is_comment {
256 let comment = trimmed_content
258 .trim_start_matches('/')
259 .trim_start_matches('*')
260 .trim_end_matches('*')
261 .trim_end_matches('/')
262 .trim();
263 tokens.push(TemplateToken::Comment {
264 content: comment.to_string(),
265 line: action_start_line,
266 });
267 } else {
268 tokens.push(TemplateToken::Action {
269 content: trimmed_content.to_string(),
270 line: action_start_line,
271 trim_left,
272 trim_right,
273 });
274
275 analyze_action(
277 trimmed_content,
278 action_start_line,
279 &mut variables_used,
280 &mut functions_called,
281 &mut defined_templates,
282 &mut referenced_templates,
283 &mut block_stack,
284 );
285 }
286
287 text_start_line = line_num;
288 } else {
289 if current_text.is_empty() {
290 text_start_line = line_num;
291 }
292 current_text.push(c);
293 }
294 }
295
296 if !current_text.is_empty() {
298 tokens.push(TemplateToken::Text {
299 content: current_text,
300 line: text_start_line,
301 });
302 }
303
304 for (structure, line) in &block_stack {
306 errors.push(TemplateParseError {
307 message: format!("Unclosed {:?} block", structure),
308 line: *line,
309 });
310 }
311
312 ParsedTemplate {
313 path: path.to_string(),
314 tokens,
315 variables_used,
316 functions_called,
317 defined_templates,
318 referenced_templates,
319 unclosed_blocks: block_stack,
320 errors,
321 }
322}
323
324pub fn parse_template_file(path: &Path) -> Result<ParsedTemplate, std::io::Error> {
326 let content = std::fs::read_to_string(path)?;
327 Ok(parse_template(&content, &path.display().to_string()))
328}
329
330fn analyze_action(
332 content: &str,
333 line: u32,
334 variables: &mut HashSet<String>,
335 functions: &mut HashSet<String>,
336 defined: &mut HashSet<String>,
337 referenced: &mut HashSet<String>,
338 block_stack: &mut Vec<(ControlStructure, u32)>,
339) {
340 let trimmed = content.trim();
341
342 if let Some(structure) = ControlStructure::parse(trimmed) {
344 match &structure {
345 ControlStructure::Define | ControlStructure::Block => {
346 if let Some(name) = extract_template_name(trimmed) {
348 defined.insert(name);
349 }
350 block_stack.push((structure, line));
351 }
352 ControlStructure::Template => {
353 if let Some(name) = extract_template_name(trimmed) {
355 referenced.insert(name);
356 }
357 }
358 ControlStructure::End => {
359 block_stack.pop();
360 }
361 s if s.starts_block() => {
362 block_stack.push((structure, line));
363 }
364 _ => {}
365 }
366 }
367
368 extract_variables(trimmed, variables);
370
371 extract_functions(trimmed, functions, referenced);
373}
374
375fn extract_variables(content: &str, variables: &mut HashSet<String>) {
377 let chars = content.chars();
378 let mut current_var = String::new();
379 let mut in_var = false;
380
381 for c in chars {
382 if c == '.' && !in_var {
383 in_var = true;
385 current_var.push(c);
386 } else if in_var {
387 if c.is_alphanumeric() || c == '_' || c == '.' {
388 current_var.push(c);
389 } else {
390 if !current_var.is_empty() && current_var.len() > 1 {
392 variables.insert(std::mem::take(&mut current_var));
393 }
394 current_var.clear();
395 in_var = false;
396 }
397 }
398 }
399
400 if !current_var.is_empty() && current_var.len() > 1 {
402 variables.insert(current_var);
403 }
404}
405
406fn extract_functions(
408 content: &str,
409 functions: &mut HashSet<String>,
410 referenced: &mut HashSet<String>,
411) {
412 let known_functions = [
414 "include",
415 "tpl",
416 "lookup",
417 "required",
418 "default",
419 "empty",
420 "coalesce",
421 "toYaml",
422 "toJson",
423 "fromYaml",
424 "fromJson",
425 "indent",
426 "nindent",
427 "trim",
428 "trimAll",
429 "trimPrefix",
430 "trimSuffix",
431 "quote",
432 "squote",
433 "upper",
434 "lower",
435 "title",
436 "untitle",
437 "substr",
438 "replace",
439 "trunc",
440 "list",
441 "dict",
442 "get",
443 "set",
444 "unset",
445 "hasKey",
446 "keys",
447 "values",
448 "merge",
449 "mergeOverwrite",
450 "append",
451 "prepend",
452 "concat",
453 "first",
454 "last",
455 "printf",
456 "print",
457 "println",
458 "fail",
459 "kindOf",
460 "typeOf",
461 "deepEqual",
462 "b64enc",
463 "b64dec",
464 "sha256sum",
465 "randAlphaNum",
466 "randAlpha",
467 "now",
468 "date",
469 "dateModify",
470 "toDate",
471 "env",
472 "expandenv",
473 ];
474
475 for func in known_functions {
476 if content.contains(func) {
477 functions.insert(func.to_string());
478 }
479 }
480
481 if content.contains("include") || content.contains("template") {
483 let parts: Vec<&str> = content.split('"').collect();
485 if parts.len() >= 2 {
486 let name = parts[1].trim();
487 if !name.is_empty() {
488 referenced.insert(name.to_string());
489 }
490 }
491 }
492}
493
494fn extract_template_name(content: &str) -> Option<String> {
496 let parts: Vec<&str> = content.split('"').collect();
498 if parts.len() >= 2 {
499 let name = parts[1].trim();
500 if !name.is_empty() {
501 return Some(name.to_string());
502 }
503 }
504 None
505}
506
507#[cfg(test)]
508mod tests {
509 use super::*;
510
511 #[test]
512 fn test_parse_simple_template() {
513 let content = r#"apiVersion: v1
514kind: ConfigMap
515metadata:
516 name: {{ .Release.Name }}-config
517data:
518 value: {{ .Values.config.value }}
519"#;
520 let parsed = parse_template(content, "configmap.yaml");
521 assert!(parsed.errors.is_empty());
522 assert!(parsed.variables_used.contains(".Release.Name"));
523 assert!(parsed.variables_used.contains(".Values.config.value"));
524 }
525
526 #[test]
527 fn test_parse_control_structures() {
528 let content = r#"{{- if .Values.enabled }}
529apiVersion: v1
530kind: Service
531{{- end }}
532"#;
533 let parsed = parse_template(content, "service.yaml");
534 assert!(parsed.errors.is_empty());
535 assert!(parsed.unclosed_blocks.is_empty());
536 }
537
538 #[test]
539 fn test_unclosed_block() {
540 let content = r#"{{- if .Values.enabled }}
541apiVersion: v1
542kind: Service
543"#;
544 let parsed = parse_template(content, "service.yaml");
545 assert!(!parsed.errors.is_empty());
546 assert!(parsed.has_unclosed_blocks());
547 }
548
549 #[test]
550 fn test_detect_functions() {
551 let content = r#"
552{{ include "mychart.labels" . }}
553{{ .Values.name | default "default-name" | quote }}
554{{ toYaml .Values.config | nindent 4 }}
555"#;
556 let parsed = parse_template(content, "deployment.yaml");
557 assert!(parsed.calls_function("include"));
558 assert!(parsed.calls_function("default"));
559 assert!(parsed.calls_function("quote"));
560 assert!(parsed.calls_function("toYaml"));
561 assert!(parsed.calls_function("nindent"));
562 }
563
564 #[test]
565 fn test_detect_lookup() {
566 let content = r#"
567{{- $secret := lookup "v1" "Secret" .Release.Namespace "my-secret" }}
568"#;
569 let parsed = parse_template(content, "secret.yaml");
570 assert!(parsed.uses_lookup());
571 }
572
573 #[test]
574 fn test_detect_tpl() {
575 let content = r#"
576{{ tpl .Values.customTemplate . }}
577"#;
578 let parsed = parse_template(content, "custom.yaml");
579 assert!(parsed.uses_tpl());
580 }
581
582 #[test]
583 fn test_parse_define() {
584 let content = r#"
585{{- define "mychart.name" -}}
586{{ .Chart.Name }}
587{{- end -}}
588"#;
589 let parsed = parse_template(content, "_helpers.tpl");
590 assert!(parsed.errors.is_empty());
591 assert!(parsed.defined_templates.contains("mychart.name"));
592 }
593
594 #[test]
595 fn test_parse_comment() {
596 let content = r#"
597{{/* This is a comment */}}
598apiVersion: v1
599"#;
600 let parsed = parse_template(content, "test.yaml");
601 let comments: Vec<_> = parsed
602 .tokens
603 .iter()
604 .filter(|t| matches!(t, TemplateToken::Comment { .. }))
605 .collect();
606 assert_eq!(comments.len(), 1);
607 }
608
609 #[test]
610 fn test_values_references() {
611 let content = r#"
612image: {{ .Values.image.repository }}:{{ .Values.image.tag }}
613replicas: {{ .Values.replicaCount }}
614"#;
615 let parsed = parse_template(content, "deployment.yaml");
616 let refs = parsed.values_references();
617 assert!(refs.contains(&".Values.image.repository"));
618 assert!(refs.contains(&".Values.image.tag"));
619 assert!(refs.contains(&".Values.replicaCount"));
620 }
621
622 #[test]
623 fn test_unclosed_action() {
624 let content = "{{ .Values.name";
625 let parsed = parse_template(content, "test.yaml");
626 assert!(!parsed.errors.is_empty());
627 assert!(parsed.errors[0].message.contains("Unclosed"));
628 }
629
630 #[test]
631 fn test_trim_markers() {
632 let content = "{{- .Values.name -}}";
633 let parsed = parse_template(content, "test.yaml");
634 if let Some(TemplateToken::Action {
635 trim_left,
636 trim_right,
637 ..
638 }) = parsed.tokens.first()
639 {
640 assert!(*trim_left);
641 assert!(*trim_right);
642 } else {
643 panic!("Expected Action token");
644 }
645 }
646}