1use 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
15const RESOURCES: &[(&str, &str, &str)] = &[
17 ("aws_sqs_queue", "sqs", "name"),
18 ("aws_sns_topic", "sns", "name"),
19 ("aws_s3_bucket", "s3", "bucket"),
20];
21
22const 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
33pub 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
51pub 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 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 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 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 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 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 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}