This file provides guidance for AI coding agents working on the Terramate project.
Terramate is an orchestration, code generation, and change management tool for Infrastructure as Code (IaC), with first-class support for Terraform, OpenTofu, and Terragrunt.
Repository Structure:
/cmd/- Main binaries (terramate CLI, terramate-ls language server)/ls/- Language Server Protocol implementation/hcl/- HCL parsing and evaluation/config/- Configuration management/stack/- Stack orchestration/cloud/- Terramate Cloud integration/e2etests/- End-to-end tests/test/- Test utilities
Language: Go 1.24+ License: MPL-2.0
# Install all dependencies using the ASDF package manager
asdf install
# Check versions
go version # Should be 1.24+
make --version# Build all binaries
make build
# Output:
# - bin/terramate (CLI)
# - bin/terramate-ls (Language Server)
# - bin/helper (test helper)# Run all tests
make test
# Run specific package tests
go test ./ls/...
go test ./hcl/...
# Run with race detector
go test -race ./ls/...
# Format code
make fmt
# Install linting
make lint/install
# Run linting
make lint/all- Follow standard Go conventions: Use
gofmt, passgo vet - Error handling: Always check errors, use
errors.E()wrapper for context - Naming: Use descriptive names, avoid abbreviations except common ones (e.g.,
ctx,err) - Comments: Public functions must have doc comments starting with function name
Error handling:
// Use errors.E() for wrapping
return nil, errors.E(err, "description of what failed")
// Use errors.L() for collecting multiple errors
errs := errors.L()
errs.Append(err1)
errs.Append(err2)
return errs.AsError()Logging:
// Use zerolog for structured logging
log.Debug().Str("key", value).Msg("description")
log.Info().Int("count", n).Msg("operation complete")
log.Error().Err(err).Msg("operation failed")Testing:
// Use test helpers from test/ package
s := sandbox.New(t)
s.BuildTree(layout)
// Use assert package, not testing.T directly
assert.NoError(t, err)
assert.EqualStrings(t, want, got)
assert.IsTrue(t, condition, "message")Core Implementation:
ls.go- Main server, handler registration, LSP capabilitiesdefinition.go- Go-to definition implementationreferences.go- Find all referencesrename.go- Rename symbolimports.go- Import resolutionlabel_rename.go- Label renamingutil.go- Shared utilitieshcl_helpers.go- HCL parsing helpers
Test Files:
definition_test.go- Definition testsreferences_test.go- Reference testsrename_test.go- Rename testslabel_rename_test.go- Label rename testsls_test.go- Core server testscommands_test.go- Command testsposition_test.go- Position handling testsutil_test.go- Utility testsbenchmark_test.go- Performance benchmarksdocument_lifecycle_test.go- Document sync tests
LSP Handler Structure:
func (s *Server) handleFeature(
ctx context.Context,
reply jsonrpc2.Replier,
r jsonrpc2.Request,
log zerolog.Logger,
) error {
// 1. Unmarshal params
// 2. Process request
// 3. Return reply
return reply(ctx, result, nil)
}Definition Search Pattern:
// 1. Search current directory
// 2. Search parent directories (recursively up to workspace root)
// 3. Search imported files (follow import chains)
// 4. Return first match (child overrides parent)Testing Pattern:
// Use sandbox for file system tests
s := sandbox.New(t)
s.BuildTree([]string{
`f:globals.tm:globals { var = "value" }`,
`f:stack.tm:stack { name = global.var }`,
})
// Create test server
srv := newTestServer(t, s.RootDir())
// Test functionality
location, err := srv.findDefinition(...)
assert.NoError(t, err)Understand these Terramate namespaces:
-
global.*- Global variables (can be defined in parent directories, imported files, labeled blocks)- Simple:
globals { my_var = "value" }→global.my_var - Labeled:
globals "a" "b" { c = "x" }→global.a.b.c - Nested:
globals { a = { b = { c = "x" } } }→global.a.b.c
- Simple:
-
let.*- Let variables (scoped to generate blocks)generate_hcl { lets { x = "y" } }→let.x
-
terramate.stack.*- Stack metadata (built-in)terramate.stack.name,terramate.stack.id, etc.
-
env.*- Environment variables (defined interramate.config.run.env) -
stack.*- NOT VALID (useterramate.stack.*instead)
Critical: Globals can be defined in imported files:
# File A
globals { project_id = "123" }
# File B
import { source = "/file_a.tm" }
globals { x = global.project_id } # Must resolve through import!Implementation must:
- Parse import statements
- Recursively follow import chains
- Search imported files for definitions
- Handle circular imports (visited tracking)
Child overrides parent:
# Parent: /globals.tm
globals { env = "default" }
# Child: /stacks/prod/globals.tm
globals { env = "production" } # This wins!
# Search order: Current dir → Parent dirs → Imports# All tests
make test
# Specific package
go test ./ls/...
# Verbose
go test -v ./ls/...
# With race detector (important!)
go test -race ./ls/...
# Specific test
go test ./ls/... -run TestFindDefinition
# With coverage
go test -cover ./ls/...Before committing:
- ✅ All tests must pass:
go test ./ls/... - ✅ Race detector clean:
go test -race ./ls/... - ✅ Linting clean:
golangci-lint run ./ls/... - ✅ Formatting correct:
gofmt -l ls/*.go(should return empty)
When adding features:
- ✅ Add tests in
*_test.gofiles - ✅ Use table-driven tests for multiple scenarios
- ✅ Test edge cases (empty files, malformed HCL, circular imports)
- ✅ Test real-world scenarios (like iac-gcloud examples)
Use sandbox for LSP tests:
s := sandbox.New(t)
s.BuildTree([]string{
`f:path/to/file.tm:content`,
`s:stack/path`, // Create stack
})- Performance: Only parse files as needed, cache where possible
- Robustness: Handle errors gracefully (return nil, don't crash)
- Completeness: Search current directory, parents, AND imports
- Correctness: Match Terramate's runtime behavior exactly
Import resolution (recursive with circular protection):
// Search strategy (in imports.go):
// 1. Search current directory
// 2. Extract imports from all .tm files in directory
// 3. Search each imported file
// 4. Extract imports from imported file (nested imports)
// 5. Recursively search those (can be N levels deep!)
// 6. Move to parent directory and repeat
// 7. Track visited files to prevent circular import loops
// Real-world example (iac-gcloud):
// File A → imports default.tm
// default.tm → imports 13 other files
// One of those defines the global
// System finds it through the entire chain!Label vs Nested Object detection:
// Both create global.a.b:
globals "a" "b" { ... } // Labeled block
globals { a = { b = { ... } } } // Nested object
// Detection (in references.go):
// - Check if labeled block exists: hasLabeledBlockWithPath()
// - Search current dir, parents, AND imports
// - Used for rename logic to differentiate label from object keyImport resolution (recursive):
// Must follow import chains:
// File A imports B, B imports C, C defines global
// Search order: A → A's imports → B → B's imports → CExpensive operations (cache/optimize):
- File parsing (use visited maps)
- Directory scanning (early exit on match)
- Import resolution (visited tracking for circular imports)
Cheap operations:
- AST traversal (after parsing)
- Range checks
- String comparisons
HCL uses 1-indexed positions, LSP uses 0-indexed:
// Converting HCL to LSP
lspLine = hclLine - 1
lspChar = hclChar - 1
// TraverseAttr.SrcRange includes the preceding dot!
// For ".attr", SrcRange.Start points to the dot, not 'a'
// Add 1 to skip the dot when neededAbsolute vs Relative:
// Absolute (project-relative): starts with /
source = "/modules/shared/globals.tm"
// Resolve: workspace + source
// Relative: no leading /
source = "../shared/globals.tm"
// Resolve: currentDir + source// Path ["a", "b", "c"] can match:
globals "a" "b" "c" { ... } // Exact match
globals "a" "b" "c" "d" { ... } // Prefix match
globals "a" "b" { c = "..." } // Labels + attribute
// Use matchesLabeledGlobal() carefully!Avoid:
- Hard-coded line numbers (use relative positions)
- Assuming file order (files may be parsed in any order)
- Timing dependencies (use proper synchronization)
Do:
- Use
t.Parallel()for test concurrency - Clean up temporary files
- Use
sandbox.New(t)for isolated test environments
Always validate:
- Workspace boundaries (use
strings.HasPrefix(path, s.workspace)) - No escaping workspace root (
..attacks) - Import paths don't escape workspace
// Good
if !strings.HasPrefix(resolvedPath, s.workspace) {
return nil, errors.E("path outside workspace")
}
// Bad
// Blindly joining paths without validationUse mutexes for:
- Shared caches
- Concurrent map access
- File system operations from multiple goroutines
Test with: go test -race
- Run full test suite:
make test - Run race detector:
go test -race ./ls/... - Format code:
make fmt - Check linting:
make lint/all - Update documentation: If adding features, update
ls/README.md
feat(ls): add cursor-aware path navigation
fix(ls): correct import resolution for circular imports
docs(ls): update README with label renaming examples
test(ls): add tests for nested object navigation
- What: Feature/fix description
- Why: Problem being solved
- How: Implementation approach
- Testing: How it was tested
- Breaking changes: If any
- Tests pass (including race detector)
- No new linter errors
- Code formatted (gofmt)
- Documentation updated
- No commented-out code
- Error messages are helpful
- Edge cases tested
Complexity: High
Why: Recursive, circular import detection, path resolution
Key functions:
findGlobalWithImports()- Entry pointsearchWithImports()- Searches dir + importssearchImportedFiles()- Recursive import followingcollectImportsFromDir()- Extracts imports from files
Testing: Use nested import chains, circular imports, relative paths
Complexity: Very High
Why: Must distinguish labels from nested objects, update both definitions and references
Key challenge: global.a.b.c could be:
globals "a" "b" { c = "..." }(labels)globals { a = { b = { c = "..." } } }(nested)
Must check actual definition to determine which!
Complexity: High
Why: Must detect precise cursor position, truncate paths correctly
Key function: truncateTraversalAtCursor() in util.go
Testing: Click on different parts of global.a.b.c.d.e.f
# Start language server with debug output
./bin/terramate-ls --log-level debug --log-fmt console 2> ls-debug.log
# Watch logs
tail -f ls-debug.logWhen testing in VSCode:
- View → Output
- Select "Terramate Language Server" from dropdown
- See real-time LSP communication
Go-to definition not working:
- Check workspace root is correct
- Verify import paths resolve correctly
- Check if cursor truncation is interfering
Rename not working:
- Check if
isLabelComponentdetection is correct - Verify
findAllReferencesreturns correct ranges - Check workspace edit is being created
Tests failing:
- Run single test:
go test ./ls/... -run TestName -v - Check for race conditions:
go test -race - Verify sandbox setup is correct
Terramate Documentation:
- https://terramate.io/docs/cli/
- https://terramate.io/docs/cli/reference/variables/globals
- https://terramate.io/docs/cli/reference/variables/map
LSP Specification:
HCL Parser:
Testing:
- Use
test/sandboxfor file system isolation - Use
test/hclutilsfor HCL test helpers