1use std::path::{Path, PathBuf};
2
3use once_cell::sync::Lazy;
4use regex::Regex;
5use rustc_hash::{FxHashMap, FxHashSet};
6
7use crate::config::edge_weights::TERRAFORM_SEMANTIC;
8use crate::types::{Fragment, FragmentId};
9
10use super::super::EdgeDict;
11use super::super::base::{self, EdgeBuilder, add_edge, add_edges_from_ids};
12
13static TF_EXTENSIONS: Lazy<FxHashSet<&str>> =
14 Lazy::new(|| [".tf", ".tfvars", ".hcl"].iter().copied().collect());
15
16fn is_terraform_file(path: &Path) -> bool {
17 let ext = base::file_ext(path);
18 TF_EXTENSIONS.contains(ext.as_str())
19}
20
21static VARIABLE_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r#"(?m)^variable\s+"([^"]+)""#).unwrap());
22static RESOURCE_RE: Lazy<Regex> =
23 Lazy::new(|| Regex::new(r#"(?m)^resource\s+"([^"]+)"\s+"([^"]+)""#).unwrap());
24static DATA_RE: Lazy<Regex> =
25 Lazy::new(|| Regex::new(r#"(?m)^data\s+"([^"]+)"\s+"([^"]+)""#).unwrap());
26static MODULE_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r#"(?m)^module\s+"([^"]+)""#).unwrap());
27static LOCALS_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?m)^locals\s*\{").unwrap());
28static LOCAL_KEY_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?m)^\s+(\w+)\s*=").unwrap());
29
30static VAR_REF_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"var\.(\w+)").unwrap());
31static LOCAL_REF_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"local\.(\w+)").unwrap());
32static DATA_REF_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"data\.(\w+)\.(\w+)").unwrap());
33static RESOURCE_REF_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?m)(\w+)\.(\w+)\.(\w+)").unwrap());
34static MODULE_REF_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"module\.(\w+)").unwrap());
35
36static SOURCE_RE: Lazy<Regex> =
37 Lazy::new(|| Regex::new(r#"(?m)^\s*source\s*=\s*"([^"]+)""#).unwrap());
38
39static GENERIC_NAMES: Lazy<FxHashSet<&str>> = Lazy::new(|| {
40 [
41 "name",
42 "region",
43 "tags",
44 "environment",
45 "env",
46 "description",
47 "enabled",
48 "type",
49 "value",
50 "default",
51 "count",
52 "id",
53 "arn",
54 "vpc_id",
55 "subnet_id",
56 "key",
57 "project",
58 "owner",
59 "stage",
60 ]
61 .iter()
62 .copied()
63 .collect()
64});
65
66static RESOURCE_SKIP_TYPES: Lazy<FxHashSet<&str>> = Lazy::new(|| {
67 [
68 "var",
69 "local",
70 "data",
71 "module",
72 "path",
73 "terraform",
74 "each",
75 "self",
76 "count",
77 ]
78 .iter()
79 .copied()
80 .collect()
81});
82
83fn extract_locals(content: &str) -> FxHashSet<String> {
84 let mut locals_keys = FxHashSet::default();
85 let mut in_locals = false;
86 let mut brace_count: i32 = 0;
87
88 for line in content.lines() {
89 if LOCALS_RE.is_match(line) {
90 in_locals = true;
91 brace_count = line.matches('{').count() as i32 - line.matches('}').count() as i32;
92 if brace_count <= 0 {
93 in_locals = false;
94 }
95 continue;
96 }
97
98 if in_locals {
99 brace_count += line.matches('{').count() as i32 - line.matches('}').count() as i32;
100 if brace_count <= 0 {
101 in_locals = false;
102 continue;
103 }
104
105 if let Some(cap) = LOCAL_KEY_RE.captures(line) {
106 locals_keys.insert(cap[1].to_string());
107 }
108 }
109 }
110
111 locals_keys
112}
113
114fn extract_qualified_defs(content: &str) -> FxHashSet<String> {
115 let mut defs = FxHashSet::default();
116
117 for cap in VARIABLE_RE.captures_iter(content) {
118 defs.insert(cap[1].to_string());
119 }
120 for cap in RESOURCE_RE.captures_iter(content) {
121 defs.insert(format!("{}.{}", &cap[1], &cap[2]));
122 }
123 for cap in DATA_RE.captures_iter(content) {
124 defs.insert(format!("{}.{}", &cap[1], &cap[2]));
125 }
126 for local_key in extract_locals(content) {
127 defs.insert(local_key);
128 }
129 for cap in MODULE_RE.captures_iter(content) {
130 defs.insert(cap[1].to_string());
131 }
132
133 defs
134}
135
136fn has_non_generic_var_local_ref(content: &str, changed_defs: &FxHashSet<String>) -> bool {
137 for cap in VAR_REF_RE.captures_iter(content) {
138 let name = &cap[1];
139 if changed_defs.contains(name) && !GENERIC_NAMES.contains(name) {
140 return true;
141 }
142 }
143 for cap in LOCAL_REF_RE.captures_iter(content) {
144 let name = &cap[1];
145 if changed_defs.contains(name) && !GENERIC_NAMES.contains(name) {
146 return true;
147 }
148 }
149 false
150}
151
152fn has_data_module_resource_ref(content: &str, changed_defs: &FxHashSet<String>) -> bool {
153 for cap in DATA_REF_RE.captures_iter(content) {
154 let full = format!("{}.{}", &cap[1], &cap[2]);
155 if changed_defs.contains(&full) || changed_defs.contains(&cap[2].to_string()) {
156 return true;
157 }
158 }
159 for cap in MODULE_REF_RE.captures_iter(content) {
160 if changed_defs.contains(&cap[1].to_string()) {
161 return true;
162 }
163 }
164 for cap in RESOURCE_REF_RE.captures_iter(content) {
165 let res_type = &cap[1];
166 if RESOURCE_SKIP_TYPES.contains(res_type) {
167 continue;
168 }
169 let res_name = &cap[2];
170 let full = format!("{}.{}", res_type, res_name);
171 if changed_defs.contains(&full) || changed_defs.contains(&res_name.to_string()) {
172 return true;
173 }
174 }
175 false
176}
177
178fn candidate_references_changed_defs_strict(
179 content: &str,
180 changed_defs: &FxHashSet<String>,
181) -> bool {
182 has_non_generic_var_local_ref(content, changed_defs)
183 || has_data_module_resource_ref(content, changed_defs)
184}
185
186fn collect_tf_dirs_and_sources(
187 tf_files: &[&PathBuf],
188 file_cache: Option<&FxHashMap<PathBuf, String>>,
189) -> (FxHashSet<PathBuf>, FxHashSet<String>) {
190 let mut tf_dirs = FxHashSet::default();
191 let mut module_sources = FxHashSet::default();
192
193 for tf in tf_files {
194 if let Some(parent) = tf.parent() {
195 tf_dirs.insert(parent.to_path_buf());
196 }
197 if let Some(content) = base::read_file_cached(tf, file_cache) {
198 for cap in SOURCE_RE.captures_iter(&content) {
199 let src = &cap[1];
200 if src.starts_with("./") || src.starts_with("../") {
201 module_sources.insert(src.to_string());
202 }
203 }
204 }
205 }
206
207 (tf_dirs, module_sources)
208}
209
210fn resolve_module_paths(
211 src: &str,
212 tf_dirs: &FxHashSet<PathBuf>,
213 repo_root: Option<&Path>,
214) -> Vec<PathBuf> {
215 let mut paths = Vec::new();
216 for tf_dir in tf_dirs {
217 if let Ok(resolved) = tf_dir.join(src).canonicalize() {
218 paths.push(resolved);
219 }
220 }
221 if let Some(root) = repo_root {
222 let stripped = src.trim_start_matches("./");
223 if let Ok(resolved) = root.join(stripped).canonicalize() {
224 paths.push(resolved);
225 }
226 }
227 paths
228}
229
230fn is_in_module(
231 candidate: &Path,
232 module_sources: &FxHashSet<String>,
233 tf_dirs: &FxHashSet<PathBuf>,
234 repo_root: Option<&Path>,
235) -> bool {
236 for src in module_sources {
237 for module_path in resolve_module_paths(src, tf_dirs, repo_root) {
238 if candidate.starts_with(&module_path) {
239 return true;
240 }
241 }
242 }
243 false
244}
245
246struct TFIndex {
247 var_defs: FxHashMap<String, Vec<FragmentId>>,
248 resource_defs: FxHashMap<String, Vec<FragmentId>>,
249 data_defs: FxHashMap<String, Vec<FragmentId>>,
250 local_defs: FxHashMap<String, Vec<FragmentId>>,
251 module_defs: FxHashMap<String, Vec<FragmentId>>,
252}
253
254impl TFIndex {
255 fn new() -> Self {
256 Self {
257 var_defs: FxHashMap::default(),
258 resource_defs: FxHashMap::default(),
259 data_defs: FxHashMap::default(),
260 local_defs: FxHashMap::default(),
261 module_defs: FxHashMap::default(),
262 }
263 }
264}
265
266fn index_definitions(f: &Fragment, idx: &mut TFIndex) {
267 for cap in VARIABLE_RE.captures_iter(&f.content) {
268 idx.var_defs
269 .entry(cap[1].to_string())
270 .or_default()
271 .push(f.id.clone());
272 }
273
274 for cap in RESOURCE_RE.captures_iter(&f.content) {
275 let full = format!("{}.{}", &cap[1], &cap[2]);
276 let name = cap[2].to_string();
277 idx.resource_defs
278 .entry(full)
279 .or_default()
280 .push(f.id.clone());
281 idx.resource_defs
282 .entry(name)
283 .or_default()
284 .push(f.id.clone());
285 }
286
287 for cap in DATA_RE.captures_iter(&f.content) {
288 let full = format!("{}.{}", &cap[1], &cap[2]);
289 let name = cap[2].to_string();
290 idx.data_defs.entry(full).or_default().push(f.id.clone());
291 idx.data_defs.entry(name).or_default().push(f.id.clone());
292 }
293
294 for local_key in extract_locals(&f.content) {
295 idx.local_defs
296 .entry(local_key)
297 .or_default()
298 .push(f.id.clone());
299 }
300
301 for cap in MODULE_RE.captures_iter(&f.content) {
302 idx.module_defs
303 .entry(cap[1].to_string())
304 .or_default()
305 .push(f.id.clone());
306 }
307}
308
309fn build_index(tf_frags: &[&Fragment]) -> TFIndex {
310 let mut idx = TFIndex::new();
311 for f in tf_frags {
312 index_definitions(f, &mut idx);
313 }
314 idx
315}
316
317fn add_var_edges(f: &Fragment, idx: &TFIndex, edges: &mut EdgeDict) {
318 for cap in VAR_REF_RE.captures_iter(&f.content) {
319 if let Some(def_ids) = idx.var_defs.get(&cap[1].to_string()) {
320 add_edges_from_ids(
321 edges,
322 &f.id,
323 def_ids,
324 TERRAFORM_SEMANTIC.weight,
325 TERRAFORM_SEMANTIC.reverse_factor,
326 );
327 }
328 }
329}
330
331fn add_local_edges(f: &Fragment, idx: &TFIndex, edges: &mut EdgeDict) {
332 for cap in LOCAL_REF_RE.captures_iter(&f.content) {
333 if let Some(def_ids) = idx.local_defs.get(&cap[1].to_string()) {
334 add_edges_from_ids(
335 edges,
336 &f.id,
337 def_ids,
338 TERRAFORM_SEMANTIC.weight,
339 TERRAFORM_SEMANTIC.reverse_factor,
340 );
341 }
342 }
343}
344
345fn add_data_edges(f: &Fragment, idx: &TFIndex, edges: &mut EdgeDict) {
346 for cap in DATA_REF_RE.captures_iter(&f.content) {
347 let full = format!("{}.{}", &cap[1], &cap[2]);
348 let name = cap[2].to_string();
349 if let Some(def_ids) = idx.data_defs.get(&full) {
350 add_edges_from_ids(
351 edges,
352 &f.id,
353 def_ids,
354 TERRAFORM_SEMANTIC.weight,
355 TERRAFORM_SEMANTIC.reverse_factor,
356 );
357 }
358 if let Some(def_ids) = idx.data_defs.get(&name) {
359 add_edges_from_ids(
360 edges,
361 &f.id,
362 def_ids,
363 TERRAFORM_SEMANTIC.weight,
364 TERRAFORM_SEMANTIC.reverse_factor,
365 );
366 }
367 }
368}
369
370fn add_module_edges(f: &Fragment, idx: &TFIndex, edges: &mut EdgeDict) {
371 for cap in MODULE_REF_RE.captures_iter(&f.content) {
372 if let Some(def_ids) = idx.module_defs.get(&cap[1].to_string()) {
373 add_edges_from_ids(
374 edges,
375 &f.id,
376 def_ids,
377 TERRAFORM_SEMANTIC.weight,
378 TERRAFORM_SEMANTIC.reverse_factor,
379 );
380 }
381 }
382}
383
384fn add_resource_edges(f: &Fragment, idx: &TFIndex, edges: &mut EdgeDict) {
385 for cap in RESOURCE_REF_RE.captures_iter(&f.content) {
386 let res_type = &cap[1];
387 if RESOURCE_SKIP_TYPES.contains(res_type) {
388 continue;
389 }
390 let res_name = &cap[2];
391 let full = format!("{}.{}", res_type, res_name);
392 if let Some(def_ids) = idx.resource_defs.get(&full) {
393 add_edges_from_ids(
394 edges,
395 &f.id,
396 def_ids,
397 TERRAFORM_SEMANTIC.weight,
398 TERRAFORM_SEMANTIC.reverse_factor,
399 );
400 }
401 if let Some(def_ids) = idx.resource_defs.get(&res_name.to_string()) {
402 add_edges_from_ids(
403 edges,
404 &f.id,
405 def_ids,
406 TERRAFORM_SEMANTIC.weight,
407 TERRAFORM_SEMANTIC.reverse_factor,
408 );
409 }
410 }
411}
412
413fn add_ref_edges(f: &Fragment, idx: &TFIndex, edges: &mut EdgeDict) {
414 add_var_edges(f, idx, edges);
415 add_local_edges(f, idx, edges);
416 add_data_edges(f, idx, edges);
417 add_module_edges(f, idx, edges);
418 add_resource_edges(f, idx, edges);
419}
420
421fn build_path_to_frags(
422 all_frags: &[Fragment],
423 repo_root: Option<&Path>,
424) -> FxHashMap<PathBuf, Vec<FragmentId>> {
425 let mut map: FxHashMap<PathBuf, Vec<FragmentId>> = FxHashMap::default();
426 for f in all_frags {
427 let path = PathBuf::from(f.path());
428 map.entry(path.clone()).or_default().push(f.id.clone());
429 if let Some(root) = repo_root {
430 if let Ok(rel) = path.strip_prefix(root) {
431 map.entry(rel.to_path_buf()).or_default().push(f.id.clone());
432 }
433 }
434 }
435 map
436}
437
438fn build_module_source_edges(
439 tf_frags: &[&Fragment],
440 all_frags: &[Fragment],
441 edges: &mut EdgeDict,
442 repo_root: Option<&Path>,
443) {
444 let path_to_frags = build_path_to_frags(all_frags, repo_root);
445
446 for f in tf_frags {
447 let base_dir = Path::new(f.path()).parent().unwrap_or(Path::new(""));
448
449 for cap in SOURCE_RE.captures_iter(&f.content) {
450 let source = &cap[1];
451 if source.starts_with("./") || source.starts_with("../") {
452 let module_dir = base_dir.join(source);
453 let resolved = module_dir.canonicalize().unwrap_or(module_dir);
454
455 for (p, frag_ids) in &path_to_frags {
456 let candidate = if p.is_absolute() {
457 p.clone()
458 } else if let Some(root) = repo_root {
459 root.join(p).canonicalize().unwrap_or_else(|_| root.join(p))
460 } else {
461 p.clone()
462 };
463 if candidate.starts_with(&resolved) {
464 for frag_id in frag_ids {
465 if *frag_id != f.id {
466 add_edge(
467 edges,
468 &f.id,
469 frag_id,
470 TERRAFORM_SEMANTIC.weight
471 * TERRAFORM_SEMANTIC.module_source_modifier,
472 TERRAFORM_SEMANTIC.reverse_factor,
473 );
474 }
475 }
476 }
477 }
478 }
479 }
480 }
481}
482
483pub struct TerraformEdgeBuilder;
484
485impl EdgeBuilder for TerraformEdgeBuilder {
486 fn build(&self, fragments: &[Fragment], repo_root: Option<&Path>) -> EdgeDict {
487 let tf_frags: Vec<&Fragment> = fragments
488 .iter()
489 .filter(|f| is_terraform_file(Path::new(f.path())))
490 .collect();
491 if tf_frags.is_empty() {
492 return FxHashMap::default();
493 }
494
495 let mut edges: EdgeDict = FxHashMap::default();
496 let idx = build_index(&tf_frags);
497
498 for f in &tf_frags {
499 add_ref_edges(f, &idx, &mut edges);
500 }
501
502 build_module_source_edges(&tf_frags, fragments, &mut edges, repo_root);
503
504 edges
505 }
506
507 fn discover_related_files(
508 &self,
509 changed: &[PathBuf],
510 candidates: &[PathBuf],
511 repo_root: Option<&Path>,
512 file_cache: Option<&FxHashMap<PathBuf, String>>,
513 ) -> Vec<PathBuf> {
514 let tf_changed: Vec<&PathBuf> = changed.iter().filter(|f| is_terraform_file(f)).collect();
515 if tf_changed.is_empty() {
516 return vec![];
517 }
518
519 let (tf_dirs, module_sources) = collect_tf_dirs_and_sources(&tf_changed, file_cache);
520
521 let mut changed_defs = FxHashSet::default();
522 let mut changed_contents: Vec<String> = Vec::new();
523 for tf in &tf_changed {
524 if let Some(content) = base::read_file_cached(tf, file_cache) {
525 for def in extract_qualified_defs(&content) {
526 changed_defs.insert(def);
527 }
528 changed_contents.push(content);
529 }
530 }
531
532 let changed_set: FxHashSet<&PathBuf> = changed.iter().collect();
533 let mut result = Vec::new();
534
535 for c in candidates {
536 if changed_set.contains(c) || !is_terraform_file(c) {
537 continue;
538 }
539 if is_related(
540 c,
541 &module_sources,
542 &tf_dirs,
543 repo_root,
544 &changed_defs,
545 &changed_contents,
546 file_cache,
547 ) {
548 result.push(c.clone());
549 }
550 }
551
552 result.sort();
553 result
554 }
555
556 fn category_label(&self) -> Option<&str> {
557 Some("semantic")
558 }
559}
560
561fn is_related(
562 candidate: &Path,
563 module_sources: &FxHashSet<String>,
564 tf_dirs: &FxHashSet<PathBuf>,
565 repo_root: Option<&Path>,
566 changed_defs: &FxHashSet<String>,
567 changed_contents: &[String],
568 file_cache: Option<&FxHashMap<PathBuf, String>>,
569) -> bool {
570 if is_in_module(candidate, module_sources, tf_dirs, repo_root) {
571 return true;
572 }
573
574 let candidate_parent = candidate.parent().map(|p| p.to_path_buf());
575 if let Some(parent) = &candidate_parent {
576 if !tf_dirs.contains(parent) {
577 return false;
578 }
579 } else {
580 return false;
581 }
582
583 let content = match base::read_file_cached(candidate, file_cache) {
584 Some(c) => c,
585 None => return false,
586 };
587
588 if candidate_references_changed_defs_strict(&content, changed_defs) {
589 return true;
590 }
591
592 let candidate_defs = extract_qualified_defs(&content);
593 !candidate_defs.is_empty()
594 && changed_contents
595 .iter()
596 .any(|c| candidate_references_changed_defs_strict(c, &candidate_defs))
597}