codeterrain-terraform 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,12 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ .codeterrain/
7
+ dist/
8
+ build/
9
+ *.egg-info/
10
+
11
+ # Local, private overlays (not part of the project)
12
+ CLAUDE.local.md
@@ -0,0 +1,39 @@
1
+ Metadata-Version: 2.5
2
+ Name: codeterrain-terraform
3
+ Version: 0.2.0
4
+ Summary: Terraform for codeterrain: resources, modules, providers and references of .tf files in terrain def and refs.
5
+ Project-URL: Homepage, https://github.com/benchli-sports/codeterrain
6
+ Project-URL: Issues, https://github.com/benchli-sports/codeterrain/issues
7
+ Author: Mitbolas GmbH
8
+ License: Apache-2.0
9
+ Requires-Python: >=3.12
10
+ Requires-Dist: codeterrain>=0.3
11
+ Description-Content-Type: text/markdown
12
+
13
+ # codeterrain-terraform
14
+
15
+ Terraform for [codeterrain](https://github.com/benchli-sports/codeterrain): the definitions and
16
+ references of `.tf` files, in `terrain def` and `terrain refs`.
17
+
18
+ ```
19
+ uv tool install "<path-to-the-codeterrain-checkout>[terraform]"
20
+ terrain def logs # resource aws_s3_bucket.logs infra/storage.tf:12
21
+ terrain refs var.region # every expression that reads it, and the block it sits in
22
+ terrain def --kind module # every module call, with its file, source and version
23
+ terrain def --kind required_provider # every pinned provider and its version constraint
24
+ terrain refs provider.aws.east # the resources and module calls that select that provider
25
+ ```
26
+
27
+ Not published yet: install from a checkout of the codeterrain repository, where this pack lives under
28
+ `packages/`. Once published, `pip install "codeterrain[terraform]"` will do the same.
29
+ `terrain doctor` lists the installed packs (`language packs: terraform 0.2.0`).
30
+
31
+ Names follow Terraform's own references: `aws_s3_bucket.logs`, `data.aws_ami.base`, `module.vpc`,
32
+ `var.region`, `local.tags`, `output.url`, and `provider.aws` for both an entry of
33
+ `required_providers` (kind `required_provider`) and a `provider "aws"` block (kind `provider`;
34
+ `provider.aws.east` with `alias = "east"`). A module call's doc is its `source` and `version`, as
35
+ is a required provider's. A resource's `provider = aws.east` and a module's
36
+ `providers = { aws = aws.east }` are references to `provider.aws.east`. References are found in attribute values and inside string
37
+ and heredoc templates. The reader is a tokenizer, not an evaluator: it follows no module source,
38
+ expands no `count` or `for_each` and reads no `.tfvars`. A resource that selects no provider uses
39
+ its default one implicitly; that is not listed as a reference. `.tf.json` files are not read.
@@ -0,0 +1,27 @@
1
+ # codeterrain-terraform
2
+
3
+ Terraform for [codeterrain](https://github.com/benchli-sports/codeterrain): the definitions and
4
+ references of `.tf` files, in `terrain def` and `terrain refs`.
5
+
6
+ ```
7
+ uv tool install "<path-to-the-codeterrain-checkout>[terraform]"
8
+ terrain def logs # resource aws_s3_bucket.logs infra/storage.tf:12
9
+ terrain refs var.region # every expression that reads it, and the block it sits in
10
+ terrain def --kind module # every module call, with its file, source and version
11
+ terrain def --kind required_provider # every pinned provider and its version constraint
12
+ terrain refs provider.aws.east # the resources and module calls that select that provider
13
+ ```
14
+
15
+ Not published yet: install from a checkout of the codeterrain repository, where this pack lives under
16
+ `packages/`. Once published, `pip install "codeterrain[terraform]"` will do the same.
17
+ `terrain doctor` lists the installed packs (`language packs: terraform 0.2.0`).
18
+
19
+ Names follow Terraform's own references: `aws_s3_bucket.logs`, `data.aws_ami.base`, `module.vpc`,
20
+ `var.region`, `local.tags`, `output.url`, and `provider.aws` for both an entry of
21
+ `required_providers` (kind `required_provider`) and a `provider "aws"` block (kind `provider`;
22
+ `provider.aws.east` with `alias = "east"`). A module call's doc is its `source` and `version`, as
23
+ is a required provider's. A resource's `provider = aws.east` and a module's
24
+ `providers = { aws = aws.east }` are references to `provider.aws.east`. References are found in attribute values and inside string
25
+ and heredoc templates. The reader is a tokenizer, not an evaluator: it follows no module source,
26
+ expands no `count` or `for_each` and reads no `.tfvars`. A resource that selects no provider uses
27
+ its default one implicitly; that is not listed as a reference. `.tf.json` files are not read.
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "codeterrain-terraform"
7
+ version = "0.2.0"
8
+ description = "Terraform for codeterrain: resources, modules, providers and references of .tf files in terrain def and refs."
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license = { text = "Apache-2.0" }
12
+ authors = [{ name = "Mitbolas GmbH" }]
13
+ dependencies = ["codeterrain>=0.3"]
14
+
15
+ [project.entry-points."codeterrain.languages"]
16
+ terraform = "codeterrain_terraform:language"
17
+
18
+ [project.urls]
19
+ Homepage = "https://github.com/benchli-sports/codeterrain"
20
+ Issues = "https://github.com/benchli-sports/codeterrain/issues"
21
+
22
+ [tool.uv.sources]
23
+ codeterrain = { workspace = true }
24
+
25
+ [tool.hatch.build.targets.wheel]
26
+ packages = ["src/codeterrain_terraform"]
@@ -0,0 +1,470 @@
1
+ """Terraform for codeterrain: the definitions and references of ``.tf`` files.
2
+
3
+ Definitions are named the way Terraform code refers to them:
4
+
5
+ - ``resource "aws_s3_bucket" "logs"`` is ``aws_s3_bucket.logs``;
6
+ - ``data "aws_ami" "base"`` is ``data.aws_ami.base``;
7
+ - ``module "vpc"`` is ``module.vpc``, ``variable "region"`` is ``var.region``, an entry of a
8
+ ``locals`` block is ``local.name`` and ``output "url"`` is ``output.url``;
9
+ - an entry of ``required_providers`` is ``provider.aws``, and ``provider "aws"`` is
10
+ ``provider.aws`` too, or ``provider.aws.east`` with ``alias = "east"``.
11
+
12
+ A module call's doc is its ``source`` and ``version``, a required provider's the same; a
13
+ resource's ``provider = aws.east`` and a module's ``providers = { aws = aws.east }`` are
14
+ references to ``provider.aws.east``.
15
+
16
+ References are the expressions that name one of those (``var.region``, ``aws_s3_bucket.logs.arn``,
17
+ ``module.vpc.id``), in attribute values and inside string and heredoc templates, with the
18
+ definition they sit in as their scope. The reader is a tokenizer, not an evaluator: it follows no
19
+ module source, expands no ``count`` or ``for_each`` and reads no ``.tfvars``.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import re
25
+ import string
26
+ import sys
27
+ import textwrap
28
+ from dataclasses import dataclass
29
+ from importlib.metadata import PackageNotFoundError, version
30
+ from typing import Any
31
+
32
+ try:
33
+ __version__ = version("codeterrain-terraform")
34
+ except PackageNotFoundError: # run from a source tree
35
+ __version__ = "0"
36
+
37
+ #: Labels each top-level block takes, and how its definition is named.
38
+ _BLOCKS = {"resource": 2, "data": 2, "module": 1, "variable": 1, "output": 1, "provider": 1}
39
+ _PREFIX = {"resource": "", "data": "data.", "module": "module.", "variable": "var.",
40
+ "output": "output.", "provider": "provider."}
41
+ #: Objects whose attributes are never a definition of this configuration.
42
+ _BUILTIN = frozenset({"each", "count", "path", "terraform", "self"})
43
+ #: A resource type: a provider name, ``_``, then the type (``aws_s3_bucket``, ``random_id``).
44
+ _RESOURCE_TYPE = re.compile(r"[a-z][a-z0-9]*_[a-z0-9_]+\Z")
45
+ #: A Terraform identifier: Unicode letters, digits, ``_`` and ``-``, not starting with a digit.
46
+ _IDENT = re.compile(r"[^\W\d][\w-]*")
47
+ #: Operators of more than one character, kept as one token so ``==`` is never read as ``=``.
48
+ _OPERATOR = re.compile(r"==|!=|<=|>=|=>|&&|\|\||\.\.\.|::")
49
+ _HEREDOC = re.compile(r"<<-?([^\W\d][\w-]*)[ \t]*\r?\n")
50
+ #: What a quoted string's single-character escapes stand for.
51
+ _ESCAPES = {"n": "\n", "r": "\r", "t": "\t", '"': '"', "\\": "\\"}
52
+ #: Deepest nesting of templates inside templates read before the file is given up.
53
+ MAX_NESTING = 64
54
+ MAX_DOC = 200
55
+
56
+
57
+ class TerraformError(ValueError):
58
+ pass
59
+
60
+
61
+ @dataclass
62
+ class Tok:
63
+ #: "ident", "str" (a literal string: its text), "punct", "mark" (a directive's ``%{`` or end)
64
+ kind: str
65
+ value: str
66
+ line: int
67
+
68
+
69
+ class _Lexer:
70
+ def __init__(self, src: str) -> None:
71
+ self.src = src
72
+ self.i = 0
73
+ self.line = 1
74
+ self.out: list[Tok] = []
75
+
76
+ def _advance(self, to: int) -> None:
77
+ self.line += self.src.count("\n", self.i, to)
78
+ self.i = to
79
+
80
+ def run(self) -> list[Tok]:
81
+ self._code(stop=None, depth=0)
82
+ return self.out
83
+
84
+ def _code(self, stop: str | None, depth: int) -> None:
85
+ """Tokens up to the ``}`` that closes an interpolation (``stop``), or the end."""
86
+ if depth > MAX_NESTING:
87
+ raise TerraformError("templates nested too deeply")
88
+ src, braces = self.src, 0
89
+ while self.i < len(src):
90
+ c = src[self.i]
91
+ if c == "\n" or c in " \t\r":
92
+ self._advance(self.i + 1)
93
+ elif c == "#" or src.startswith("//", self.i):
94
+ end = src.find("\n", self.i)
95
+ self._advance(len(src) if end < 0 else end)
96
+ elif src.startswith("/*", self.i):
97
+ end = src.find("*/", self.i + 2)
98
+ self._advance(len(src) if end < 0 else end + 2)
99
+ elif c == '"':
100
+ self._string(depth)
101
+ elif src.startswith("<<", self.i) and (m := _HEREDOC.match(src, self.i)):
102
+ self._heredoc(m, depth)
103
+ elif m := _IDENT.match(src, self.i):
104
+ self.out.append(Tok("ident", m.group(), self.line))
105
+ self._advance(m.end())
106
+ elif m := _OPERATOR.match(src, self.i):
107
+ self.out.append(Tok("punct", m.group(), self.line))
108
+ self._advance(m.end())
109
+ elif c in "{[(":
110
+ braces += c == "{"
111
+ self.out.append(Tok("punct", c, self.line))
112
+ self._advance(self.i + 1)
113
+ elif c == "}" and stop is not None and braces == 0:
114
+ self._advance(self.i + 1)
115
+ return
116
+ else:
117
+ braces -= c == "}"
118
+ self.out.append(Tok("punct", c, self.line))
119
+ self._advance(self.i + 1)
120
+
121
+ def _template(self, end: int, depth: int, quoted: bool = True) -> str:
122
+ """Read template text up to ``end`` (exclusive), or in a quoted string up to its closing
123
+ quote or the end of its line, lexing each ``${...}`` as code: the code lexer finds where
124
+ an interpolation ends, so its comments, strings and braces never end the template. The
125
+ literal text, for a block label. A backslash escapes only in a quoted string: a heredoc
126
+ keeps it as text."""
127
+ src, text = self.src, []
128
+ while self.i < end:
129
+ if quoted and src[self.i] in '"\n':
130
+ break
131
+ if src.startswith(("$${", "%%{"), self.i):
132
+ text.append(src[self.i + 1:self.i + 3])
133
+ self._advance(self.i + 3)
134
+ elif src.startswith(("${", "%{"), self.i):
135
+ directive = src[self.i] == "%"
136
+ if directive:
137
+ self.out.append(Tok("mark", "%{", self.line))
138
+ # a ``~`` strip marker (``%{~ endfor }``) is part of the opener, not code
139
+ self._advance(self.i + 2 + src.startswith("~", self.i + 2))
140
+ self._code(stop="}", depth=depth + 1)
141
+ if directive:
142
+ self.out.append(Tok("mark", "%}", self.line))
143
+ elif quoted and src[self.i] == "\\" and self.i + 1 < end:
144
+ text.append(self._escape())
145
+ else:
146
+ text.append(src[self.i])
147
+ self._advance(self.i + 1)
148
+ return "".join(text)
149
+
150
+ def _escape(self) -> str:
151
+ """The character a quoted string's escape at ``self.i`` stands for (``\\n``, ``\\t``,
152
+ ``\\u00e9``), moving past it; an unknown escape keeps the character after the backslash."""
153
+ src, i = self.src, self.i
154
+ kind = src[i + 1]
155
+ width = {"u": 4, "U": 8}.get(kind)
156
+ if width:
157
+ digits = src[i + 2:i + 2 + width]
158
+ if len(digits) == width and all(c in string.hexdigits for c in digits):
159
+ code = int(digits, 16)
160
+ if code <= sys.maxunicode and not 0xD800 <= code <= 0xDFFF: # no lone surrogate
161
+ self._advance(i + 2 + width)
162
+ return chr(code)
163
+ self._advance(i + 2)
164
+ return _ESCAPES.get(kind, kind)
165
+
166
+ def _string(self, depth: int) -> None:
167
+ line = self.line
168
+ self._advance(self.i + 1)
169
+ text = self._template(len(self.src), depth)
170
+ self.out.append(Tok("str", text, line))
171
+ if self.i < len(self.src) and self.src[self.i] == '"':
172
+ self._advance(self.i + 1)
173
+
174
+ def _heredoc(self, m: re.Match[str], depth: int) -> None:
175
+ line = self.line
176
+ self._advance(m.end())
177
+ close = re.compile(rf"^[ \t]*{re.escape(m.group(1))}[ \t]*\r?$", re.M)
178
+ found = close.search(self.src, self.i)
179
+ end = found.start() if found else len(self.src)
180
+ text = self._template(end, depth, quoted=False)
181
+ # the literal text, for a description: ``<<-`` strips the common indentation
182
+ self.out.append(Tok("str", textwrap.dedent(text).strip(), line))
183
+ if found:
184
+ self._advance(found.end())
185
+
186
+
187
+ def _punct(toks: list[Tok], i: int, value: str) -> bool:
188
+ """Whether ``toks[i]`` is the punctuation ``value``: never a string's text that reads the same
189
+ (the ``.`` that ends ``"${var.domain}."``)."""
190
+ return 0 <= i < len(toks) and toks[i].kind == "punct" and toks[i].value == value
191
+
192
+
193
+ def _entry(toks: list[Tok], i: int) -> bool:
194
+ """Whether ``toks[i]`` separates an object entry's key from its value: ``=`` or ``:``. A
195
+ block body's attributes take only ``=``."""
196
+ return _punct(toks, i, "=") or _punct(toks, i, ":")
197
+
198
+
199
+ def _mark(toks: list[Tok], i: int, value: str) -> bool:
200
+ """Whether ``toks[i]`` is where a template directive starts (``%{``) or ends (``%}``)."""
201
+ return 0 <= i < len(toks) and toks[i].kind == "mark" and toks[i].value == value
202
+
203
+
204
+ def _traversal(toks: list[Tok], i: int) -> list[str]:
205
+ """The names of ``a.b.c`` starting at ``toks[i]``, up to an index or the end."""
206
+ parts = [toks[i].value]
207
+ j = i + 1
208
+ while j + 1 < len(toks) and _punct(toks, j, ".") and toks[j + 1].kind == "ident":
209
+ parts.append(toks[j + 1].value)
210
+ j += 2
211
+ return parts
212
+
213
+
214
+ def _iterators(toks: list[Tok]) -> frozenset[str]:
215
+ """The iterator names of the file's ``dynamic`` blocks: the label (``dynamic "rule"`` gives
216
+ ``rule.value``), or the ``iterator = name`` that replaces it, written directly in that
217
+ block's body."""
218
+ found = set()
219
+ depth = 0
220
+ bodies: list[int] = [] # the brace depth of each open dynamic block's body
221
+ for i, tok in enumerate(toks):
222
+ if tok.kind == "punct" and tok.value == "{":
223
+ depth += 1
224
+ elif tok.kind == "punct" and tok.value == "}":
225
+ depth -= 1
226
+ while bodies and bodies[-1] > depth:
227
+ bodies.pop()
228
+ elif i + 2 < len(toks) and tok.kind == "ident":
229
+ if tok.value == "dynamic" and toks[i + 1].kind == "str" and _punct(toks, i + 2, "{"):
230
+ found.add(toks[i + 1].value)
231
+ bodies.append(depth + 1)
232
+ elif (
233
+ tok.value == "iterator" and bodies and bodies[-1] == depth
234
+ and _punct(toks, i + 1, "=") and toks[i + 2].kind == "ident"
235
+ and not _punct(toks, i + 3, ".")
236
+ ):
237
+ found.add(toks[i + 2].value)
238
+ return frozenset(found)
239
+
240
+
241
+ def _bound(toks: list[Tok]) -> set[int]:
242
+ """The indexes of the tokens that name a ``for`` binding rather than the configuration:
243
+ ``local`` in ``[for local in var.items : local.name]`` reads the element. An expression's
244
+ names hold from its ``:`` to its closing bracket; a template directive's from the end of
245
+ ``%{ for x in xs }`` to its ``%{ endfor }``."""
246
+ found: set[int] = set()
247
+ frames: list[set[str]] = [] # the names bound inside each open bracket
248
+ questions: list[int] = [] # the ``?`` of each open bracket still waiting for their ``:``
249
+ directives: list[set[str]] = []
250
+ # Each ``for`` whose collection is still being read: its names, its bracket depth (None: a
251
+ # directive) and the directive it opens (how many were open with it). A ``for`` nested in
252
+ # that collection stacks above it.
253
+ pending: list[tuple[set[str], int | None, int]] = []
254
+ open_marks = 0 # directives opened and not yet ended
255
+ for i, tok in enumerate(toks):
256
+ if tok.kind == "punct" and tok.value in "{[(":
257
+ frames.append(set())
258
+ questions.append(0)
259
+ elif tok.kind == "punct" and tok.value in "}])":
260
+ if frames:
261
+ closed = len(frames)
262
+ frames.pop()
263
+ questions.pop()
264
+ pending = [p for p in pending if p[1] is None or p[1] < closed]
265
+ elif tok.kind == "punct" and tok.value == "?" and questions:
266
+ questions[-1] += 1
267
+ elif tok.kind == "punct" and tok.value == ":" and questions and questions[-1]:
268
+ questions[-1] -= 1 # the ``:`` of a conditional
269
+ elif tok.kind == "punct" and tok.value == ":" and pending and pending[-1][1] == len(frames):
270
+ if frames:
271
+ frames[-1] |= pending[-1][0]
272
+ pending.pop()
273
+ elif _mark(toks, i, "%{"):
274
+ open_marks += 1
275
+ elif _mark(toks, i, "%}"):
276
+ # only the end of the ``for`` directive's own ``%{ ... }`` starts its binding
277
+ if pending and pending[-1][1] is None and pending[-1][2] == open_marks:
278
+ directives.append(pending.pop()[0])
279
+ open_marks = max(open_marks - 1, 0)
280
+ elif tok.kind == "ident" and tok.value == "endfor" and _mark(toks, i - 1, "%{"):
281
+ if directives:
282
+ directives.pop()
283
+ elif tok.kind == "ident" and tok.value == "for":
284
+ names, j = set(), i + 1
285
+ while j < len(toks) and toks[j].kind == "ident" and toks[j].value != "in":
286
+ names.add(toks[j].value)
287
+ j += 1 + _punct(toks, j + 1, ",")
288
+ if j < len(toks) and toks[j].kind == "ident" and toks[j].value == "in" and names:
289
+ if _punct(toks, i - 1, "{") or _punct(toks, i - 1, "["):
290
+ pending.append((names, len(frames), 0))
291
+ elif _mark(toks, i - 1, "%{"):
292
+ pending.append((names, None, open_marks))
293
+ elif (
294
+ tok.kind == "ident" and not _punct(toks, i - 1, ".")
295
+ and any(tok.value in names for names in (*frames, *directives))
296
+ ):
297
+ found.add(i)
298
+ return found
299
+
300
+
301
+ def _reference(parts: list[str], iterators: frozenset[str] = frozenset()) -> str | None:
302
+ head = parts[0]
303
+ if head in iterators and parts[1] in ("key", "value"):
304
+ return None
305
+ if head in ("var", "local", "module") and len(parts) >= 2:
306
+ return f"{head}.{parts[1]}"
307
+ if head == "data" and len(parts) >= 3:
308
+ return f"data.{parts[1]}.{parts[2]}"
309
+ if head not in _BUILTIN and len(parts) >= 2 and _RESOURCE_TYPE.match(head):
310
+ return f"{head}.{parts[1]}"
311
+ return None
312
+
313
+
314
+ def read(source: str) -> dict[str, Any]:
315
+ toks = _Lexer(source).run()
316
+ iterators = _iterators(toks)
317
+ bound = _bound(toks)
318
+ defs: list[dict[str, Any]] = []
319
+ refs: list[dict[str, Any]] = []
320
+ depth, nest, scope, kind = 0, 0, "", "" # nest: open ( and [ inside the block body
321
+ current: dict[str, Any] | None = None
322
+ #: ``source`` and ``version`` of a module or required provider, for its doc
323
+ settings: dict[int, dict[str, str]] = {}
324
+ first_ref = 0 # the first reference of the current block, renamed with a provider alias
325
+ required = 0 # the depth of the body of ``required_providers``, while inside it
326
+ wanted_provider: dict[str, Any] | None = None # the required provider being read
327
+ providers = 0 # the depth of the body of a module's ``providers`` map, while inside it
328
+ i = 0
329
+ while i < len(toks):
330
+ tok = toks[i]
331
+ if tok.kind == "punct":
332
+ if tok.value == "{":
333
+ depth += 1
334
+ elif tok.value == "}":
335
+ depth = max(depth - 1, 0)
336
+ if depth < required:
337
+ required = 0
338
+ if depth <= required:
339
+ wanted_provider = None
340
+ if depth < providers:
341
+ providers = 0
342
+ if depth == 0:
343
+ scope, kind, current, nest = "", "", None, 0
344
+ elif tok.value in "([":
345
+ nest += 1
346
+ elif tok.value in ")]":
347
+ nest = max(nest - 1, 0)
348
+ i += 1
349
+ continue
350
+ if depth == 0 and tok.kind == "ident":
351
+ labels = []
352
+ j = i + 1
353
+ while j < len(toks) and toks[j].kind in ("str", "ident"):
354
+ labels.append(toks[j].value)
355
+ j += 1
356
+ if _punct(toks, j, "{"):
357
+ kind = tok.value
358
+ wanted = _BLOCKS.get(kind)
359
+ if wanted is not None and len(labels) == wanted:
360
+ scope = _PREFIX[kind] + ".".join(labels)
361
+ current = {"name": scope, "kind": kind, "line": tok.line, "doc": ""}
362
+ defs.append(current)
363
+ else:
364
+ scope, current = "", None
365
+ first_ref = len(refs)
366
+ depth = 1
367
+ i = j + 1
368
+ continue
369
+ if (
370
+ kind == "terraform" and depth == 1 and nest == 0 and tok.kind == "ident"
371
+ and tok.value == "required_providers" and _punct(toks, i + 1, "{")
372
+ ):
373
+ required = 2
374
+ i += 1
375
+ continue
376
+ # A required provider: ``aws = { source = "hashicorp/aws", version = "~> 5.0" }``, or
377
+ # the older ``aws = "~> 5.0"``.
378
+ if (
379
+ required and depth == required and nest == 0 and tok.kind == "ident"
380
+ and _punct(toks, i + 1, "=")
381
+ ):
382
+ wanted_provider = {
383
+ "name": f"provider.{tok.value}", "kind": "required_provider", "line": tok.line,
384
+ "doc": "",
385
+ }
386
+ defs.append(wanted_provider)
387
+ if i + 2 < len(toks) and toks[i + 2].kind == "str":
388
+ settings[id(wanted_provider)] = {"version": toks[i + 2].value}
389
+ i += 3
390
+ else:
391
+ i += 2
392
+ continue
393
+ if (
394
+ wanted_provider is not None and depth == required + 1 and nest == 0
395
+ and tok.kind == "ident" and tok.value in ("source", "version")
396
+ and _entry(toks, i + 1) and i + 2 < len(toks) and toks[i + 2].kind == "str"
397
+ ):
398
+ settings.setdefault(id(wanted_provider), {})[tok.value] = toks[i + 2].value
399
+ i += 3
400
+ continue
401
+ # The provider configurations of a module call: ``providers = { aws = aws.east }``.
402
+ if (
403
+ providers and depth == providers and tok.kind == "ident"
404
+ and _entry(toks, i - 1)
405
+ ):
406
+ parts = _traversal(toks, i)
407
+ refs.append({"name": "provider." + ".".join(parts[:2]), "line": tok.line,
408
+ "scope": scope})
409
+ i += 2 * len(parts) - 1
410
+ continue
411
+ # An attribute: ``name =`` starting a line of the block body, outside brackets.
412
+ if (
413
+ tok.kind == "ident" and depth == 1 and nest == 0
414
+ and _punct(toks, i + 1, "=")
415
+ and (i == 0 or toks[i - 1].line < tok.line or _punct(toks, i - 1, "{"))
416
+ ):
417
+ if kind == "locals":
418
+ scope = f"local.{tok.value}"
419
+ defs.append({"name": scope, "kind": "local", "line": tok.line, "doc": ""})
420
+ i += 2
421
+ continue
422
+ if (
423
+ current is not None and tok.value == "description" and i + 2 < len(toks)
424
+ and toks[i + 2].kind == "str"
425
+ ):
426
+ current["doc"] = toks[i + 2].value[:MAX_DOC]
427
+ literal = toks[i + 2].value if i + 2 < len(toks) and toks[i + 2].kind == "str" else None
428
+ if current is not None and kind == "module" and tok.value in ("source", "version"):
429
+ if literal is not None:
430
+ settings.setdefault(id(current), {})[tok.value] = literal
431
+ elif current is not None and kind == "provider" and tok.value == "alias":
432
+ # ``provider "aws" { alias = "east" }`` is what ``provider = aws.east`` names
433
+ if literal is not None and _IDENT.fullmatch(literal):
434
+ scope = current["name"] = f"{current['name']}.{literal}"
435
+ for ref in refs[first_ref:]:
436
+ ref["scope"] = scope
437
+ elif kind in ("resource", "data") and tok.value == "provider" and i + 2 < len(toks):
438
+ if toks[i + 2].kind == "ident":
439
+ parts = _traversal(toks, i + 2)
440
+ refs.append({"name": "provider." + ".".join(parts[:2]), "line": tok.line,
441
+ "scope": scope})
442
+ i += 2 + 2 * len(parts) - 1
443
+ continue
444
+ elif kind == "module" and tok.value == "providers" and _punct(toks, i + 2, "{"):
445
+ providers = 2
446
+ if tok.kind == "ident" and not _punct(toks, i - 1, "."):
447
+ parts = _traversal(toks, i)
448
+ name = _reference(parts, iterators) if len(parts) > 1 and i not in bound else None
449
+ if name is not None:
450
+ refs.append({"name": name, "line": tok.line, "scope": scope})
451
+ i += max(1, 2 * len(parts) - 1)
452
+ continue
453
+ i += 1
454
+ for d in defs:
455
+ found = settings.get(id(d))
456
+ if found and not d["doc"]:
457
+ d["doc"] = ", ".join(
458
+ f"{key} {found[key]}" for key in ("source", "version") if key in found
459
+ )[:MAX_DOC]
460
+ return {"defs": defs, "refs": refs}
461
+
462
+
463
+ class _Language:
464
+ interface = 1
465
+ suffixes = (".tf",)
466
+ read = staticmethod(read)
467
+
468
+
469
+ #: The entry point codeterrain loads (``codeterrain.languages``: ``terraform``).
470
+ language = _Language()
@@ -0,0 +1,519 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ import pytest
6
+ from codeterrain_terraform import language, read
7
+
8
+ from codeterrain import languages
9
+ from codeterrain.cli import main
10
+
11
+ MAIN = '''\
12
+ # storage
13
+ variable "region" {
14
+ description = "Where everything runs"
15
+ default = "eu-central-1"
16
+ }
17
+
18
+ locals {
19
+ tags = { Team = "core", Region = var.region }
20
+ name = "logs-${var.region}"
21
+ }
22
+
23
+ resource "aws_s3_bucket" "logs" {
24
+ bucket = local.name
25
+ tags = local.tags
26
+ }
27
+
28
+ data "aws_iam_policy_document" "read" {
29
+ statement {
30
+ resources = ["${aws_s3_bucket.logs.arn}/*"]
31
+ }
32
+ }
33
+
34
+ module "vpc" {
35
+ source = "./modules/vpc"
36
+ region = var.region
37
+ }
38
+
39
+ output "bucket" {
40
+ value = aws_s3_bucket.logs.id
41
+ }
42
+ '''
43
+
44
+
45
+ def _names(facts, key):
46
+ return [(f["name"], f["line"]) for f in facts[key]]
47
+
48
+
49
+ def test_definitions_are_named_the_way_terraform_refers_to_them():
50
+ facts = read(MAIN)
51
+ assert [(d["name"], d["kind"], d["line"]) for d in facts["defs"]] == [
52
+ ("var.region", "variable", 2),
53
+ ("local.tags", "local", 8),
54
+ ("local.name", "local", 9),
55
+ ("aws_s3_bucket.logs", "resource", 12),
56
+ ("data.aws_iam_policy_document.read", "data", 17),
57
+ ("module.vpc", "module", 23),
58
+ ("output.bucket", "output", 28),
59
+ ]
60
+ assert facts["defs"][0]["doc"] == "Where everything runs"
61
+
62
+
63
+ @pytest.mark.parametrize("opener", ["<<-", "<<"])
64
+ def test_a_heredoc_description_is_the_definition_doc(opener):
65
+ text = (
66
+ 'variable "env" {\n'
67
+ f' description = {opener}EOT\n'
68
+ ' The environment to deploy to.\n'
69
+ ' One of dev or prod.\n'
70
+ ' EOT\n'
71
+ ' default = var.fallback\n'
72
+ '}\n'
73
+ )
74
+ facts = read(text)
75
+ assert facts["defs"][0]["doc"] == "The environment to deploy to.\nOne of dev or prod."
76
+ assert _names(facts, "refs") == [("var.fallback", 6)]
77
+
78
+
79
+ @pytest.mark.parametrize(("written", "doc"), [
80
+ (r"First line\nSecond line", "First line\nSecond line"),
81
+ (r"a\tb \"quoted\" back\\slash", 'a\tb "quoted" back\\slash'),
82
+ (r"café \U0001F600", "café \U0001F600"),
83
+ (r"short \u00e and \q", "short u00e and q"),
84
+ (r"surrogate \uD800", "surrogate uD800"),
85
+ ])
86
+ def test_a_quoted_description_decodes_its_escapes(written, doc):
87
+ text = f'variable "env" {{\n description = "{written}"\n}}\n'
88
+ assert read(text)["defs"][0]["doc"] == doc
89
+
90
+
91
+ def test_references_carry_the_definition_they_sit_in():
92
+ facts = read(MAIN)
93
+ assert [(r["name"], r["line"], r["scope"]) for r in facts["refs"]] == [
94
+ ("var.region", 8, "local.tags"),
95
+ ("var.region", 9, "local.name"),
96
+ ("local.name", 13, "aws_s3_bucket.logs"),
97
+ ("local.tags", 14, "aws_s3_bucket.logs"),
98
+ ("aws_s3_bucket.logs", 19, "data.aws_iam_policy_document.read"),
99
+ ("var.region", 25, "module.vpc"),
100
+ ("aws_s3_bucket.logs", 29, "output.bucket"),
101
+ ]
102
+
103
+
104
+ @pytest.mark.parametrize("text", [
105
+ 'value = "var.region is not read here"\n',
106
+ "# value = var.region\n",
107
+ "// value = var.region\n",
108
+ "/* value =\n var.region */\n",
109
+ 'value = "$${var.region}"\n',
110
+ "value = each.value\nother = count.index\nmore = self.id\nlast = path.module\n",
111
+ ])
112
+ def test_text_comments_escapes_and_builtins_are_not_references(text):
113
+ assert read(f'resource "null_resource" "x" {{\n{text}}}\n')["refs"] == []
114
+
115
+
116
+ def test_heredoc_and_nested_templates_are_read():
117
+ text = '''\
118
+ resource "aws_instance" "web" {
119
+ user_data = <<-EOT
120
+ region=${var.region}
121
+ %{ if var.debug }debug=on%{ endif }
122
+ EOT
123
+ name = "${upper("${var.prefix}-web")}"
124
+ ami = data.aws_ami.base.id
125
+ subnet_id = module.vpc.subnets[0]
126
+ depends_on = [aws_iam_role.web]
127
+ }
128
+ '''
129
+ assert _names(read(text), "refs") == [
130
+ ("var.region", 3), ("var.debug", 4), ("var.prefix", 6), ("data.aws_ami.base", 7),
131
+ ("module.vpc", 8), ("aws_iam_role.web", 9),
132
+ ]
133
+
134
+
135
+ def test_a_dynamic_block_iterator_is_not_a_reference():
136
+ text = """resource "aws_lambda_alias" "live" {
137
+ dynamic "routing_config" {
138
+ for_each = var.weights
139
+ content {
140
+ weight = routing_config.value
141
+ }
142
+ }
143
+ dynamic "ingress_rule" {
144
+ for_each = var.rules
145
+ iterator = rule_item
146
+ content {
147
+ port = rule_item.value.port
148
+ key = rule_item.key
149
+ }
150
+ }
151
+ }
152
+ """
153
+ assert _names(read(text), "refs") == [("var.weights", 3), ("var.rules", 9)]
154
+
155
+
156
+ def test_an_object_literal_inside_an_interpolation_does_not_end_the_string():
157
+ text = (
158
+ 'locals { msg = "${lookup({ a = "ok" }, "a")}-suffix" }\n'
159
+ 'output "env" { value = var.env }\n'
160
+ )
161
+ facts = read(text)
162
+ assert [d["name"] for d in facts["defs"]] == ["local.msg", "output.env"]
163
+ assert [(r["name"], r["scope"]) for r in facts["refs"]] == [("var.env", "output.env")]
164
+
165
+
166
+ def test_an_attribute_named_iterator_outside_a_dynamic_block_hides_nothing():
167
+ text = '''\
168
+ variable "value" {}
169
+ locals {
170
+ iterator = var.value
171
+ }
172
+ output "value" {
173
+ value = var.value
174
+ }
175
+ '''
176
+ assert _names(read(text), "refs") == [("var.value", 3), ("var.value", 6)]
177
+
178
+
179
+ def test_a_backslash_in_a_heredoc_does_not_escape_an_interpolation():
180
+ text = r'''output "env" {
181
+ value = <<EOT
182
+ C:\${var.env}
183
+ EOT
184
+ }
185
+ '''
186
+ assert _names(read(text), "refs") == [("var.env", 3)]
187
+
188
+
189
+ def test_a_comparison_in_an_expression_is_not_a_local():
190
+ text = '''\
191
+ variable "names" {}
192
+ locals {
193
+ selected = [for name in var.names : name if name == "prod"]
194
+ other = var.names != null ? (count >= 1) : {}
195
+ }
196
+ '''
197
+ facts = read(text)
198
+ assert [d["name"] for d in facts["defs"]] == ["var.names", "local.selected", "local.other"]
199
+ assert [(r["name"], r["scope"]) for r in facts["refs"]] == [
200
+ ("var.names", "local.selected"), ("var.names", "local.other"),
201
+ ]
202
+
203
+
204
+ def test_a_key_inside_brackets_or_after_another_token_is_not_a_local():
205
+ text = '''\
206
+ locals {
207
+ tags = merge(var.base, { owner = var.owner })
208
+ list = [{ name = var.first }]
209
+ }
210
+ '''
211
+ assert [d["name"] for d in read(text)["defs"]] == ["local.tags", "local.list"]
212
+
213
+
214
+ @pytest.mark.parametrize("escaped", ["$${", "%%{"])
215
+ def test_an_escaped_template_opener_does_not_hide_the_closing_quote(escaped):
216
+ text = f'locals {{ marker = "{escaped}" }}\noutput "env" {{ value = "prod" }}\n'
217
+ assert [d["name"] for d in read(text)["defs"]] == ["local.marker", "output.env"]
218
+
219
+
220
+ def test_unicode_identifiers_keep_their_whole_names():
221
+ text = '''\
222
+ variable "région" {}
223
+ locals {
224
+ café = var.région
225
+ }
226
+ output "region" {
227
+ value = var.région
228
+ }
229
+ '''
230
+ facts = read(text)
231
+ assert [d["name"] for d in facts["defs"]] == ["var.région", "local.café", "output.region"]
232
+ assert _names(facts, "refs") == [("var.région", 3), ("var.région", 6)]
233
+
234
+
235
+ def test_a_for_binding_is_not_a_reference_to_the_configuration():
236
+ text = '''\
237
+ variable "items" {}
238
+ locals {
239
+ name = "global"
240
+ }
241
+ output "names" {
242
+ value = [for local in var.items : local.name]
243
+ }
244
+ output "pairs" {
245
+ value = {for var, module in local.map : var.id => module.vpc}
246
+ }
247
+ output "after" {
248
+ value = [local.name, var.items]
249
+ }
250
+ '''
251
+ assert _names(read(text), "refs") == [
252
+ ("var.items", 6), ("local.map", 9), ("local.name", 12), ("var.items", 12),
253
+ ]
254
+
255
+
256
+ @pytest.mark.parametrize("value", [
257
+ "[for local in [for x in var.items : x] : local.name]",
258
+ "[for local in (var.on ? var.items : []) : local.name]",
259
+ "[for local in var.on ? var.items : [] : local.name]",
260
+ "{for local in {for k, v in var.map : k => v} : local.name => 1}",
261
+ ])
262
+ def test_a_for_inside_another_for_collection_keeps_the_outer_binding(value):
263
+ text = f'output "names" {{\n value = {value}\n}}\n'
264
+ refs = [r["name"] for r in read(text)["refs"]]
265
+ assert "local.name" not in refs
266
+ assert "var.items" in refs or "var.map" in refs
267
+
268
+
269
+ def test_a_template_for_directive_over_a_comprehension_binds_its_names():
270
+ text = '''\
271
+ output "list" {
272
+ value = <<EOT
273
+ %{ for local in [for x in var.items : x] }${local.name}%{ endfor }
274
+ EOT
275
+ }
276
+ '''
277
+ assert _names(read(text), "refs") == [("var.items", 3)]
278
+
279
+
280
+ @pytest.mark.parametrize("body", ["${local.endfor}${local.name}", "${local.for}${local.name}"])
281
+ def test_only_a_directive_opens_or_closes_a_template_binding(body):
282
+ text = f'output "list" {{\n value = "%{{ for local in var.items }}{body}%{{ endfor }}"\n}}\n'
283
+ assert [r["name"] for r in read(text)["refs"]] == ["var.items"]
284
+
285
+
286
+ @pytest.mark.parametrize("loop", [
287
+ "%{ for local in var.items }${local.name}%{~ endfor }",
288
+ "%{~ for local in var.items ~}${local.name}%{~ endfor ~}",
289
+ "%{~for local in var.items~}${~local.name~}%{~endfor~}",
290
+ ])
291
+ def test_strip_markers_do_not_hide_a_directive(loop):
292
+ text = f'output "names" {{\n value = "{loop}${{local.name}}"\n}}\n'
293
+ assert [r["name"] for r in read(text)["refs"]] == ["var.items", "local.name"]
294
+
295
+
296
+ def test_a_directive_inside_a_template_for_collection_does_not_start_its_binding():
297
+ # The inner `if` directive's end began the outer loop's binding early.
298
+ text = (
299
+ 'output "names" {\n'
300
+ ' value = "%{ for local in ["%{ if var.on }${local.name}%{ endif }"] }'
301
+ '${local.name}%{ endfor }"\n'
302
+ '}\n'
303
+ )
304
+ assert [r["name"] for r in read(text)["refs"]] == ["var.on", "local.name"]
305
+
306
+
307
+ def test_a_template_for_directive_binds_its_names_until_endfor():
308
+ text = '''\
309
+ output "list" {
310
+ value = <<EOT
311
+ %{ for local in var.items }${local.name}%{ endfor }
312
+ ${local.name}
313
+ EOT
314
+ }
315
+ '''
316
+ assert _names(read(text), "refs") == [("var.items", 3), ("local.name", 4)]
317
+
318
+
319
+ @pytest.mark.parametrize("inside", [
320
+ '/* quote: " */ var.env',
321
+ 'var.env # quote: "\n',
322
+ 'var.env // quote: "\n',
323
+ 'lookup({ a = "}" }, "a", var.env)',
324
+ 'join("", [<<EOT\n"\nEOT\n, var.env])',
325
+ ])
326
+ def test_what_an_interpolation_contains_does_not_end_its_string(inside):
327
+ text = (
328
+ 'variable "env" {}\n'
329
+ f'locals {{ msg = "${{ {inside} }}-suffix" }}\n'
330
+ 'output "env" { value = var.env }\n'
331
+ )
332
+ facts = read(text)
333
+ assert [d["name"] for d in facts["defs"]] == ["var.env", "local.msg", "output.env"]
334
+ assert [(r["name"], r["scope"]) for r in facts["refs"]] == [
335
+ ("var.env", "local.msg"), ("var.env", "output.env"),
336
+ ]
337
+
338
+
339
+ @pytest.mark.parametrize("value", ['"${var.domain}."', '"${var.domain}{"', '"${var.domain}="'])
340
+ def test_string_text_that_reads_like_punctuation_is_text(value):
341
+ text = f'''\
342
+ variable "domain" {{}}
343
+ locals {{
344
+ fqdn = {value}
345
+ other = var.domain
346
+ }}
347
+ '''
348
+ facts = read(text)
349
+ assert [d["name"] for d in facts["defs"]] == ["var.domain", "local.fqdn", "local.other"]
350
+ assert [(r["name"], r["scope"]) for r in facts["refs"]] == [
351
+ ("var.domain", "local.fqdn"), ("var.domain", "local.other"),
352
+ ]
353
+
354
+
355
+ def test_an_attribute_after_an_index_is_not_a_new_reference():
356
+ text = 'output "first" {\n value = aws_instance.web[0].private_ip\n}\n'
357
+ assert _names(read(text), "refs") == [("aws_instance.web", 2)]
358
+
359
+
360
+ def test_templates_nested_too_deeply_are_refused():
361
+ deep = "x = " + '"${' * 100 + "1" + '}"' * 100 + "\n"
362
+ with pytest.raises(ValueError, match="nested too deeply"):
363
+ read(deep)
364
+
365
+
366
+ PROVIDERS = '''\
367
+ terraform {
368
+ required_providers {
369
+ aws = {
370
+ source = "hashicorp/aws"
371
+ version = "~> 5.0"
372
+ }
373
+ random = "~> 3.0"
374
+ }
375
+ }
376
+
377
+ provider "aws" {
378
+ region = var.region
379
+ }
380
+
381
+ provider "aws" {
382
+ region = var.east
383
+ alias = "east"
384
+ }
385
+
386
+ module "network" {
387
+ source = "terraform-aws-modules/vpc/aws"
388
+ version = "5.1.0"
389
+ providers = {
390
+ aws = aws.east
391
+ }
392
+ name = var.name
393
+ }
394
+
395
+ module "iam" {
396
+ source = "../../modules/iam"
397
+ }
398
+
399
+ resource "aws_s3_bucket" "logs" {
400
+ provider = aws.east
401
+ version = "not a module's"
402
+ bucket = local.name
403
+ }
404
+
405
+ data "aws_ami" "base" {
406
+ provider = aws
407
+ }
408
+ '''
409
+
410
+
411
+ def test_providers_are_definitions_and_module_calls_carry_their_source():
412
+ facts = read(PROVIDERS)
413
+ assert [(d["name"], d["kind"], d["line"], d["doc"]) for d in facts["defs"]] == [
414
+ ("provider.aws", "required_provider", 3, "source hashicorp/aws, version ~> 5.0"),
415
+ ("provider.random", "required_provider", 7, "version ~> 3.0"),
416
+ ("provider.aws", "provider", 11, ""),
417
+ ("provider.aws.east", "provider", 15, ""),
418
+ ("module.network", "module", 20, "source terraform-aws-modules/vpc/aws, version 5.1.0"),
419
+ ("module.iam", "module", 29, "source ../../modules/iam"),
420
+ ("aws_s3_bucket.logs", "resource", 33, ""),
421
+ ("data.aws_ami.base", "data", 39, ""),
422
+ ]
423
+
424
+
425
+ def test_a_provider_alias_names_its_block_and_what_selects_it():
426
+ # An alias set after the block's first attribute still renames the references read before it.
427
+ refs = [(r["name"], r["line"], r["scope"]) for r in read(PROVIDERS)["refs"]]
428
+ assert refs == [
429
+ ("var.region", 12, "provider.aws"),
430
+ ("var.east", 16, "provider.aws.east"),
431
+ ("provider.aws.east", 24, "module.network"),
432
+ ("var.name", 26, "module.network"),
433
+ ("provider.aws.east", 34, "aws_s3_bucket.logs"),
434
+ ("local.name", 36, "aws_s3_bucket.logs"),
435
+ ("provider.aws", 40, "data.aws_ami.base"),
436
+ ]
437
+
438
+
439
+ def test_object_entries_separated_by_a_colon_are_read():
440
+ facts = read(
441
+ 'terraform {\n required_providers {\n'
442
+ ' aws = { source: "hashicorp/aws", version: "5.0" }\n }\n}\n'
443
+ 'module "m" {\n source = "./m"\n providers = { aws: aws.east }\n}\n'
444
+ )
445
+ assert facts["defs"][0]["doc"] == "source hashicorp/aws, version 5.0"
446
+ assert [(r["name"], r["scope"]) for r in facts["refs"]] == [("provider.aws.east", "module.m")]
447
+
448
+
449
+ def test_terrain_def_kind_filters_before_the_match_limit(tmp_path, capsys):
450
+ # More same-named definitions of other kinds than the match limit never hide the one asked.
451
+ root = tmp_path / "repo"
452
+ root.mkdir()
453
+ blocks = [f'variable "v{n}" {{}}\noutput "x{n}" {{}}\n' for n in range(120)]
454
+ text = "".join(f'resource "aws_s3_bucket" "logs{n}" {{}}\n' for n in range(120))
455
+ (root / "a.tf").write_text(
456
+ text.replace('"logs0"', '"logs"') + "".join(blocks), encoding="utf-8"
457
+ )
458
+ (root / "b.tf").write_text(
459
+ "".join(f'resource "aws_instance{n}" "logs" {{}}\n' for n in range(120)), encoding="utf-8"
460
+ )
461
+ (root / "c.tf").write_text('module "logs" {}\n', encoding="utf-8")
462
+ argv = ["--root", str(root), "--cache-dir", str(tmp_path / "cache")]
463
+ assert main(["def", "logs", "--kind", "module", "--json", *argv]) == 0
464
+ rows = json.loads(capsys.readouterr().out)["rows"]
465
+ assert [r["name"] for r in rows] == ["module.logs"]
466
+
467
+
468
+ def test_a_module_description_is_kept_over_its_source():
469
+ facts = read('module "m" {\n description = "shared"\n source = "./m"\n}\n')
470
+ assert facts["defs"][0]["doc"] == "shared"
471
+
472
+
473
+ def test_terrain_def_lists_every_definition_of_a_kind(tmp_path, capsys):
474
+ root = tmp_path / "repo"
475
+ (root / "envs" / "dev").mkdir(parents=True)
476
+ (root / "envs" / "dev" / "main.tf").write_text(PROVIDERS, encoding="utf-8")
477
+ (root / "app.py").write_text("class Thing:\n pass\n", encoding="utf-8")
478
+ argv = ["--root", str(root), "--cache-dir", str(tmp_path / "cache")]
479
+ assert main(["def", "--kind", "module", "--json", *argv]) == 0
480
+ rows = json.loads(capsys.readouterr().out)["rows"]
481
+ assert [(r["name"], r["file"], r["doc"]) for r in rows] == [
482
+ ("module.network", "envs/dev/main.tf",
483
+ "source terraform-aws-modules/vpc/aws, version 5.1.0"),
484
+ ("module.iam", "envs/dev/main.tf", "source ../../modules/iam"),
485
+ ]
486
+ assert main(["def", "aws", "--kind", "provider", "--json", *argv]) == 0
487
+ rows = json.loads(capsys.readouterr().out)["rows"]
488
+ assert [(r["name"], r["line"]) for r in rows] == [("provider.aws", 11)]
489
+ assert main(["def", "--kind", "class", "--json", *argv]) == 0
490
+ assert [r["name"] for r in json.loads(capsys.readouterr().out)["rows"]] == ["app.Thing"]
491
+ assert main(["def", "--kind", "nope", *argv]) == 1
492
+ err = capsys.readouterr().err
493
+ assert "no definition of kind nope; kinds here: class, data, module, provider," in err
494
+ assert main(["def", *argv]) == 2
495
+ assert main(["refs", "provider.aws.east", *argv]) == 0
496
+ assert "2 references" in capsys.readouterr().out
497
+
498
+
499
+ def test_the_pack_answers_def_and_refs_through_terrain(tmp_path, capsys):
500
+ found, problems = languages.discover()
501
+ assert "terraform" in [lang.name for lang in found], problems
502
+ root = tmp_path / "repo"
503
+ (root / "infra").mkdir(parents=True)
504
+ (root / "infra" / "main.tf").write_text(MAIN, encoding="utf-8")
505
+ argv = ["--root", str(root), "--cache-dir", str(tmp_path / "cache")]
506
+ assert main(["def", "logs", "--json", *argv]) == 0
507
+ rows = json.loads(capsys.readouterr().out)["rows"]
508
+ assert [(r["name"], r["file"], r["line"], r["language"]) for r in rows] == [
509
+ ("aws_s3_bucket.logs", "infra/main.tf", 12, "terraform"),
510
+ ]
511
+ assert main(["refs", "var.region", *argv]) == 0
512
+ out = capsys.readouterr().out
513
+ assert "variable var.region infra/main.tf:2" in out
514
+ assert "3 references" in out
515
+
516
+
517
+ def test_the_language_object_matches_the_interface():
518
+ assert language.interface == languages.INTERFACE
519
+ assert language.suffixes == (".tf",)