Version CodeLens, resolved-value hover on var and local, count → for_each refactor,
plus unused-local and version-constraint lints.
What does that var.* or local.* actually resolve to? Hover and find out.
You almost certainly already run the HashiCorp Terraform extension. Keep it. Formatting, completion, syntax and validation all go through terraform-ls, and nothing here reaches into any of that.
This fills the gaps terraform-ls leaves: it won't tell you a provider constraint is a major release behind, and it won't tell you what var.environment resolves to until you run a plan. Install both and they stay out of each other's way.
Requires VS Code 1.125 or newer. Activates on .tf / .tfvars files.
Version CodeLens
terraform {
required_providers {
# → 6.0.1 blocked by your constraint (1 major)
aws = {
source = "hashicorp/aws"
version = "~> 5.34"
}
}
}
The lens sits over the version line. Click it to bump the constraint or jump to the registry page. It measures the gap from the release your constraint would actually install, not from the floor you typed, so a ~> 5.34 that can't cross into 6.x reads blocked rather than pretending everything's fine; an exact pin that could just be raised reads behind. Modules work the same way. Answers are cached six hours, and a source the registry doesn't recognise shows nothing at all instead of an error.
Resolved-value hover
Point at a var.* or local.* and you get the string that actually lands there, plus its provenance: a tfvars file, a variable default, an argument passed at a module call. There's a copy link right in the hover.
Under the value sits its length in characters. Half of what a local resolves to is a name on its way into a field with a hard ceiling — an AWS target group stops at 32 — and 28 chars under satispay-payments-green-prod is cheaper to read than an apply that fails. It counts the real string, so a newline is one character and not the two of the \n you see rendered; instances that disagree each carry their own count. Where there is no length to report it says nothing rather than a number you'd act on: a list or an object is rendered here, and its brackets and commas are notation, not infrastructure; a value still carrying ⟨unknown⟩ in one slot has no length yet.
It follows the whole chain. local.name = "app-${var.env}" with var.env = "dev" in tfvars resolves to app-dev, not a bare dev. Inside a called module, var.* comes from the call site and walks back up to the root and its tfvars, falling back to the module's own default when nobody passes it. Two module blocks passing different values? You get one line per instance instead of a lie about which one won.
A status bar item pins which tfvars file counts as active, for when auto-loading isn't what you want. The pin behaves like -var-file: it belongs to the module you were in when you set it, and the file itself can live anywhere — a central environments/prod.tfvars beside the module, or a folder above it. Each module keeps its own pin, and the bar reports the one for the file you're looking at.
The picker lists what that module could plausibly read, labelled by path relative to the module, so twenty files named prod.tfvars stay distinguishable. It recognises a vars folder by what it holds rather than by what it is called: a directory carrying .tf files is somebody's module and its tfvars answer to it — its whole subtree does, so other/env/prod.tfvars belongs to other exactly as other/prod.tfvars does — and everything else holding tfvars in the module's neighbourhood is fair game: environments/prod/, a Terragrunt-shaped live/prod/vpc/, config/, or a name only your company uses. Nearest first, where "near" charges for detours: your own module's folders cost nothing whatever they are called, a folder that names itself is cheap, and wandering into somebody else's subtree isn't — so infra/deploy/prod.tfvars stays above anything in infra's parent, and in a monorepo the environments/ everyone shares stays above the six sibling teams that happen to sit closer to you. It still doesn't enumerate the whole repo, and it's only a shortlist anyway: Browse… reaches everything else, including files outside the workspace.
Because a pin belongs to a module, the picker needs a .tf file open to know which one you mean. Open it on a module that's called from somewhere else and it says so instead of taking a pin it would then ignore — Terraform never reads tfvars for a called module, its values come from the call site.
count → for_each
resource "aws_instance" "web" {
count = length(var.instance_names)
tags = { Name = var.instance_names[count.index] }
}
Drop the first name from that list and Terraform destroys and recreates every instance after it, because count keys resources by position. The quick fix rewrites the block to for_each = toset(...) with each.value in place of the indexed reads, so each instance is keyed by its own value and reordering the list stops mattering.
It only offers the rewrite when the rewrite is safe. If count.index also drives something else in the block, if the resource is addressed by index somewhere else (web[0], web[*], a cross-file depends_on), if the elements get read as objects, or if the values would collapse under toset() because they're duplicates or not strings, the fix stays hidden. One caveat it can't fix for you: resources already in state need terraform state mv, since their addresses change from [0] to ["web"].
Lints
- Unused locals, flagged on the definition line, scoped to the whole module so a local defined in one file and used in another doesn't get a false positive.
- Version hygiene: registry modules with no
version, and>=/ bare~> 5constraints that leave the upper end open. Optionally, variables missing adescriptionortype. - Redundant
depends_on: an entry the block's own arguments already imply. The quick fix splices just that entry out and leaves your comments and formatting alone.
.terraform cache cleanup
On startup it looks for .terraform folders whose module hasn't been touched in 30 days, and asks before deleting. These are caches terraform init rebuilds. Your state is never touched: it lives in terraform.tfstate and terraform.tfstate.d/ beside .terraform, not inside it, and .terraform.lock.hcl is a sibling too. What you do lose is the selected workspace, which resets to default, and the cached backend configuration — a module initialised with terraform init -backend-config=… needs those flags again. The scan never follows symlinks, so it can't wander out of the workspace, and it only ever removes a directory named exactly .terraform. Flip cacheCleaner.autoDelete on to skip the prompt.
Offline
The CodeLens is the only thing that touches the network. No connection, or a registry that's down, and it serves the last cached answer or renders nothing — no popups, no red squiggles. Everything else runs entirely on your machine.
Settings
All keys are under tfCompanion.
| Setting | Default | Notes |
|---|---|---|
versionLens.enabled |
true |
|
versionLens.cacheTtlHours |
6 |
floored at 5 minutes |
resolvedHover.enabled |
true |
|
countForEach.enabled |
true |
|
dependsOn.enabled |
true |
|
unusedLocals.enabled |
true |
|
versionHygiene.enabled |
true |
|
versionHygiene.variableDocs |
false |
also flag variables without description or type |
cacheCleaner.enabled |
true |
|
cacheCleaner.staleDays |
30 |
floored at 1 |
cacheCleaner.autoDelete |
false |
delete without asking |
Development
npm install
npm run build # extension + WASM grammar into dist/
npm run watch # rebuild on change
npm test # vitest
npm run typecheck # tsc --noEmit
npm run lint # biome check
npm run package # .vsix
MIT.