Skip to main content

normalize_languages/
hcl.rs

1//! HCL (HashiCorp Configuration Language) support.
2
3use crate::external_packages::ResolvedPackage;
4use crate::{Export, Import, Language, Symbol, SymbolKind, Visibility, VisibilityMechanism};
5use std::path::{Path, PathBuf};
6use tree_sitter::Node;
7
8/// HCL language support (Terraform, Packer, etc.).
9pub struct Hcl;
10
11impl Language for Hcl {
12    fn name(&self) -> &'static str {
13        "HCL"
14    }
15    fn extensions(&self) -> &'static [&'static str] {
16        &["tf", "tfvars", "hcl"]
17    }
18    fn grammar_name(&self) -> &'static str {
19        "hcl"
20    }
21
22    fn has_symbols(&self) -> bool {
23        true
24    }
25
26    fn container_kinds(&self) -> &'static [&'static str] {
27        &["block"] // resource, data, module, variable, output, locals, provider
28    }
29
30    fn function_kinds(&self) -> &'static [&'static str] {
31        &[]
32    }
33    fn type_kinds(&self) -> &'static [&'static str] {
34        &[]
35    }
36
37    fn import_kinds(&self) -> &'static [&'static str] {
38        &["block"] // module blocks are imports
39    }
40
41    fn public_symbol_kinds(&self) -> &'static [&'static str] {
42        &["block"]
43    }
44
45    fn visibility_mechanism(&self) -> VisibilityMechanism {
46        VisibilityMechanism::NotApplicable
47    }
48
49    fn extract_public_symbols(&self, node: &Node, content: &str) -> Vec<Export> {
50        if node.kind() != "block" {
51            return Vec::new();
52        }
53
54        let (block_type, name) = match self.extract_block_info(node, content) {
55            Some(info) => info,
56            None => return Vec::new(),
57        };
58
59        let kind = match block_type.as_str() {
60            "resource" | "data" => SymbolKind::Struct,
61            "variable" | "output" | "locals" => SymbolKind::Variable,
62            "module" => SymbolKind::Module,
63            "provider" => SymbolKind::Class,
64            _ => SymbolKind::Variable,
65        };
66
67        vec![Export {
68            name,
69            kind,
70            line: node.start_position().row + 1,
71        }]
72    }
73
74    fn scope_creating_kinds(&self) -> &'static [&'static str] {
75        &["block", "object"]
76    }
77
78    fn control_flow_kinds(&self) -> &'static [&'static str] {
79        &["conditional", "for_expr"]
80    }
81
82    fn complexity_nodes(&self) -> &'static [&'static str] {
83        &["conditional", "for_expr"]
84    }
85
86    fn nesting_nodes(&self) -> &'static [&'static str] {
87        &["block", "object"]
88    }
89
90    fn signature_suffix(&self) -> &'static str {
91        ""
92    }
93
94    fn extract_function(
95        &self,
96        _node: &Node,
97        _content: &str,
98        _in_container: bool,
99    ) -> Option<Symbol> {
100        None
101    }
102
103    fn extract_container(&self, node: &Node, content: &str) -> Option<Symbol> {
104        if node.kind() != "block" {
105            return None;
106        }
107
108        let (block_type, name) = self.extract_block_info(node, content)?;
109
110        let kind = match block_type.as_str() {
111            "resource" | "data" => SymbolKind::Struct,
112            "module" => SymbolKind::Module,
113            "provider" => SymbolKind::Class,
114            _ => SymbolKind::Variable,
115        };
116
117        Some(Symbol {
118            name: name.clone(),
119            kind,
120            signature: format!("{} \"{}\"", block_type, name),
121            docstring: self.extract_docstring(node, content),
122            attributes: Vec::new(),
123            start_line: node.start_position().row + 1,
124            end_line: node.end_position().row + 1,
125            visibility: Visibility::Public,
126            children: Vec::new(),
127            is_interface_impl: false,
128            implements: Vec::new(),
129        })
130    }
131
132    fn extract_type(&self, _node: &Node, _content: &str) -> Option<Symbol> {
133        None
134    }
135
136    fn extract_docstring(&self, node: &Node, content: &str) -> Option<String> {
137        // HCL uses # or // for comments
138        let mut prev = node.prev_sibling();
139        let mut doc_lines = Vec::new();
140
141        while let Some(sibling) = prev {
142            let text = &content[sibling.byte_range()];
143            if sibling.kind() == "comment" {
144                let line = text.trim_start_matches('#').trim_start_matches("//").trim();
145                doc_lines.push(line.to_string());
146                prev = sibling.prev_sibling();
147            } else {
148                break;
149            }
150        }
151
152        if doc_lines.is_empty() {
153            return None;
154        }
155
156        doc_lines.reverse();
157        Some(doc_lines.join(" "))
158    }
159
160    fn extract_attributes(&self, _node: &Node, _content: &str) -> Vec<String> {
161        Vec::new()
162    }
163
164    fn extract_imports(&self, node: &Node, content: &str) -> Vec<Import> {
165        if node.kind() != "block" {
166            return Vec::new();
167        }
168
169        let (block_type, _name) = match self.extract_block_info(node, content) {
170            Some(info) => info,
171            None => return Vec::new(),
172        };
173
174        if block_type != "module" {
175            return Vec::new();
176        }
177
178        // Look for source attribute in the block
179        let text = &content[node.byte_range()];
180        for line in text.lines() {
181            if line.trim().starts_with("source") {
182                if let Some(start) = line.find('"') {
183                    let rest = &line[start + 1..];
184                    if let Some(end) = rest.find('"') {
185                        let module = rest[..end].to_string();
186                        return vec![Import {
187                            module,
188                            names: Vec::new(),
189                            alias: None,
190                            is_wildcard: false,
191                            is_relative: !rest.starts_with("registry") && !rest.starts_with("git"),
192                            line: node.start_position().row + 1,
193                        }];
194                    }
195                }
196            }
197        }
198
199        Vec::new()
200    }
201
202    fn format_import(&self, _import: &Import, _names: Option<&[&str]>) -> String {
203        // HCL has no imports
204        String::new()
205    }
206
207    fn is_public(&self, _node: &Node, _content: &str) -> bool {
208        true
209    }
210    fn get_visibility(&self, _node: &Node, _content: &str) -> Visibility {
211        Visibility::Public
212    }
213
214    fn is_test_symbol(&self, symbol: &crate::Symbol) -> bool {
215        let name = symbol.name.as_str();
216        match symbol.kind {
217            crate::SymbolKind::Function | crate::SymbolKind::Method => name.starts_with("test_"),
218            crate::SymbolKind::Module => name == "tests" || name == "test",
219            _ => false,
220        }
221    }
222
223    fn embedded_content(&self, _node: &Node, _content: &str) -> Option<crate::EmbeddedBlock> {
224        None
225    }
226
227    fn container_body<'a>(&self, node: &'a Node<'a>) -> Option<Node<'a>> {
228        node.child_by_field_name("body")
229    }
230
231    fn body_has_docstring(&self, _body: &Node, _content: &str) -> bool {
232        false
233    }
234    fn node_name<'a>(&self, _node: &Node, _content: &'a str) -> Option<&'a str> {
235        None
236    }
237
238    fn file_path_to_module_name(&self, path: &Path) -> Option<String> {
239        let ext = path.extension()?.to_str()?;
240        if ext != "tf" && ext != "tfvars" && ext != "hcl" {
241            return None;
242        }
243        let stem = path.file_stem()?.to_str()?;
244        Some(stem.to_string())
245    }
246
247    fn module_name_to_paths(&self, module: &str) -> Vec<String> {
248        vec![format!("{}.tf", module), format!("{}/main.tf", module)]
249    }
250
251    fn lang_key(&self) -> &'static str {
252        "hcl"
253    }
254
255    fn is_stdlib_import(&self, _import_name: &str, _project_root: &Path) -> bool {
256        false
257    }
258    fn find_stdlib(&self, _project_root: &Path) -> Option<PathBuf> {
259        None
260    }
261
262    fn resolve_local_import(
263        &self,
264        import: &str,
265        _current_file: &Path,
266        project_root: &Path,
267    ) -> Option<PathBuf> {
268        if import.starts_with("./") || import.starts_with("../") {
269            let full = project_root.join(import);
270            if full.is_dir() {
271                let main_tf = full.join("main.tf");
272                if main_tf.is_file() {
273                    return Some(main_tf);
274                }
275            }
276        }
277        None
278    }
279
280    fn resolve_external_import(
281        &self,
282        _import_name: &str,
283        _project_root: &Path,
284    ) -> Option<ResolvedPackage> {
285        // Terraform registry resolution would go here
286        None
287    }
288
289    fn get_version(&self, project_root: &Path) -> Option<String> {
290        // Check versions.tf or terraform block for version
291        let versions = project_root.join("versions.tf");
292        if versions.is_file() {
293            if let Ok(content) = std::fs::read_to_string(&versions) {
294                for line in content.lines() {
295                    if line.contains("required_version") {
296                        if let Some(start) = line.find('"') {
297                            let rest = &line[start + 1..];
298                            if let Some(end) = rest.find('"') {
299                                return Some(rest[..end].to_string());
300                            }
301                        }
302                    }
303                }
304            }
305        }
306        None
307    }
308
309    fn find_package_cache(&self, _project_root: &Path) -> Option<PathBuf> {
310        // Terraform modules cache
311        if let Some(home) = std::env::var_os("HOME") {
312            let cache = PathBuf::from(home).join(".terraform.d/plugin-cache");
313            if cache.is_dir() {
314                return Some(cache);
315            }
316        }
317        None
318    }
319
320    fn indexable_extensions(&self) -> &'static [&'static str] {
321        &["tf"]
322    }
323    fn package_sources(&self, _project_root: &Path) -> Vec<crate::PackageSource> {
324        Vec::new()
325    }
326
327    fn should_skip_package_entry(&self, name: &str, is_dir: bool) -> bool {
328        use crate::traits::{has_extension, skip_dotfiles};
329        if skip_dotfiles(name) {
330            return true;
331        }
332        if is_dir && name == ".terraform" {
333            return true;
334        }
335        !is_dir && !has_extension(name, self.indexable_extensions())
336    }
337
338    fn discover_packages(&self, _source: &crate::PackageSource) -> Vec<(String, PathBuf)> {
339        Vec::new()
340    }
341
342    fn package_module_name(&self, entry_name: &str) -> String {
343        entry_name
344            .strip_suffix(".tf")
345            .or_else(|| entry_name.strip_suffix(".tfvars"))
346            .or_else(|| entry_name.strip_suffix(".hcl"))
347            .unwrap_or(entry_name)
348            .to_string()
349    }
350
351    fn find_package_entry(&self, path: &Path) -> Option<PathBuf> {
352        if path.is_file() {
353            return Some(path.to_path_buf());
354        }
355        let main = path.join("main.tf");
356        if main.is_file() {
357            return Some(main);
358        }
359        None
360    }
361}
362
363impl Hcl {
364    fn extract_block_info(&self, node: &Node, content: &str) -> Option<(String, String)> {
365        let mut cursor = node.walk();
366        let mut block_type = None;
367        let mut labels = Vec::new();
368
369        for child in node.children(&mut cursor) {
370            match child.kind() {
371                "identifier" if block_type.is_none() => {
372                    block_type = Some(content[child.byte_range()].to_string());
373                }
374                "string_lit" => {
375                    let text = content[child.byte_range()].trim_matches('"').to_string();
376                    labels.push(text);
377                }
378                _ => {}
379            }
380        }
381
382        let block_type = block_type?;
383        let name = if labels.len() >= 2 {
384            format!("{}.{}", labels[0], labels[1])
385        } else if !labels.is_empty() {
386            labels[0].clone()
387        } else {
388            block_type.clone()
389        };
390
391        Some((block_type, name))
392    }
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398    use crate::validate_unused_kinds_audit;
399
400    #[test]
401    fn unused_node_kinds_audit() {
402        // Run cross_check_node_kinds to populate this list
403        #[rustfmt::skip]
404        let documented_unused: &[&str] = &[
405            "binary_operation", "body", "collection_value", "expression",
406            "for_cond", "for_intro", "for_object_expr", "for_tuple_expr",
407            "function_arguments", "function_call", "get_attr", "heredoc_identifier",
408            "identifier", "index", "literal_value", "object_elem", "quoted_template",
409            "template_else_intro", "template_for", "template_for_end", "template_for_start",
410            "template_if", "template_if_end", "template_if_intro", "tuple",
411            "block_end", "block_start",
412        ];
413        validate_unused_kinds_audit(&Hcl, documented_unused)
414            .expect("HCL unused node kinds audit failed");
415    }
416}