Skip to main content

comindly_core/
infra.rs

1//! Terraform: queues, topics and buckets a repo declares or looks up, and which queues
2//! subscribe to which topics. Becomes contract facts on the `.tf` file's symbol.
3
4use std::collections::{BTreeMap, HashMap};
5use std::path::Path;
6
7use hcl::expr::{Expression, TemplateExpr, TraversalOperator};
8use hcl::structure::{Block, Body};
9use hcl::template::Element;
10use ignore::WalkBuilder;
11
12use crate::facts::{ContractFact, FileFacts, InfraRef};
13use crate::model::{GlobalSymbolId, Language, Position, Range, RepoId, Symbol, SymbolKind};
14
15/// Resource types → (scheme, attribute holding the name).
16const RESOURCES: &[(&str, &str, &str)] = &[
17    ("aws_sqs_queue", "sqs", "name"),
18    ("aws_sns_topic", "sns", "name"),
19    ("aws_s3_bucket", "s3", "bucket"),
20];
21
22/// Module inputs that name the resource a module creates (team modules hide the full name).
23const MODULE_NAMES: &[(&str, &str)] = &[
24    ("queue_name", "sqs"),
25    ("topic_name", "sns"),
26    ("bucket_name", "s3"),
27];
28
29pub fn is_tf(rel: &str) -> bool {
30    rel.ends_with(".tf") && !rel.split('/').any(|d| d == ".terraform")
31}
32
33/// Symbols and facts for every `.tf` file in a repo.
34pub fn scan(root: &Path, repo: &str) -> Vec<(Symbol, FileFacts)> {
35    let rels: Vec<String> = WalkBuilder::new(root)
36        .hidden(false)
37        .build()
38        .filter_map(|e| e.ok())
39        .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
40        .filter_map(|e| {
41            e.path()
42                .strip_prefix(root)
43                .ok()
44                .map(|r| r.to_string_lossy().replace('\\', "/"))
45        })
46        .filter(|r| is_tf(r))
47        .collect();
48    files(root, repo, &rels)
49}
50
51/// Facts for these `.tf` files; locals and references resolve across their module directory.
52pub fn files(root: &Path, repo: &str, rels: &[String]) -> Vec<(Symbol, FileFacts)> {
53    let mut dirs: BTreeMap<String, Vec<&String>> = BTreeMap::new();
54    for r in rels.iter().filter(|r| is_tf(r)) {
55        let dir = r.rsplit_once('/').map_or("", |(d, _)| d).to_string();
56        dirs.entry(dir).or_default().push(r);
57    }
58    let mut out = Vec::new();
59    for (dir, wanted) in dirs {
60        let module = read_dir(root, &dir);
61        for rel in wanted {
62            let Some(body) = module.bodies.get(rel.as_str()) else {
63                continue;
64            };
65            let contracts = module.facts(body);
66            out.push(file(repo, rel, contracts));
67        }
68    }
69    out
70}
71
72struct Module {
73    bodies: BTreeMap<String, Body>,
74    locals: HashMap<String, Expression>,
75    /// `aws_sqs_queue.x`, `data.aws_sns_topic.y`, `module.z` → what it names.
76    handles: HashMap<String, InfraRef>,
77}
78
79fn read_dir(root: &Path, dir: &str) -> Module {
80    let mut m = Module {
81        bodies: BTreeMap::new(),
82        locals: HashMap::new(),
83        handles: HashMap::new(),
84    };
85    let full = if dir.is_empty() {
86        root.to_path_buf()
87    } else {
88        root.join(dir)
89    };
90    for e in std::fs::read_dir(full).into_iter().flatten().flatten() {
91        let name = e.file_name().to_string_lossy().to_string();
92        if !name.ends_with(".tf") {
93            continue;
94        }
95        let rel = if dir.is_empty() {
96            name
97        } else {
98            format!("{dir}/{name}")
99        };
100        let Ok(text) = std::fs::read_to_string(e.path()) else {
101            continue;
102        };
103        if let Ok(body) = hcl::parse(&text) {
104            m.bodies.insert(rel, body);
105        }
106    }
107    for body in m.bodies.values() {
108        for b in body.blocks().filter(|b| b.identifier.as_str() == "locals") {
109            for a in b.body.attributes() {
110                m.locals.insert(a.key.as_str().to_string(), a.expr.clone());
111            }
112        }
113    }
114    let mut handles = HashMap::new();
115    for body in m.bodies.values() {
116        for b in body.blocks() {
117            if let Some((key, r)) = m.handle(b) {
118                handles.insert(key, r);
119            }
120        }
121    }
122    m.handles = handles;
123    m
124}
125
126impl Module {
127    /// What a `resource`, `data` or `module` block names, keyed by how other blocks refer to it.
128    fn handle(&self, b: &Block) -> Option<(String, InfraRef)> {
129        let labels: Vec<&str> = b.labels.iter().map(|l| l.as_str()).collect();
130        match (b.identifier.as_str(), labels.as_slice()) {
131            ("resource", [ty, name]) | ("data", [ty, name]) => {
132                let (_, scheme, attr) = RESOURCES.iter().find(|(t, _, _)| t == ty)?;
133                let value = self.attr(&b.body, attr)?;
134                let key = if b.identifier.as_str() == "data" {
135                    format!("data.{ty}.{name}")
136                } else {
137                    format!("{ty}.{name}")
138                };
139                Some((
140                    key,
141                    InfraRef {
142                        scheme: scheme.to_string(),
143                        name: value,
144                        logical: false,
145                    },
146                ))
147            }
148            ("module", [name]) => {
149                let (attr, scheme) = MODULE_NAMES
150                    .iter()
151                    .find(|(a, _)| b.body.attributes().any(|x| x.key.as_str() == *a))?;
152                Some((
153                    format!("module.{name}"),
154                    InfraRef {
155                        scheme: scheme.to_string(),
156                        name: self.attr(&b.body, attr)?,
157                        logical: true,
158                    },
159                ))
160            }
161            _ => None,
162        }
163    }
164
165    fn attr(&self, body: &Body, key: &str) -> Option<String> {
166        let a = body.attributes().find(|a| a.key.as_str() == key)?;
167        self.eval(&a.expr, 0)
168    }
169
170    /// A string value: literals, templates, `local.*` (resolved) and `var.*` (unknown → `{}`).
171    fn eval(&self, e: &Expression, depth: u32) -> Option<String> {
172        if depth > 8 {
173            return None;
174        }
175        match e {
176            Expression::String(s) => Some(s.clone()),
177            Expression::TemplateExpr(t) => {
178                let raw = match t.as_ref() {
179                    TemplateExpr::QuotedString(s) => s.clone(),
180                    TemplateExpr::Heredoc(h) => h.template.clone(),
181                };
182                let tpl: hcl::Template = raw.parse().ok()?;
183                let mut out = String::new();
184                for el in tpl.elements() {
185                    match el {
186                        Element::Literal(l) => out.push_str(l),
187                        Element::Interpolation(i) => out.push_str(
188                            &self.eval(&i.expr, depth + 1).unwrap_or_else(|| "{}".into()),
189                        ),
190                        Element::Directive(_) => return None,
191                    }
192                }
193                Some(out)
194            }
195            Expression::Traversal(t) => {
196                let Expression::Variable(root) = &t.expr else {
197                    return None;
198                };
199                let Some(TraversalOperator::GetAttr(first)) = t.operators.first() else {
200                    return None;
201                };
202                match root.as_str() {
203                    "var" => Some("{}".into()),
204                    "local" if t.operators.len() == 1 => {
205                        self.eval(self.locals.get(first.as_str())?, depth + 1)
206                    }
207                    _ => None,
208                }
209            }
210            Expression::Parenthesis(p) => self.eval(p, depth + 1),
211            _ => None,
212        }
213    }
214
215    /// The infrastructure a reference expression (`data.aws_sns_topic.x.arn`) points at.
216    fn target(&self, e: &Expression) -> Option<InfraRef> {
217        let Expression::Traversal(t) = e else {
218            return None;
219        };
220        let Expression::Variable(root) = &t.expr else {
221            return None;
222        };
223        let names: Vec<&str> = t
224            .operators
225            .iter()
226            .map_while(|o| match o {
227                TraversalOperator::GetAttr(i) => Some(i.as_str()),
228                _ => None,
229            })
230            .collect();
231        let key = match (root.as_str(), names.as_slice()) {
232            ("data", [ty, name, ..]) => format!("data.{ty}.{name}"),
233            ("module", [name, ..]) => format!("module.{name}"),
234            (ty, [name, ..]) => format!("{ty}.{name}"),
235            _ => return None,
236        };
237        self.handles.get(&key).cloned()
238    }
239
240    fn facts(&self, body: &Body) -> Vec<ContractFact> {
241        let mut out = Vec::new();
242        for b in body.blocks() {
243            let labels: Vec<&str> = b.labels.iter().map(|l| l.as_str()).collect();
244            let own = match b.identifier.as_str() {
245                "data" => None,
246                _ => self.handle(b).map(|(_, r)| r),
247            };
248            if let Some(r) = &own {
249                out.push(ContractFact::Infra {
250                    declares: r.clone(),
251                    subscribes: None,
252                });
253            }
254            // `aws_sns_topic_subscription { topic_arn, endpoint }` → queue subscribes to topic.
255            if b.identifier.as_str() == "resource"
256                && labels.first() == Some(&"aws_sns_topic_subscription")
257            {
258                let get = |k: &str| b.body.attributes().find(|a| a.key.as_str() == k);
259                if let (Some(t), Some(q)) = (
260                    get("topic_arn").and_then(|a| self.target(&a.expr)),
261                    get("endpoint").and_then(|a| self.target(&a.expr)),
262                ) {
263                    out.push(ContractFact::Infra {
264                        declares: q,
265                        subscribes: Some(t),
266                    });
267                }
268            }
269            // A queue module wired to topics through any `*topic_arn` input.
270            if let Some(q) = own.filter(|r| r.scheme == "sqs") {
271                let mut topics = Vec::new();
272                for a in b.body.attributes() {
273                    self.topic_refs(a.key.as_str(), &a.expr, &mut topics);
274                }
275                for t in topics {
276                    out.push(ContractFact::Infra {
277                        declares: q.clone(),
278                        subscribes: Some(t),
279                    });
280                }
281            }
282        }
283        out
284    }
285
286    fn topic_refs(&self, key: &str, e: &Expression, out: &mut Vec<InfraRef>) {
287        match e {
288            Expression::Object(o) => {
289                for (k, v) in o {
290                    let k = match k {
291                        hcl::ObjectKey::Identifier(i) => i.as_str().to_string(),
292                        hcl::ObjectKey::Expression(x) => self.eval(x, 0).unwrap_or_default(),
293                        _ => String::new(),
294                    };
295                    self.topic_refs(&k, v, out);
296                }
297            }
298            Expression::Array(a) => a.iter().for_each(|v| self.topic_refs(key, v, out)),
299            other if key.ends_with("topic_arn") => {
300                out.extend(self.target(other).filter(|t| t.scheme == "sns"))
301            }
302            _ => {}
303        }
304    }
305}
306
307fn file(repo: &str, rel: &str, contracts: Vec<ContractFact>) -> (Symbol, FileFacts) {
308    let id = GlobalSymbolId {
309        scheme: "comindly-treesitter".into(),
310        package_manager: ".".into(),
311        package: repo.to_string(),
312        version: ".".into(),
313        descriptor: rel.to_string(),
314    };
315    let at = Position { line: 1, column: 0 };
316    let sym = Symbol {
317        id: id.clone(),
318        name: rel.to_string(),
319        kind: SymbolKind::File,
320        language: Language::Other("hcl".into()),
321        repo: RepoId(repo.to_string()),
322        file_path: rel.to_string(),
323        range: Range { start: at, end: at },
324        signature: None,
325        docstring: None,
326        fingerprint: None,
327    };
328    let facts = FileFacts {
329        repo: repo.to_string(),
330        path: rel.to_string(),
331        language: Language::Other("hcl".into()),
332        file_id: id.render(),
333        modules: Vec::new(),
334        imports: Vec::new(),
335        refs: Vec::new(),
336        types: Vec::new(),
337        scip: None,
338        contracts,
339        values: Vec::new(),
340        params: Vec::new(),
341        calls: Vec::new(),
342    };
343    (sym, facts)
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349
350    #[test]
351    fn declarations_and_subscriptions() {
352        let dir = std::env::temp_dir().join(format!("comindly-tf-{}", std::process::id()));
353        std::fs::create_dir_all(dir.join("iac")).unwrap();
354        std::fs::write(
355            dir.join("iac/main.tf"),
356            r#"
357locals { team = "acme" }
358data "aws_sns_topic" "upstream" { name = "${local.team}-${var.tier}-orders-events" }
359module "events_queue" {
360  source     = "git::https://example/modules//queue"
361  queue_name = "order-events"
362  sns_subscriptions = { orders = { topic_arn = data.aws_sns_topic.upstream.arn } }
363}
364resource "aws_s3_bucket" "files" { bucket = "acme-files" }
365"#,
366        )
367        .unwrap();
368        let out = scan(&dir, "svc");
369        std::fs::remove_dir_all(&dir).ok();
370        assert_eq!(out.len(), 1);
371        let c = &out[0].1.contracts;
372        let has = |scheme: &str, name: &str, sub: Option<&str>| {
373            c.iter().any(|f| match f {
374                ContractFact::Infra {
375                    declares,
376                    subscribes,
377                } => {
378                    declares.scheme == scheme
379                        && declares.name == name
380                        && subscribes.as_ref().map(|s| s.name.as_str()) == sub
381                }
382                _ => false,
383            })
384        };
385        assert!(has("sqs", "order-events", None));
386        assert!(has("sqs", "order-events", Some("acme-{}-orders-events")));
387        assert!(has("s3", "acme-files", None));
388    }
389}