1use crate::external_packages::ResolvedPackage;
4use crate::{Export, Import, Language, Symbol, SymbolKind, Visibility, VisibilityMechanism};
5use std::path::{Path, PathBuf};
6use tree_sitter::Node;
7
8pub 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"] }
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"] }
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 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 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 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 None
287 }
288
289 fn get_version(&self, project_root: &Path) -> Option<String> {
290 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 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 #[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}