Navegador

Blame History Raw 491 lines
1
"""
2
HCL/Terraform parser — extracts resources, data sources, providers,
3
variables, outputs, modules, and locals from .tf files using tree-sitter.
4
"""
5
6
import logging
7
import re
8
from pathlib import Path
9
10
from navegador.graph.schema import EdgeType, NodeLabel
11
from navegador.graph.store import GraphStore
12
from navegador.ingestion.parser import LanguageParser
13
14
logger = logging.getLogger(__name__)
15
16
# Patterns for reference extraction from expression text
17
_VAR_REF = re.compile(r"\bvar\.(\w+)")
18
_LOCAL_REF = re.compile(r"\blocal\.(\w+)")
19
_MODULE_REF = re.compile(r"\bmodule\.(\w+)")
20
_DATA_REF = re.compile(r"\bdata\.(\w+)\.(\w+)")
21
_RESOURCE_REF = re.compile(
22
r"(?<!\bdata\.)" # exclude data.resource_type references (handled by _DATA_REF)
23
r"\b(aws_\w+|google_\w+|azurerm_\w+|azuread_\w+|oci_\w+|digitalocean_\w+"
24
r"|cloudflare_\w+|helm_\w+|kubernetes_\w+|null_\w+|random_\w+"
25
r"|local_\w+|tls_\w+|template_\w+|archive_\w+|external_\w+)\.(\w+)"
26
)
27
28
29
def _get_hcl_language():
30
try:
31
import tree_sitter_hcl as tshcl # type: ignore[import]
32
from tree_sitter import Language
33
34
return Language(tshcl.language())
35
except ImportError as e:
36
raise ImportError("Install tree-sitter-hcl: pip install tree-sitter-hcl") from e
37
38
39
def _node_text(node, source: bytes) -> str:
40
return source[node.start_byte : node.end_byte].decode("utf-8", errors="replace")
41
42
43
def _string_lit_text(node, source: bytes) -> str:
44
"""Extract the inner text from a string_lit node (strips quotes)."""
45
for child in node.children:
46
if child.type == "template_literal":
47
return _node_text(child, source)
48
# Fallback: strip surrounding quotes from the full text
49
text = _node_text(node, source)
50
return text.strip('"').strip("'")
51
52
53
class HCLParser(LanguageParser):
54
"""Parses HCL/Terraform files into the navegador graph."""
55
56
def __init__(self) -> None:
57
from tree_sitter import Parser # type: ignore[import]
58
59
self._parser = Parser(_get_hcl_language())
60
61
def parse_file(self, path: Path, repo_root: Path, store: GraphStore) -> dict[str, int]:
62
source = path.read_bytes()
63
tree = self._parser.parse(source)
64
rel_path = str(path.relative_to(repo_root))
65
66
store.create_node(
67
NodeLabel.File,
68
{
69
"name": path.name,
70
"path": rel_path,
71
"language": "hcl",
72
"line_count": source.count(b"\n"),
73
},
74
)
75
76
stats = {"functions": 0, "classes": 0, "edges": 0}
77
self._walk(tree.root_node, source, rel_path, store, stats)
78
return stats
79
80
# ── AST walker ────────────────────────────────────────────────────────────
81
82
def _walk(self, node, source: bytes, file_path: str, store: GraphStore, stats: dict) -> None:
83
"""Walk the top-level body looking for block nodes."""
84
for child in node.children:
85
if child.type == "body":
86
for body_child in child.children:
87
if body_child.type == "block":
88
self._handle_block(body_child, source, file_path, store, stats)
89
elif child.type == "block":
90
self._handle_block(child, source, file_path, store, stats)
91
92
def _handle_block(
93
self, node, source: bytes, file_path: str, store: GraphStore, stats: dict
94
) -> None:
95
"""Dispatch a block based on its block-type identifier."""
96
block_type = None
97
labels: list[str] = []
98
body_node = None
99
100
for child in node.children:
101
if child.type == "identifier" and block_type is None:
102
block_type = _node_text(child, source)
103
elif child.type == "string_lit":
104
labels.append(_string_lit_text(child, source))
105
elif child.type == "body":
106
body_node = child
107
108
if not block_type:
109
return
110
111
if block_type == "resource" and len(labels) >= 2:
112
self._handle_resource(node, source, file_path, store, stats, labels, body_node)
113
elif block_type == "data" and len(labels) >= 2:
114
self._handle_data(node, source, file_path, store, stats, labels, body_node)
115
elif block_type == "provider" and len(labels) >= 1:
116
self._handle_provider(node, source, file_path, store, stats, labels, body_node)
117
elif block_type == "variable" and len(labels) >= 1:
118
self._handle_variable(node, source, file_path, store, stats, labels, body_node)
119
elif block_type == "output" and len(labels) >= 1:
120
self._handle_output(node, source, file_path, store, stats, labels, body_node)
121
elif block_type == "module" and len(labels) >= 1:
122
self._handle_module(node, source, file_path, store, stats, labels, body_node)
123
elif block_type == "locals":
124
self._handle_locals(node, source, file_path, store, stats, body_node)
125
elif block_type == "terraform":
126
pass # Configuration block, skip
127
else:
128
logger.debug("Skipping unknown HCL block type: %s", block_type)
129
130
# ── Handlers ──────────────────────────────────────────────────────────────
131
132
def _handle_resource(
133
self,
134
node,
135
source: bytes,
136
file_path: str,
137
store: GraphStore,
138
stats: dict,
139
labels: list[str],
140
body_node,
141
) -> None:
142
name = f"{labels[0]}.{labels[1]}"
143
store.create_node(
144
NodeLabel.Class,
145
{
146
"name": name,
147
"file_path": file_path,
148
"line_start": node.start_point[0] + 1,
149
"line_end": node.end_point[0] + 1,
150
"docstring": "",
151
"semantic_type": "terraform_resource",
152
},
153
)
154
store.create_edge(
155
NodeLabel.File,
156
{"path": file_path},
157
EdgeType.CONTAINS,
158
NodeLabel.Class,
159
{"name": name, "file_path": file_path},
160
)
161
stats["classes"] += 1
162
stats["edges"] += 1
163
164
if body_node:
165
self._extract_references(
166
body_node, source, file_path, name, NodeLabel.Class, store, stats
167
)
168
169
def _handle_data(
170
self,
171
node,
172
source: bytes,
173
file_path: str,
174
store: GraphStore,
175
stats: dict,
176
labels: list[str],
177
body_node,
178
) -> None:
179
name = f"{labels[0]}.{labels[1]}"
180
store.create_node(
181
NodeLabel.Class,
182
{
183
"name": name,
184
"file_path": file_path,
185
"line_start": node.start_point[0] + 1,
186
"line_end": node.end_point[0] + 1,
187
"docstring": "",
188
"semantic_type": "terraform_data",
189
},
190
)
191
store.create_edge(
192
NodeLabel.File,
193
{"path": file_path},
194
EdgeType.CONTAINS,
195
NodeLabel.Class,
196
{"name": name, "file_path": file_path},
197
)
198
stats["classes"] += 1
199
stats["edges"] += 1
200
201
if body_node:
202
self._extract_references(
203
body_node, source, file_path, name, NodeLabel.Class, store, stats
204
)
205
206
def _handle_provider(
207
self,
208
node,
209
source: bytes,
210
file_path: str,
211
store: GraphStore,
212
stats: dict,
213
labels: list[str],
214
body_node,
215
) -> None:
216
name = labels[0]
217
store.create_node(
218
NodeLabel.Class,
219
{
220
"name": name,
221
"file_path": file_path,
222
"line_start": node.start_point[0] + 1,
223
"line_end": node.end_point[0] + 1,
224
"docstring": "",
225
"semantic_type": "terraform_provider",
226
},
227
)
228
store.create_edge(
229
NodeLabel.File,
230
{"path": file_path},
231
EdgeType.CONTAINS,
232
NodeLabel.Class,
233
{"name": name, "file_path": file_path},
234
)
235
stats["classes"] += 1
236
stats["edges"] += 1
237
238
if body_node:
239
self._extract_references(
240
body_node, source, file_path, name, NodeLabel.Class, store, stats
241
)
242
243
def _handle_variable(
244
self,
245
node,
246
source: bytes,
247
file_path: str,
248
store: GraphStore,
249
stats: dict,
250
labels: list[str],
251
body_node,
252
) -> None:
253
name = labels[0]
254
store.create_node(
255
NodeLabel.Variable,
256
{
257
"name": name,
258
"file_path": file_path,
259
"line_start": node.start_point[0] + 1,
260
"line_end": node.end_point[0] + 1,
261
"semantic_type": "terraform_variable",
262
},
263
)
264
store.create_edge(
265
NodeLabel.File,
266
{"path": file_path},
267
EdgeType.CONTAINS,
268
NodeLabel.Variable,
269
{"name": name, "file_path": file_path},
270
)
271
stats["functions"] += 1
272
stats["edges"] += 1
273
274
def _handle_output(
275
self,
276
node,
277
source: bytes,
278
file_path: str,
279
store: GraphStore,
280
stats: dict,
281
labels: list[str],
282
body_node,
283
) -> None:
284
name = labels[0]
285
store.create_node(
286
NodeLabel.Variable,
287
{
288
"name": name,
289
"file_path": file_path,
290
"line_start": node.start_point[0] + 1,
291
"line_end": node.end_point[0] + 1,
292
"semantic_type": "terraform_output",
293
},
294
)
295
store.create_edge(
296
NodeLabel.File,
297
{"path": file_path},
298
EdgeType.CONTAINS,
299
NodeLabel.Variable,
300
{"name": name, "file_path": file_path},
301
)
302
stats["functions"] += 1
303
stats["edges"] += 1
304
305
if body_node:
306
self._extract_references(
307
body_node, source, file_path, name, NodeLabel.Variable, store, stats
308
)
309
310
def _handle_module(
311
self,
312
node,
313
source: bytes,
314
file_path: str,
315
store: GraphStore,
316
stats: dict,
317
labels: list[str],
318
body_node,
319
) -> None:
320
name = labels[0]
321
source_attr = ""
322
if body_node:
323
source_attr = self._get_attribute_value(body_node, "source", source)
324
325
store.create_node(
326
NodeLabel.Module,
327
{
328
"name": name,
329
"file_path": file_path,
330
"line_start": node.start_point[0] + 1,
331
"line_end": node.end_point[0] + 1,
332
"semantic_type": "terraform_module",
333
"source": source_attr,
334
},
335
)
336
store.create_edge(
337
NodeLabel.File,
338
{"path": file_path},
339
EdgeType.CONTAINS,
340
NodeLabel.Module,
341
{"name": name, "file_path": file_path},
342
)
343
stats["classes"] += 1
344
stats["edges"] += 1
345
346
if body_node:
347
self._extract_references(
348
body_node, source, file_path, name, NodeLabel.Module, store, stats
349
)
350
351
def _handle_locals(
352
self,
353
node,
354
source: bytes,
355
file_path: str,
356
store: GraphStore,
357
stats: dict,
358
body_node,
359
) -> None:
360
if not body_node:
361
return
362
363
for child in body_node.children:
364
if child.type == "attribute":
365
attr_name = None
366
for attr_child in child.children:
367
if attr_child.type == "identifier":
368
attr_name = _node_text(attr_child, source)
369
break
370
371
if not attr_name:
372
continue
373
374
store.create_node(
375
NodeLabel.Variable,
376
{
377
"name": attr_name,
378
"file_path": file_path,
379
"line_start": child.start_point[0] + 1,
380
"line_end": child.end_point[0] + 1,
381
"semantic_type": "terraform_local",
382
},
383
)
384
store.create_edge(
385
NodeLabel.File,
386
{"path": file_path},
387
EdgeType.CONTAINS,
388
NodeLabel.Variable,
389
{"name": attr_name, "file_path": file_path},
390
)
391
stats["functions"] += 1
392
stats["edges"] += 1
393
394
# Extract references from the attribute expression
395
self._extract_references(
396
child, source, file_path, attr_name, NodeLabel.Variable, store, stats
397
)
398
399
# ── Reference extraction ──────────────────────────────────────────────────
400
401
def _extract_references(
402
self,
403
node,
404
source: bytes,
405
file_path: str,
406
from_name: str,
407
from_label: str,
408
store: GraphStore,
409
stats: dict,
410
) -> None:
411
"""Scan expression text for var.X, local.X, module.X, data.T.N, and resource references."""
412
text = _node_text(node, source)
413
414
# var.xxx → REFERENCES edge to terraform_variable
415
for match in _VAR_REF.finditer(text):
416
var_name = match.group(1)
417
store.create_edge(
418
from_label,
419
{"name": from_name, "file_path": file_path},
420
EdgeType.REFERENCES,
421
NodeLabel.Variable,
422
{"name": var_name, "file_path": file_path},
423
)
424
stats["edges"] += 1
425
426
# local.xxx → REFERENCES edge to terraform_local
427
for match in _LOCAL_REF.finditer(text):
428
local_name = match.group(1)
429
store.create_edge(
430
from_label,
431
{"name": from_name, "file_path": file_path},
432
EdgeType.REFERENCES,
433
NodeLabel.Variable,
434
{"name": local_name, "file_path": file_path},
435
)
436
stats["edges"] += 1
437
438
# module.xxx → REFERENCES edge to terraform_module
439
for match in _MODULE_REF.finditer(text):
440
mod_name = match.group(1)
441
store.create_edge(
442
from_label,
443
{"name": from_name, "file_path": file_path},
444
EdgeType.REFERENCES,
445
NodeLabel.Module,
446
{"name": mod_name, "file_path": file_path},
447
)
448
stats["edges"] += 1
449
450
# data.type.name → DEPENDS_ON edge to terraform_data
451
for match in _DATA_REF.finditer(text):
452
data_name = f"{match.group(1)}.{match.group(2)}"
453
store.create_edge(
454
from_label,
455
{"name": from_name, "file_path": file_path},
456
EdgeType.DEPENDS_ON,
457
NodeLabel.Class,
458
{"name": data_name, "file_path": file_path},
459
)
460
stats["edges"] += 1
461
462
# resource_type.resource_name → DEPENDS_ON edge to terraform_resource
463
for match in _RESOURCE_REF.finditer(text):
464
resource_name = f"{match.group(1)}.{match.group(2)}"
465
store.create_edge(
466
from_label,
467
{"name": from_name, "file_path": file_path},
468
EdgeType.DEPENDS_ON,
469
NodeLabel.Class,
470
{"name": resource_name, "file_path": file_path},
471
)
472
stats["edges"] += 1
473
474
# ── Helpers ───────────────────────────────────────────────────────────────
475
476
def _get_attribute_value(self, body_node, attr_name: str, source: bytes) -> str:
477
"""Extract the string value of a named attribute from a body node."""
478
for child in body_node.children:
479
if child.type == "attribute":
480
ident = None
481
expr = None
482
for attr_child in child.children:
483
if attr_child.type == "identifier":
484
ident = _node_text(attr_child, source)
485
elif attr_child.type == "expression" or attr_child.is_named:
486
expr = attr_child
487
if ident == attr_name and expr is not None:
488
text = _node_text(expr, source).strip().strip('"').strip("'")
489
return text
490
return ""
491

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button