Terraform Companion
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.
Install from the VS Code Marketplace →
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.
Install
Terraform Companion on the VS Code Marketplace — or from the command line:
code --install-extension Federico-Baldan.tf-companion
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 ~> 5 constraints that leave the upper end open. Optionally, variables missing a description or type.
- 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.
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.
It tells you what it is about to delete. The prompt names the modules — infra/prod, modules/vpc — rather than just counting them, and Review… opens a checklist with, for every cache, the full path, its size, the date it was last used and the exact subdirectories that go:
[x] infra/prod 12.0 MB · last activity 2026-05-04
/repo/infra/prod/.terraform — deletes .terraform/providers, .terraform/modules
[x] modules/vpc 5.0 MB · last activity 2026-02-13
/repo/modules/vpc/.terraform — deletes .terraform/providers
Everything starts checked; uncheck anything you want to keep and only the rest is deleted. Whatever you choose, the Terraform Companion output channel gets the full inventory — one line per cache, with its path and its victims — before anything is removed.
Only the cache goes. providers, plugins and modules are deleted; .terraform itself and the metadata beside it stay, so your selected workspace and your -backend-config settings survive. Your state is never touched either: it lives in terraform.tfstate and terraform.tfstate.d/ beside .terraform, not inside it, and .terraform.lock.hcl is a sibling too. A pre-0.14 plugins/<os>_<arch>/ with no lock.json is left alone — that is a hand-placed binary no terraform init can bring back. The scan never follows symlinks, so it can't wander out of the workspace, and it only ever removes a directory named exactly .terraform. Dismiss the prompt and it holds for a week instead of returning on the next window. Flip cacheCleaner.autoDelete on to skip the prompt entirely.
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 |
on startup, offer to delete the cache of modules with no recent activity |
cacheCleaner.staleDays |
30 |
days of inactivity before a .terraform counts as stale; floored at 1 |
cacheCleaner.autoDelete |
false |
delete without asking — the folders are still listed in the output channel |
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
Licence
MIT.
Built by Federico Baldan. Bugs and feature requests go in Issues.