Skip to content
| Marketplace
Sign in
Visual Studio Code>Programming Languages>Terraform CompanionNew to Visual Studio Code? Get it now.
Terraform Companion

Terraform Companion

Federico-Baldan

|
15 installs
| (0) | Free
Terraform version CodeLens, resolved-value hover on var and local, count→for_each refactor, plus unused-local and version-constraint lints.
Installation
Launch VS Code Quick Open (Ctrl+P), paste the following command, and press enter.
Copied to clipboard
More Info

Terraform Companion

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.

VS Code Marketplace version CI MIT licence

Install from the VS Code Marketplace →

Hovering over a var or local shows its resolved value


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.

.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.

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.

  • Contact us
  • Jobs
  • Privacy
  • Manage cookies
  • Terms of use
  • Trademarks
  • Your Privacy Choices
  • Consumer Health Privacy
© 2026 Microsoft