syncable_cli/analyzer/helmlint/parser/
template.rs

1//! Go template parser for Helm templates.
2//!
3//! Tokenizes Go templates for static analysis without full evaluation.
4
5use std::collections::HashSet;
6use std::path::Path;
7
8/// A token in a Go template.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum TemplateToken {
11    /// Raw text outside of template delimiters
12    Text { content: String, line: u32 },
13    /// Template action: {{ ... }}
14    Action {
15        content: String,
16        line: u32,
17        trim_left: bool,
18        trim_right: bool,
19    },
20    /// Template comment: {{/* ... */}}
21    Comment { content: String, line: u32 },
22}
23
24impl TemplateToken {
25    /// Get the line number of this token.
26    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    /// Check if this is an action token.
35    pub fn is_action(&self) -> bool {
36        matches!(self, Self::Action { .. })
37    }
38
39    /// Get the content of the token.
40    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/// Control structure type.
50#[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    /// Parse from action content.
65    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    /// Check if this starts a block (needs matching end).
89    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    /// Check if this ends a block.
97    pub fn ends_block(&self) -> bool {
98        matches!(self, Self::End)
99    }
100}
101
102/// A parsed Go template with analysis data.
103#[derive(Debug, Clone)]
104pub struct ParsedTemplate {
105    /// The original file path.
106    pub path: String,
107    /// All tokens in the template.
108    pub tokens: Vec<TemplateToken>,
109    /// All variables referenced (e.g., ".Values.image", ".Release.Name").
110    pub variables_used: HashSet<String>,
111    /// All functions called (e.g., "include", "tpl", "default").
112    pub functions_called: HashSet<String>,
113    /// Defined template names (from define/block).
114    pub defined_templates: HashSet<String>,
115    /// Referenced template names (from template/include).
116    pub referenced_templates: HashSet<String>,
117    /// Control structure stack tracking.
118    pub unclosed_blocks: Vec<(ControlStructure, u32)>,
119    /// Parse errors encountered.
120    pub errors: Vec<TemplateParseError>,
121}
122
123impl ParsedTemplate {
124    /// Get all .Values references.
125    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    /// Get all .Release references.
134    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    /// Check if the template has unclosed blocks.
143    pub fn has_unclosed_blocks(&self) -> bool {
144        !self.unclosed_blocks.is_empty()
145    }
146
147    /// Check if a function is called.
148    pub fn calls_function(&self, name: &str) -> bool {
149        self.functions_called.contains(name)
150    }
151
152    /// Check if the template uses lookup (requires K8s cluster).
153    pub fn uses_lookup(&self) -> bool {
154        self.functions_called.contains("lookup")
155    }
156
157    /// Check if the template uses tpl (dynamic template execution).
158    pub fn uses_tpl(&self) -> bool {
159        self.functions_called.contains("tpl")
160    }
161}
162
163/// Parse error for templates.
164#[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
176/// Parse a Go template file.
177pub 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(); // consume second {
200
201            // Save any pending text
202            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            // Check for trim marker or comment
212            let trim_left = chars.peek() == Some(&'-');
213            if trim_left {
214                chars.next();
215            }
216
217            let is_comment = chars.peek() == Some(&'/');
218
219            // Collect action content
220            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(); // consume }
231                    if chars.peek() == Some(&'}') {
232                        chars.next(); // consume second }
233                        found_end = true;
234                        break;
235                    }
236                } else if c == '}' && chars.peek() == Some(&'}') {
237                    chars.next(); // consume second }
238                    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            // Process the action
253            let trimmed_content = action_content.trim();
254
255            if is_comment {
256                // Remove /* and */ from comment
257                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 the action content
276                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    // Save any remaining text
297    if !current_text.is_empty() {
298        tokens.push(TemplateToken::Text {
299            content: current_text,
300            line: text_start_line,
301        });
302    }
303
304    // Report unclosed blocks
305    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
324/// Parse a template from a file.
325pub 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
330/// Analyze a template action for variables, functions, and control structures.
331fn 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    // Handle control structures
343    if let Some(structure) = ControlStructure::parse(trimmed) {
344        match &structure {
345            ControlStructure::Define | ControlStructure::Block => {
346                // Extract template name
347                if let Some(name) = extract_template_name(trimmed) {
348                    defined.insert(name);
349                }
350                block_stack.push((structure, line));
351            }
352            ControlStructure::Template => {
353                // Extract referenced template name
354                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 (things starting with .)
369    extract_variables(trimmed, variables);
370
371    // Extract function calls
372    extract_functions(trimmed, functions, referenced);
373}
374
375/// Extract variable references from action content.
376fn 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            // Start of a variable reference
384            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                // End of variable
391                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    // Don't forget the last variable
401    if !current_var.is_empty() && current_var.len() > 1 {
402        variables.insert(current_var);
403    }
404}
405
406/// Extract function calls from action content.
407fn extract_functions(
408    content: &str,
409    functions: &mut HashSet<String>,
410    referenced: &mut HashSet<String>,
411) {
412    // Common Helm/Sprig functions to detect
413    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    // Extract include/template references
482    if content.contains("include") || content.contains("template") {
483        // Try to extract the template name from include "name" or template "name"
484        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
494/// Extract template name from define/block/template action.
495fn extract_template_name(content: &str) -> Option<String> {
496    // Pattern: define "name" or template "name" or block "name"
497    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}