Skip to main content

_diffctx/edges/semantic/
terraform.rs

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}