syncable_cli/analyzer/helmlint/parser/
helpers.rs

1//! Helper template parser.
2//!
3//! Parses _helpers.tpl files to extract defined template helpers.
4
5use std::collections::HashSet;
6use std::path::Path;
7
8use crate::analyzer::helmlint::parser::template::{ParsedTemplate, TemplateToken, parse_template};
9
10/// A helper template definition.
11#[derive(Debug, Clone)]
12pub struct HelperDefinition {
13    /// The name of the helper (e.g., "mychart.fullname").
14    pub name: String,
15    /// The line number where the helper is defined.
16    pub line: u32,
17    /// The content of the helper definition.
18    pub content: String,
19    /// Documentation comment (if any).
20    pub doc_comment: Option<String>,
21}
22
23/// Parsed helpers file.
24#[derive(Debug, Clone)]
25pub struct ParsedHelpers {
26    /// Path to the helpers file.
27    pub path: String,
28    /// All defined helpers.
29    pub helpers: Vec<HelperDefinition>,
30    /// All helper names for quick lookup.
31    pub helper_names: HashSet<String>,
32    /// The underlying template parse result.
33    pub template: ParsedTemplate,
34}
35
36impl ParsedHelpers {
37    /// Check if a helper is defined.
38    pub fn has_helper(&self, name: &str) -> bool {
39        self.helper_names.contains(name)
40    }
41
42    /// Get a helper by name.
43    pub fn get_helper(&self, name: &str) -> Option<&HelperDefinition> {
44        self.helpers.iter().find(|h| h.name == name)
45    }
46
47    /// Get all helper names.
48    pub fn names(&self) -> impl Iterator<Item = &str> {
49        self.helper_names.iter().map(|s| s.as_str())
50    }
51}
52
53/// Parse a helpers file.
54pub fn parse_helpers(content: &str, path: &str) -> ParsedHelpers {
55    let template = parse_template(content, path);
56    let mut helpers = Vec::new();
57    let mut helper_names = HashSet::new();
58
59    // Track the previous comment for documentation
60    let mut last_comment: Option<(String, u32)> = None;
61
62    // Look for define blocks
63    let mut i = 0;
64    while i < template.tokens.len() {
65        let token = &template.tokens[i];
66
67        match token {
68            TemplateToken::Comment { content, line } => {
69                // Save comment as potential documentation
70                last_comment = Some((content.clone(), *line));
71            }
72            TemplateToken::Action { content, line, .. } => {
73                let trimmed = content.trim();
74                if trimmed.starts_with("define ") {
75                    // Extract helper name
76                    if let Some(name) = extract_define_name(trimmed) {
77                        // Collect the helper content until we hit the matching end
78                        let mut helper_content = String::new();
79                        let mut depth = 1;
80                        let mut j = i + 1;
81
82                        while j < template.tokens.len() && depth > 0 {
83                            match &template.tokens[j] {
84                                TemplateToken::Action {
85                                    content: inner_content,
86                                    ..
87                                } => {
88                                    let inner_trimmed = inner_content.trim();
89                                    if inner_trimmed.starts_with("define ")
90                                        || inner_trimmed.starts_with("if ")
91                                        || inner_trimmed.starts_with("range ")
92                                        || inner_trimmed.starts_with("with ")
93                                        || inner_trimmed.starts_with("block ")
94                                    {
95                                        depth += 1;
96                                    } else if inner_trimmed == "end" {
97                                        depth -= 1;
98                                        if depth == 0 {
99                                            break;
100                                        }
101                                    }
102                                    if depth > 0 {
103                                        helper_content
104                                            .push_str(&format!("{{{{ {} }}}}", inner_content));
105                                    }
106                                }
107                                TemplateToken::Text {
108                                    content: text_content,
109                                    ..
110                                } => {
111                                    helper_content.push_str(text_content);
112                                }
113                                TemplateToken::Comment {
114                                    content: comment_content,
115                                    ..
116                                } => {
117                                    helper_content
118                                        .push_str(&format!("{{{{/* {} */}}}}", comment_content));
119                                }
120                            }
121                            j += 1;
122                        }
123
124                        // Check if previous comment is documentation (within a few lines)
125                        // The comment line is the starting line of the comment, which may be
126                        // several lines before the define if it's a multi-line comment
127                        let doc_comment = last_comment
128                            .take()
129                            .filter(|(_, comment_line)| {
130                                *line > *comment_line && *line - *comment_line <= 5
131                            })
132                            .map(|(c, _)| c);
133
134                        helpers.push(HelperDefinition {
135                            name: name.clone(),
136                            line: *line,
137                            content: helper_content.trim().to_string(),
138                            doc_comment,
139                        });
140                        helper_names.insert(name);
141                    }
142                }
143
144                // Clear comment if this isn't immediately after a comment
145                if !content.trim().starts_with("define ") {
146                    last_comment = None;
147                }
148            }
149            TemplateToken::Text { .. } => {
150                // Only clear comment if there's non-whitespace text
151                if !token.content().trim().is_empty() {
152                    last_comment = None;
153                }
154            }
155        }
156        i += 1;
157    }
158
159    ParsedHelpers {
160        path: path.to_string(),
161        helpers,
162        helper_names,
163        template,
164    }
165}
166
167/// Parse a helpers file from disk.
168pub fn parse_helpers_file(path: &Path) -> Result<ParsedHelpers, std::io::Error> {
169    let content = std::fs::read_to_string(path)?;
170    Ok(parse_helpers(&content, &path.display().to_string()))
171}
172
173/// Extract the name from a define action.
174fn extract_define_name(content: &str) -> Option<String> {
175    // Pattern: define "name"
176    let parts: Vec<&str> = content.split('"').collect();
177    if parts.len() >= 2 {
178        let name = parts[1].trim();
179        if !name.is_empty() {
180            return Some(name.to_string());
181        }
182    }
183    None
184}
185
186/// Common helper names that charts typically define.
187pub const COMMON_HELPERS: &[&str] = &[
188    "chart",
189    "name",
190    "fullname",
191    "labels",
192    "selectorLabels",
193    "serviceAccountName",
194    "image",
195];
196
197/// Check if a helper name follows the expected pattern.
198pub fn is_valid_helper_name(name: &str) -> bool {
199    // Should be chart.name or similar
200    if name.is_empty() {
201        return false;
202    }
203
204    // Allow alphanumeric, dots, hyphens, and underscores
205    name.chars()
206        .all(|c| c.is_alphanumeric() || c == '.' || c == '-' || c == '_')
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    #[test]
214    fn test_parse_helpers() {
215        let content = r#"
216{{/*
217Get the name of the chart.
218*/}}
219{{- define "mychart.name" -}}
220{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
221{{- end }}
222
223{{- define "mychart.fullname" -}}
224{{- if .Values.fullnameOverride }}
225{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
226{{- else }}
227{{- $name := default .Chart.Name .Values.nameOverride }}
228{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
229{{- end }}
230{{- end }}
231
232{{- define "mychart.labels" -}}
233app.kubernetes.io/name: {{ include "mychart.name" . }}
234app.kubernetes.io/instance: {{ .Release.Name }}
235{{- end }}
236"#;
237        let parsed = parse_helpers(content, "_helpers.tpl");
238
239        assert!(parsed.has_helper("mychart.name"));
240        assert!(parsed.has_helper("mychart.fullname"));
241        assert!(parsed.has_helper("mychart.labels"));
242        assert_eq!(parsed.helpers.len(), 3);
243
244        // Check documentation comment
245        let name_helper = parsed.get_helper("mychart.name").unwrap();
246        assert!(name_helper.doc_comment.is_some());
247        assert!(
248            name_helper
249                .doc_comment
250                .as_ref()
251                .unwrap()
252                .contains("Get the name")
253        );
254    }
255
256    #[test]
257    fn test_parse_empty_helpers() {
258        let content = "";
259        let parsed = parse_helpers(content, "_helpers.tpl");
260        assert!(parsed.helpers.is_empty());
261    }
262
263    #[test]
264    fn test_valid_helper_name() {
265        assert!(is_valid_helper_name("mychart.name"));
266        assert!(is_valid_helper_name("my-chart.full_name"));
267        assert!(is_valid_helper_name("common.labels"));
268        assert!(!is_valid_helper_name(""));
269        assert!(!is_valid_helper_name("has space"));
270        assert!(!is_valid_helper_name("has:colon"));
271    }
272
273    #[test]
274    fn test_helper_content() {
275        let content = r#"
276{{- define "simple.helper" -}}
277hello world
278{{- end }}
279"#;
280        let parsed = parse_helpers(content, "_helpers.tpl");
281        let helper = parsed.get_helper("simple.helper").unwrap();
282        assert!(helper.content.contains("hello world"));
283    }
284
285    #[test]
286    fn test_nested_structures() {
287        let content = r#"
288{{- define "mychart.conditional" -}}
289{{- if .Values.enabled }}
290enabled
291{{- else }}
292disabled
293{{- end }}
294{{- end }}
295"#;
296        let parsed = parse_helpers(content, "_helpers.tpl");
297        assert!(parsed.has_helper("mychart.conditional"));
298        assert!(parsed.template.errors.is_empty());
299    }
300}