├── vscode-extension ├── LICENSE ├── testFixture │ ├── completion.txt │ └── diagnostics.txt ├── .gitignore ├── images │ └── icon.png ├── scripts │ └── package.sh ├── .vscode │ ├── settings.json │ ├── extensions.json │ ├── tasks.json │ └── launch.json ├── tsconfig.json ├── .vscodeignore ├── eslint.config.mjs ├── docs │ └── development.md ├── package.json ├── README.md └── src │ └── extension.ts ├── zed-extension ├── .gitignore ├── languages │ └── terragrunt │ │ ├── brackets.scm │ │ ├── indents.scm │ │ ├── injections.scm │ │ ├── config.toml │ │ ├── outline.scm │ │ └── highlights.scm ├── Cargo.toml ├── README.md ├── extension.toml ├── src │ └── lib.rs └── Cargo.lock ├── internal ├── lsp │ ├── consts.go │ ├── lsp.go │ ├── textdocument_didopen.go │ ├── textdocument_diagnostics.go │ ├── textdocument_didchange.go │ ├── textdocument_format.go │ ├── textdocument_definition.go │ ├── textdocument_completion.go │ ├── textdocument_hover.go │ ├── message.go │ └── initialize.go ├── tg │ ├── tg.go │ ├── store │ │ └── store.go │ ├── text │ │ ├── text.go │ │ └── text_test.go │ ├── hover │ │ ├── hover_test.go │ │ └── hover.go │ ├── definition │ │ ├── definition_test.go │ │ └── definition.go │ ├── parse_test.go │ ├── parse.go │ ├── completion │ │ ├── completion_test.go │ │ └── completion.go │ ├── state.go │ └── state_test.go ├── rpc │ ├── rpc_test.go │ └── rpc.go ├── ast │ ├── position.go │ ├── ast.go │ └── ast_test.go ├── config │ └── config.go ├── logger │ └── logger.go └── testutils │ └── testutils.go ├── .gitignore ├── mise.toml ├── .licensei.toml ├── lua └── terragrunt-ls.lua ├── .github ├── dependabot.yml └── workflows │ ├── build.yml │ ├── test.yml │ ├── license-check.yml │ └── lint.yml ├── README.md ├── docs ├── server-capabilities.md ├── setup.md └── contributing.md ├── .golangci.yml ├── main.go ├── go.mod └── LICENSE /vscode-extension/LICENSE: -------------------------------------------------------------------------------- 1 | ../LICENSE -------------------------------------------------------------------------------- /vscode-extension/testFixture/completion.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /zed-extension/.gitignore: -------------------------------------------------------------------------------- 1 | grammars 2 | target 3 | *.wasm 4 | -------------------------------------------------------------------------------- /vscode-extension/testFixture/diagnostics.txt: -------------------------------------------------------------------------------- 1 | ANY browsers, ANY OS. -------------------------------------------------------------------------------- /vscode-extension/.gitignore: -------------------------------------------------------------------------------- 1 | out 2 | node_modules 3 | .vscode-test 4 | *.tsbuildinfo 5 | *.vsix 6 | -------------------------------------------------------------------------------- /vscode-extension/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gruntwork-io/terragrunt-ls/HEAD/vscode-extension/images/icon.png -------------------------------------------------------------------------------- /internal/lsp/consts.go: -------------------------------------------------------------------------------- 1 | package lsp 2 | 3 | const ( 4 | name = "terragrunt-ls" 5 | version = "0.1.0" 6 | 7 | RPCVersion = "2.0" 8 | ) 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | terragrunt-ls 2 | 3 | # Put temporary fixtures here. 4 | tmp-fixtures 5 | 6 | 7 | .DS_Store 8 | *.log 9 | 10 | vendor 11 | .licensei.cache 12 | -------------------------------------------------------------------------------- /internal/tg/tg.go: -------------------------------------------------------------------------------- 1 | // Package tg provides the main logic used by the Terragrunt Language Server, 2 | // as it pertains to Terragrunt configuration files. 3 | package tg 4 | -------------------------------------------------------------------------------- /internal/lsp/lsp.go: -------------------------------------------------------------------------------- 1 | // Package lsp provides the data structures 2 | // for different events that can be sent to 3 | // and from the Terragrunt Language Server. 4 | package lsp 5 | -------------------------------------------------------------------------------- /mise.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | go = "1.24.4" 3 | golangci-lint = "2.1.6" 4 | lua = "5.4.7" 5 | node = "23.10.0" 6 | rust = "1.85.1" 7 | "go:github.com/goph/licensei/cmd/licensei" = "v0.9.0" 8 | -------------------------------------------------------------------------------- /zed-extension/languages/terragrunt/brackets.scm: -------------------------------------------------------------------------------- 1 | ("(" @open ")" @close) 2 | ("[" @open "]" @close) 3 | ("{" @open "}" @close) 4 | ((block_start) @open (block_end) @close) 5 | ((tuple_start) @open (tuple_end) @close) 6 | -------------------------------------------------------------------------------- /internal/lsp/textdocument_didopen.go: -------------------------------------------------------------------------------- 1 | package lsp 2 | 3 | import "go.lsp.dev/protocol" 4 | 5 | type DidOpenTextDocumentNotification struct { 6 | Notification 7 | Params protocol.DidOpenTextDocumentParams `json:"params"` 8 | } 9 | -------------------------------------------------------------------------------- /internal/lsp/textdocument_diagnostics.go: -------------------------------------------------------------------------------- 1 | package lsp 2 | 3 | import "go.lsp.dev/protocol" 4 | 5 | type PublishDiagnosticsNotification struct { 6 | Notification 7 | Params protocol.PublishDiagnosticsParams `json:"params"` 8 | } 9 | -------------------------------------------------------------------------------- /internal/lsp/textdocument_didchange.go: -------------------------------------------------------------------------------- 1 | package lsp 2 | 3 | import "go.lsp.dev/protocol" 4 | 5 | type DidChangeTextDocumentNotification struct { 6 | Notification 7 | Params protocol.DidChangeTextDocumentParams `json:"params"` 8 | } 9 | -------------------------------------------------------------------------------- /zed-extension/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "terragrunt-ls" 3 | version = "0.0.1" 4 | edition = "2021" 5 | 6 | [lib] 7 | crate-type = ["cdylib"] 8 | path = "src/lib.rs" 9 | 10 | [dependencies] 11 | zed_extension_api = "0.2.0" 12 | -------------------------------------------------------------------------------- /vscode-extension/scripts/package.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 6 | 7 | pushd "$SCRIPT_DIR/.." >/dev/null 8 | 9 | go build -o out/terragrunt-ls .. 10 | -------------------------------------------------------------------------------- /vscode-extension/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.insertSpaces": false, 3 | "typescript.tsc.autoDetect": "off", 4 | "typescript.preferences.quoteStyle": "single", 5 | "editor.codeActionsOnSave": { 6 | "source.fixAll.eslint": "explicit" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /internal/lsp/textdocument_format.go: -------------------------------------------------------------------------------- 1 | package lsp 2 | 3 | import "go.lsp.dev/protocol" 4 | 5 | type FormatRequest struct { 6 | Params protocol.DocumentFormattingParams `json:"params"` 7 | Request 8 | } 9 | 10 | type FormatResponse struct { 11 | Response 12 | Result []protocol.TextEdit `json:"result"` 13 | } 14 | -------------------------------------------------------------------------------- /internal/lsp/textdocument_definition.go: -------------------------------------------------------------------------------- 1 | package lsp 2 | 3 | import "go.lsp.dev/protocol" 4 | 5 | type DefinitionRequest struct { 6 | Params protocol.DefinitionParams `json:"params"` 7 | Request 8 | } 9 | 10 | type DefinitionResponse struct { 11 | Response 12 | Result protocol.Location `json:"result"` 13 | } 14 | -------------------------------------------------------------------------------- /internal/lsp/textdocument_completion.go: -------------------------------------------------------------------------------- 1 | package lsp 2 | 3 | import "go.lsp.dev/protocol" 4 | 5 | type CompletionRequest struct { 6 | Params protocol.CompletionParams `json:"params"` 7 | Request 8 | } 9 | 10 | type CompletionResponse struct { 11 | Response 12 | Result []protocol.CompletionItem `json:"result"` 13 | } 14 | -------------------------------------------------------------------------------- /vscode-extension/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations. 3 | // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp 4 | 5 | // List of extensions which should be recommended for users of this workspace. 6 | "recommendations": [ 7 | "dbaeumer.vscode-eslint" 8 | ] 9 | } -------------------------------------------------------------------------------- /vscode-extension/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es2020", 5 | "lib": [ 6 | "es2020" 7 | ], 8 | "outDir": "out", 9 | "rootDir": "src", 10 | "sourceMap": true 11 | }, 12 | "include": [ 13 | "src" 14 | ], 15 | "exclude": [ 16 | "node_modules", 17 | ".vscode-test" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /internal/lsp/textdocument_hover.go: -------------------------------------------------------------------------------- 1 | package lsp 2 | 3 | import "go.lsp.dev/protocol" 4 | 5 | type HoverRequest struct { 6 | Params protocol.HoverParams `json:"params"` 7 | Request 8 | } 9 | 10 | type HoverResponse struct { 11 | Response 12 | Result HoverResult `json:"result"` 13 | } 14 | 15 | type HoverResult struct { 16 | Contents protocol.MarkupContent `json:"contents"` 17 | } 18 | -------------------------------------------------------------------------------- /internal/lsp/message.go: -------------------------------------------------------------------------------- 1 | package lsp 2 | 3 | type Request struct { 4 | RPC string `json:"jsonrpc"` 5 | Method string `json:"method"` 6 | ID int `json:"id"` 7 | } 8 | 9 | type Response struct { 10 | ID *int `json:"id,omitempty"` 11 | RPC string `json:"jsonrpc"` 12 | } 13 | 14 | type Notification struct { 15 | RPC string `json:"jsonrpc"` 16 | Method string `json:"method"` 17 | } 18 | -------------------------------------------------------------------------------- /.licensei.toml: -------------------------------------------------------------------------------- 1 | approved = [ 2 | "apache-2.0", 3 | "bsd-2-clause", 4 | "bsd-3-clause", 5 | "isc", 6 | "mpl-2.0", 7 | "mit", 8 | "mit-0", 9 | ] 10 | 11 | ignored = [ 12 | "github.com/terraform-linters/tflint-plugin-sdk", 13 | "github.com/owenrumney/go-sarif", 14 | "github.com/davecgh/go-spew" 15 | ] 16 | 17 | 18 | [header] 19 | ignorePaths = ["vendor", ".gen"] 20 | ignoreFiles = ["mock_*.go", "*_gen.go"] 21 | -------------------------------------------------------------------------------- /zed-extension/languages/terragrunt/indents.scm: -------------------------------------------------------------------------------- 1 | ; https://github.com/nvim-treesitter/nvim-treesitter/blob/ce4adf11cfe36fc5b0e5bcdce0c7c6e8fbc9798a/queries/hcl/indents.scm 2 | [ 3 | (block) 4 | (object) 5 | (tuple) 6 | (function_call) 7 | ] @indent 8 | 9 | [ 10 | "]" 11 | "}" 12 | ")" 13 | ] @outdent 14 | 15 | ; https://github.com/nvim-treesitter/nvim-treesitter/blob/ce4adf11cfe36fc5b0e5bcdce0c7c6e8fbc9798a/queries/terraform/indents.scm 16 | ; inherits: hcl 17 | -------------------------------------------------------------------------------- /zed-extension/languages/terragrunt/injections.scm: -------------------------------------------------------------------------------- 1 | ; https://github.com/nvim-treesitter/nvim-treesitter/blob/ce4adf11cfe36fc5b0e5bcdce0c7c6e8fbc9798a/queries/hcl/injections.scm 2 | 3 | (heredoc_template 4 | (template_literal) @injection.content 5 | (heredoc_identifier) @injection.language 6 | (#downcase! @injection.language)) 7 | 8 | ; https://github.com/nvim-treesitter/nvim-treesitter/blob/ce4adf11cfe36fc5b0e5bcdce0c7c6e8fbc9798a/queries/terraform/injections.scm 9 | ; inherits: hcl 10 | -------------------------------------------------------------------------------- /internal/tg/store/store.go: -------------------------------------------------------------------------------- 1 | // Package store provides the logic for the state stored for each document. 2 | // 3 | // Whenever possible, stored state should be used instead of re-parsing the document. 4 | package store 5 | 6 | import ( 7 | "github.com/gruntwork-io/terragrunt/config" 8 | "github.com/zclconf/go-cty/cty" 9 | 10 | "terragrunt-ls/internal/ast" 11 | ) 12 | 13 | type Store struct { 14 | AST *ast.IndexedAST 15 | Cfg *config.TerragruntConfig 16 | CfgAsCty cty.Value 17 | Document string 18 | } 19 | -------------------------------------------------------------------------------- /vscode-extension/.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode/** 2 | **/*.ts 3 | **/*.map 4 | .gitignore 5 | **/tsconfig.json 6 | **/tsconfig.base.json 7 | contributing.md 8 | .travis.yml 9 | client/node_modules/** 10 | !client/node_modules/vscode-jsonrpc/** 11 | !client/node_modules/vscode-languageclient/** 12 | !client/node_modules/vscode-languageserver-protocol/** 13 | !client/node_modules/vscode-languageserver-types/** 14 | !client/node_modules/{minimatch,brace-expansion,concat-map,balanced-match}/** 15 | !client/node_modules/{semver,lru-cache,yallist}/** -------------------------------------------------------------------------------- /lua/terragrunt-ls.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | M.client = nil 4 | 5 | M.config = { 6 | cmd = { "terragrunt-ls" }, 7 | cmd_env = {}, 8 | } 9 | 10 | function M.setup(user_config) 11 | M.config = vim.tbl_deep_extend("force", M.config, user_config or {}) 12 | 13 | M.client = vim.lsp.start_client({ 14 | name = "terragrunt-ls", 15 | cmd = M.config.cmd, 16 | cmd_env = M.config.cmd_env, 17 | }) 18 | 19 | if not M.client then 20 | vim.notify("Failed to start terragrunt-ls", "error") 21 | return false 22 | end 23 | 24 | return true 25 | end 26 | 27 | return M 28 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | 13 | - package-ecosystem: "gomod" 14 | directory: "/" 15 | schedule: 16 | interval: "weekly" 17 | -------------------------------------------------------------------------------- /zed-extension/README.md: -------------------------------------------------------------------------------- 1 | # zed-terragrunt 2 | 3 | [Zed extension](https://zed.dev/docs/extensions/installing-extensions) for [terragrunt-ls](https://github.com/gruntwork-io/terragrunt-ls), mostly based on [terraform extension](https://github.com/zed-extensions/terraform) 4 | 5 | ## Configuration 6 | 7 | By default this extension will only recognize all HCL files as valid Terragrunt configuration files. You can configure the following setting to adjust this: 8 | 9 | ```json 10 | { 11 | "file_types": { 12 | "Terragrunt": [ 13 | "terragrunt.hcl", 14 | "terragrunt.stack.hcl", 15 | "root.hcl" 16 | ] 17 | } 18 | } 19 | ``` 20 | -------------------------------------------------------------------------------- /zed-extension/extension.toml: -------------------------------------------------------------------------------- 1 | id = "terragrunt-ls" 2 | name = "Terragrunt" 3 | version = "0.0.1" 4 | schema_version = 1 5 | authors = [ 6 | "Tomasz Warczykowski ", 7 | "Terragrunt Language Server Contributors" 8 | ] 9 | description = "Terragrunt Language Server" 10 | repository = "https://github.com/gruntwork-io/terragrunt-ls/zed-extension" 11 | 12 | [language_servers.terragrunt] 13 | name = "Terragrunt LSP" 14 | languages = ["Terragrunt"] 15 | language_ids = { Terragrunt = "terragrunt" } 16 | 17 | [grammars.hcl] 18 | repository = "https://github.com/tree-sitter-grammars/tree-sitter-hcl" 19 | commit = "9e3ec9848f28d26845ba300fd73c740459b83e9b" 20 | -------------------------------------------------------------------------------- /vscode-extension/.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "npm", 6 | "script": "compile", 7 | "group": "build", 8 | "presentation": { 9 | "panel": "dedicated", 10 | "reveal": "never" 11 | }, 12 | "problemMatcher": [ 13 | "$tsc" 14 | ] 15 | }, 16 | { 17 | "type": "npm", 18 | "script": "watch", 19 | "isBackground": true, 20 | "group": { 21 | "kind": "build", 22 | "isDefault": true 23 | }, 24 | "presentation": { 25 | "panel": "dedicated", 26 | "reveal": "never" 27 | }, 28 | "problemMatcher": [ 29 | "$tsc-watch" 30 | ] 31 | } 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /zed-extension/languages/terragrunt/config.toml: -------------------------------------------------------------------------------- 1 | name = "Terragrunt" 2 | grammar = "hcl" 3 | path_suffixes = ["hcl"] 4 | line_comments = ["# ", "// "] 5 | block_comment = ["/*", "*/"] 6 | autoclose_before = ",}])" 7 | brackets = [ 8 | { start = "{", end = "}", close = true, newline = true }, 9 | { start = "[", end = "]", close = true, newline = true }, 10 | { start = "(", end = ")", close = true, newline = true }, 11 | { start = "\"", end = "\"", close = true, newline = false, not_in = [ 12 | "comment", 13 | "string", 14 | ] }, 15 | { start = "'", end = "'", close = true, newline = false, not_in = [ 16 | "comment", 17 | "string", 18 | ] }, 19 | { start = "/*", end = " */", close = true, newline = false, not_in = [ 20 | "comment", 21 | "string", 22 | ] }, 23 | ] 24 | tab_size = 2 25 | -------------------------------------------------------------------------------- /zed-extension/src/lib.rs: -------------------------------------------------------------------------------- 1 | use zed_extension_api as zed; 2 | 3 | struct TerragruntLsExtension; 4 | 5 | impl zed::Extension for TerragruntLsExtension { 6 | fn new() -> Self { 7 | Self 8 | } 9 | fn language_server_command( 10 | &mut self, 11 | _language_server_id: &zed_extension_api::LanguageServerId, 12 | worktree: &zed_extension_api::Worktree, 13 | ) -> zed_extension_api::Result { 14 | let path = worktree 15 | .which("terragrunt-ls") 16 | .ok_or_else(|| "The LSP for Terragrunt 'terragrunt-ls' is not installed".to_string())?; 17 | 18 | Ok(zed::Command { 19 | command: path, 20 | args: vec![], 21 | env: Default::default(), 22 | }) 23 | } 24 | } 25 | 26 | zed::register_extension!(TerragruntLsExtension); 27 | -------------------------------------------------------------------------------- /internal/rpc/rpc_test.go: -------------------------------------------------------------------------------- 1 | package rpc_test 2 | 3 | import ( 4 | "terragrunt-ls/internal/rpc" 5 | "testing" 6 | ) 7 | 8 | type EncodingExample struct { 9 | Testing bool 10 | } 11 | 12 | func TestEncode(t *testing.T) { 13 | t.Parallel() 14 | 15 | expected := "Content-Length: 16\r\n\r\n{\"Testing\":true}" 16 | actual := rpc.EncodeMessage(EncodingExample{Testing: true}) 17 | if expected != actual { 18 | t.Fatalf("Expected: %s, Actual: %s", expected, actual) 19 | } 20 | } 21 | 22 | func TestDecode(t *testing.T) { 23 | t.Parallel() 24 | 25 | incomingMessage := "Content-Length: 15\r\n\r\n{\"Method\":\"hi\"}" 26 | method, content, err := rpc.DecodeMessage([]byte(incomingMessage)) 27 | contentLength := len(content) 28 | if err != nil { 29 | t.Fatal(err) 30 | } 31 | 32 | if contentLength != 15 { 33 | t.Fatalf("Expected: 16, Got: %d", contentLength) 34 | } 35 | 36 | if method != "hi" { 37 | t.Fatalf("Expected: 'hi', Got: %s", method) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /internal/lsp/initialize.go: -------------------------------------------------------------------------------- 1 | package lsp 2 | 3 | import "go.lsp.dev/protocol" 4 | 5 | type InitializeRequest struct { 6 | Request 7 | Params protocol.InitializeParams `json:"params"` 8 | } 9 | 10 | type InitializeResponse struct { 11 | Result protocol.InitializeResult `json:"result"` 12 | Response 13 | } 14 | 15 | type ServerInfo struct { 16 | Name string `json:"name"` 17 | Version string `json:"version"` 18 | } 19 | 20 | func NewInitializeResponse(id int) InitializeResponse { 21 | return InitializeResponse{ 22 | Response: Response{ 23 | RPC: RPCVersion, 24 | ID: &id, 25 | }, 26 | Result: protocol.InitializeResult{ 27 | Capabilities: protocol.ServerCapabilities{ 28 | TextDocumentSync: 1, 29 | HoverProvider: true, 30 | DefinitionProvider: true, 31 | CompletionProvider: &protocol.CompletionOptions{}, 32 | DocumentFormattingProvider: true, 33 | }, 34 | ServerInfo: &protocol.ServerInfo{ 35 | Name: name, 36 | Version: version, 37 | }, 38 | }, 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /internal/tg/text/text.go: -------------------------------------------------------------------------------- 1 | // Package text provides generic utilities for working with text. 2 | package text 3 | 4 | import ( 5 | "bufio" 6 | "strings" 7 | 8 | "go.lsp.dev/protocol" 9 | ) 10 | 11 | func GetCursorWord(document string, position protocol.Position) string { 12 | scanner := bufio.NewScanner(strings.NewReader(document)) 13 | for i := 0; i <= int(position.Line); i++ { 14 | scanner.Scan() 15 | } 16 | 17 | line := scanner.Text() 18 | 19 | // Find the start of the word 20 | start := position.Character 21 | for start > 0 && int(start) <= len(line) && isWordChar(line[start-1]) { 22 | start-- 23 | } 24 | 25 | // Find the end of the word 26 | end := position.Character 27 | for int(end) < len(line) && isWordChar(line[end]) { 28 | end++ 29 | } 30 | 31 | return line[start:end] 32 | } 33 | 34 | func isWordChar(c byte) bool { 35 | return c == '_' || c == '.' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' 36 | } 37 | 38 | func WrapAsHCLCodeFence(s string) string { 39 | return "```hcl\n" + s + "\n```" 40 | } 41 | -------------------------------------------------------------------------------- /internal/ast/position.go: -------------------------------------------------------------------------------- 1 | package ast 2 | 3 | import ( 4 | "github.com/hashicorp/hcl/v2" 5 | "go.lsp.dev/protocol" 6 | ) 7 | 8 | // FromHCLRange converts a hcl.Range to a LSP protocol.Range. 9 | func FromHCLRange(s hcl.Range) protocol.Range { 10 | return protocol.Range{ 11 | Start: FromHCLPos(s.Start), 12 | End: FromHCLPos(s.End), 13 | } 14 | } 15 | 16 | // FromHCLPos converts a hcl.Pos to a LSP protocol.Position. 17 | func FromHCLPos(s hcl.Pos) protocol.Position { 18 | return protocol.Position{ 19 | Line: uint32(max(s.Line-1, 0)), 20 | Character: uint32(max(s.Column-1, 0)), 21 | } 22 | } 23 | 24 | // ToHCLRange converts a LSP protocol.Range to a hcl.Range. 25 | func ToHCLRange(s protocol.Range) hcl.Range { 26 | return hcl.Range{ 27 | Filename: "", 28 | Start: ToHCLPos(s.Start), 29 | End: ToHCLPos(s.End), 30 | } 31 | } 32 | 33 | // ToHCLPos converts a LSP protocol.Position to a hcl.Pos. 34 | func ToHCLPos(s protocol.Position) hcl.Pos { 35 | return hcl.Pos{ 36 | Line: int(s.Line + 1), 37 | Column: int(s.Character + 1), 38 | Byte: 0, 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /vscode-extension/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * ESLint configuration for the project. 3 | * 4 | * See https://eslint.style and https://typescript-eslint.io for additional linting options. 5 | */ 6 | // @ts-check 7 | import js from '@eslint/js'; 8 | import tseslint from 'typescript-eslint'; 9 | import stylistic from '@stylistic/eslint-plugin'; 10 | 11 | export default tseslint.config( 12 | { 13 | ignores: [ 14 | '**/.vscode-test', 15 | '**/out', 16 | ] 17 | }, 18 | js.configs.recommended, 19 | ...tseslint.configs.recommended, 20 | ...tseslint.configs.stylistic, 21 | { 22 | plugins: { 23 | '@stylistic': stylistic 24 | }, 25 | rules: { 26 | 'curly': 'warn', 27 | '@stylistic/semi': ['warn', 'always'], 28 | '@typescript-eslint/no-empty-function': 'off', 29 | '@typescript-eslint/naming-convention': [ 30 | 'warn', 31 | { 32 | 'selector': 'import', 33 | 'format': ['camelCase', 'PascalCase'] 34 | } 35 | ], 36 | '@typescript-eslint/no-unused-vars': [ 37 | 'error', 38 | { 39 | 'argsIgnorePattern': '^_' 40 | } 41 | ] 42 | } 43 | } 44 | ); -------------------------------------------------------------------------------- /vscode-extension/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | { 3 | "version": "0.2.0", 4 | "configurations": [ 5 | { 6 | "type": "extensionHost", 7 | "request": "launch", 8 | "name": "Launch Terragrunt Extension", 9 | "runtimeExecutable": "${execPath}", 10 | "args": [ 11 | "--extensionDevelopmentPath=${workspaceRoot}" 12 | ], 13 | "outFiles": [ 14 | "${workspaceRoot}/out/**/*.js" 15 | ], 16 | "autoAttachChildProcesses": true, 17 | "preLaunchTask": { 18 | "type": "npm", 19 | "script": "watch" 20 | } 21 | }, 22 | // { 23 | // "name": "Language Server E2E Test", 24 | // "type": "extensionHost", 25 | // "request": "launch", 26 | // "runtimeExecutable": "${execPath}", 27 | // "args": [ 28 | // "--extensionDevelopmentPath=${workspaceRoot}", 29 | // "--extensionTestsPath=${workspaceRoot}/client/out/test/index", 30 | // "${workspaceRoot}/client/testFixture" 31 | // ], 32 | // "outFiles": ["${workspaceRoot}/client/out/test/**/*.js"] 33 | // } 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | types: [opened, synchronize, reopened] 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Set up mise 18 | uses: jdx/mise-action@v2 19 | with: 20 | version: 2025.6.8 21 | experimental: true 22 | env: 23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | 25 | - id: go-cache-paths 26 | run: | 27 | echo "go-build=$(go env GOCACHE)" >> "$GITHUB_OUTPUT" 28 | echo "go-mod=$(go env GOMODCACHE)" >> "$GITHUB_OUTPUT" 29 | 30 | - name: Go Build Cache 31 | uses: actions/cache@v4 32 | with: 33 | path: ${{ steps.go-cache-paths.outputs.go-build }} 34 | key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }} 35 | 36 | - name: Go Mod Cache 37 | uses: actions/cache@v4 38 | with: 39 | path: ${{ steps.go-cache-paths.outputs.go-mod }} 40 | key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }} 41 | 42 | - name: Build 43 | run: go build 44 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | types: [opened, synchronize, reopened] 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Set up mise 18 | uses: jdx/mise-action@v2 19 | with: 20 | version: 2025.6.8 21 | experimental: true 22 | env: 23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | 25 | - id: go-cache-paths 26 | run: | 27 | echo "go-build=$(go env GOCACHE)" >> "$GITHUB_OUTPUT" 28 | echo "go-mod=$(go env GOMODCACHE)" >> "$GITHUB_OUTPUT" 29 | 30 | - name: Go Build Cache 31 | uses: actions/cache@v4 32 | with: 33 | path: ${{ steps.go-cache-paths.outputs.go-build }} 34 | key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }} 35 | 36 | - name: Go Mod Cache 37 | uses: actions/cache@v4 38 | with: 39 | path: ${{ steps.go-cache-paths.outputs.go-mod }} 40 | key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }} 41 | 42 | - name: Test 43 | run: go test ./... 44 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | // Package config provides configuration loading for terragrunt-ls. 2 | package config 3 | 4 | import ( 5 | "log/slog" 6 | "os" 7 | ) 8 | 9 | // Config holds the configuration for terragrunt-ls 10 | type Config struct { 11 | // LogFile is the path to the log file, empty string means stderr 12 | LogFile string 13 | // LogLevel is the log level to use 14 | LogLevel slog.Level 15 | } 16 | 17 | const ( 18 | // EnvLogFile is the environment variable that specifies the log file. 19 | EnvLogFile = "TG_LS_LOG" 20 | // EnvLogLevel is the environment variable that specifies the log level. 21 | EnvLogLevel = "TG_LS_LOG_LEVEL" 22 | ) 23 | 24 | // Load reads configuration from environment variables and returns a populated Config 25 | func Load() *Config { 26 | cfg := &Config{ 27 | LogFile: os.Getenv(EnvLogFile), 28 | LogLevel: slog.LevelInfo, // default level 29 | } 30 | 31 | // Parse log level from environment variable 32 | if envLevel := os.Getenv(EnvLogLevel); envLevel != "" { 33 | levelVar := slog.LevelVar{} 34 | if err := levelVar.UnmarshalText([]byte(envLevel)); err != nil { 35 | slog.Error("Failed to parse log level", "error", err) 36 | } else { 37 | cfg.LogLevel = levelVar.Level() 38 | } 39 | } 40 | 41 | return cfg 42 | } 43 | -------------------------------------------------------------------------------- /vscode-extension/docs/development.md: -------------------------------------------------------------------------------- 1 | # Developing the Visual Studio Code Extension 2 | 3 | ## Setup 4 | 5 | - Read the [setup docs](../../docs/setup.md) for instructions on how to setup your local development environment. 6 | 7 | ## Running the Extension in Development Mode 8 | 9 | - See the [setup docs](../../docs/setup.md) for instructions on how to setup your local development environment. 10 | - Run `npm install` in this folder. This installs all necessary npm modules in both the client and server folder 11 | - Open VS Code on this folder. 12 | - Press Ctrl+Shift+B to start compiling the client and server in [watch mode](https://code.visualstudio.com/docs/editor/tasks#:~:text=The%20first%20entry%20executes,the%20HelloWorld.js%20file.). 13 | - Switch to the Run and Debug View in the Sidebar (Ctrl+Shift+D). 14 | - Select `Launch Client` from the drop down (if it is not already). 15 | - Press ▷ to run the launch config (F5). 16 | - In the [Extension Development Host](https://code.visualstudio.com/api/get-started/your-first-extension#:~:text=Then%2C%20inside%20the%20editor%2C%20press%20F5.%20This%20will%20compile%20and%20run%20the%20extension%20in%20a%20new%20Extension%20Development%20Host%20window.) instance of VSCode, open a Terragrunt HCL file. 17 | - See the [capabilities documentation](../../docs/server-capabilities.md) for what the language server can do. 18 | -------------------------------------------------------------------------------- /internal/tg/hover/hover_test.go: -------------------------------------------------------------------------------- 1 | package hover_test 2 | 3 | import ( 4 | "terragrunt-ls/internal/testutils" 5 | "terragrunt-ls/internal/tg/hover" 6 | "terragrunt-ls/internal/tg/store" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "go.lsp.dev/protocol" 11 | ) 12 | 13 | func TestGetHoverTargetWithContext(t *testing.T) { 14 | t.Parallel() 15 | 16 | tc := []struct { 17 | store store.Store 18 | name string 19 | expectedTarget string 20 | expectedContext string 21 | position protocol.Position 22 | }{ 23 | { 24 | name: "empty document", 25 | store: store.Store{}, 26 | position: protocol.Position{Line: 0, Character: 0}, 27 | expectedContext: "null", 28 | }, 29 | { 30 | name: "local variable", 31 | store: store.Store{Document: "local.var"}, 32 | position: protocol.Position{Line: 0, Character: 0}, 33 | expectedTarget: "var", 34 | expectedContext: "local", 35 | }, 36 | } 37 | 38 | for _, tt := range tc { 39 | t.Run(tt.name, func(t *testing.T) { 40 | t.Parallel() 41 | 42 | l := testutils.NewTestLogger(t) 43 | 44 | target, context := hover.GetHoverTargetWithContext(l, tt.store, tt.position) 45 | 46 | assert.Equal(t, tt.expectedTarget, target) 47 | assert.Equal(t, tt.expectedContext, context) 48 | }) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /.github/workflows/license-check.yml: -------------------------------------------------------------------------------- 1 | name: License Check 2 | 3 | on: 4 | push: 5 | 6 | jobs: 7 | license-check: 8 | name: License Check 9 | runs-on: ubuntu-latest 10 | env: 11 | MISE_PROFILE: cicd 12 | 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v4 16 | 17 | - name: Set up mise 18 | uses: jdx/mise-action@v2 19 | with: 20 | version: 2025.6.8 21 | experimental: true 22 | env: 23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | 25 | - name: Cache licensei 26 | uses: actions/cache@v4 27 | with: 28 | path: .licensei.cache 29 | key: ${{ runner.os }}-licensei-${{ hashFiles('**/go.sum', '.licensei.toml') }} 30 | restore-keys: | 31 | ${{ runner.os }}-licensei- 32 | 33 | - name: Run License Check 34 | id: run-license-check 35 | run: | 36 | set -o pipefail 37 | { 38 | go mod vendor 39 | licensei cache --debug 40 | licensei check --debug 41 | licensei header --debug 42 | } | tee license-check.log 43 | shell: bash 44 | env: 45 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 46 | 47 | - name: Upload License Check Report 48 | uses: actions/upload-artifact@v4 49 | with: 50 | name: license-check-report-ubuntu 51 | path: license-check.log 52 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | types: [opened, synchronize, reopened] 9 | 10 | jobs: 11 | lint: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Set up mise 18 | uses: jdx/mise-action@v2 19 | with: 20 | version: 2025.6.8 21 | experimental: true 22 | env: 23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | 25 | - id: go-cache-paths 26 | run: | 27 | echo "go-build=$(go env GOCACHE)" >> "$GITHUB_OUTPUT" 28 | echo "go-mod=$(go env GOMODCACHE)" >> "$GITHUB_OUTPUT" 29 | 30 | # TODO: Make this less brittle. 31 | echo "golanci-lint-cache=/home/runner/.cache/golangci-lint" >> "$GITHUB_OUTPUT" 32 | 33 | - name: Go Build Cache 34 | uses: actions/cache@v4 35 | with: 36 | path: ${{ steps.go-cache-paths.outputs.go-build }} 37 | key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }} 38 | 39 | - name: Go Mod Cache 40 | uses: actions/cache@v4 41 | with: 42 | path: ${{ steps.go-cache-paths.outputs.go-mod }} 43 | key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }} 44 | 45 | - name: golangci-lint Cache 46 | uses: actions/cache@v4 47 | with: 48 | path: ${{ steps.go-cache-paths.outputs.golanci-lint-cache }} 49 | key: ${{ runner.os }}-golangci-lint-${{ hashFiles('**/go.sum') }} 50 | 51 | - name: Lint 52 | run: golangci-lint run ./... 53 | -------------------------------------------------------------------------------- /zed-extension/languages/terragrunt/outline.scm: -------------------------------------------------------------------------------- 1 | ; HCL Outline Scheme 2 | ; Comments 3 | (comment) @annotation 4 | 5 | ; Block with and without string_lit 6 | ; Example: 7 | ; terraform { ... } 8 | ; module "vpc" { ... } 9 | ; resource "resource" "name" { ... } 10 | (config_file 11 | (body 12 | (block 13 | (identifier) @context 14 | (string_lit)? @name 15 | (string_lit)? @name 16 | ) @item 17 | ) 18 | ) 19 | 20 | ; Inside block with identifier 21 | (config_file 22 | (body 23 | (block 24 | (identifier) 25 | (body 26 | (attribute 27 | (identifier) @context 28 | ) @item 29 | ) 30 | ) 31 | ) 32 | ) 33 | 34 | ; Inside block with identifier and string_lit 35 | (config_file 36 | (body 37 | (block 38 | (identifier) 39 | (body 40 | (block 41 | (identifier) @context 42 | (string_lit)? @name 43 | ) @item 44 | ) 45 | ) 46 | ) 47 | ) 48 | 49 | ; Root Attribute block 50 | ; Example: 51 | ; inputs = { ... } 52 | (config_file 53 | (body 54 | (attribute 55 | (identifier) @context 56 | ) @item 57 | ) 58 | ) 59 | 60 | ; Inside Root Attribute block 61 | (config_file 62 | (body 63 | (attribute 64 | (identifier) 65 | (expression 66 | (collection_value 67 | (object 68 | (object_elem 69 | key: (expression (variable_expr (identifier) @context)) 70 | ) @item 71 | ) 72 | ) 73 | ) 74 | ) 75 | ) 76 | ) 77 | -------------------------------------------------------------------------------- /internal/tg/hover/hover.go: -------------------------------------------------------------------------------- 1 | // Package hover provides the logic for determining the target of a hover. 2 | package hover 3 | 4 | import ( 5 | "strings" 6 | "terragrunt-ls/internal/logger" 7 | "terragrunt-ls/internal/tg/store" 8 | "terragrunt-ls/internal/tg/text" 9 | 10 | "go.lsp.dev/protocol" 11 | ) 12 | 13 | const ( 14 | // HoverContextLocal is the context for a local hover. 15 | // This means that a hover is happening on top of a local variable. 16 | HoverContextLocal = "local" 17 | 18 | // HoverContextNull is the context for a null hover. 19 | // This means that a hover is happening on top of nothing useful. 20 | HoverContextNull = "null" 21 | ) 22 | 23 | func GetHoverTargetWithContext(l logger.Logger, store store.Store, position protocol.Position) (string, string) { 24 | word := text.GetCursorWord(store.Document, position) 25 | if len(word) == 0 { 26 | l.Debug( 27 | "No word found", 28 | "line", position.Line, 29 | "character", position.Character, 30 | ) 31 | 32 | return word, HoverContextNull 33 | } 34 | 35 | splitExpression := strings.Split(word, ".") 36 | 37 | const localPartsLen = 2 38 | 39 | if len(splitExpression) != localPartsLen { 40 | l.Debug( 41 | "Invalid word found", 42 | "line", position.Line, 43 | "character", position.Character, 44 | "word", word, 45 | ) 46 | 47 | return word, HoverContextNull 48 | } 49 | 50 | if splitExpression[0] == "local" { 51 | l.Debug( 52 | "Found local variable", 53 | "line", position.Line, 54 | "character", position.Character, 55 | "local", splitExpression[1], 56 | ) 57 | 58 | return splitExpression[1], HoverContextLocal 59 | } 60 | 61 | return word, HoverContextNull 62 | } 63 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Terragrunt Language Server 2 | 3 | This is a simple language server for [Terragrunt](https://terragrunt.gruntwork.io/). 4 | 5 | It's a work in progress, and we're looking to start coordination with the Terragrunt community to make this the best possible language server for Terragrunt. 6 | 7 | ## Capabilities 8 | 9 | The capabilities of this language server are documented in the [server capabilities documentation](./docs/server-capabilities.md). 10 | 11 | ## Setup 12 | 13 | For instructions on how to setup the Terragrunt Language Server in your editor, see the [setup documentation](./docs/setup.md). 14 | 15 | ## Contributions 16 | 17 | Contributions are welcome, though the maintainers request your patience and understanding, as this is not a project we can dedicate a lot of time to. 18 | 19 | If you would like to contribute, please read the [contributing documentation](./docs/contributing.md) file. 20 | 21 | ## Special Thanks 22 | 23 | A special thanks also goes to [jowharshamshiri](https://github.com/jowharshamshiri) for getting the ball rolling on [tg-hcl-lsp](https://github.com/jowharshamshiri/tg-hcl-lsp) and [mightyguava](https://github.com/mightyguava) for [terragrunt-langserver](https://github.com/mightyguava/terragrunt-langserver), the first community supported Terragrunt Language Servers. Seeing the community commit to creating Language Servers on their own convinced the maintainers of Terragrunt to create and maintain this official version. 24 | 25 | This one was created by learning from [tjdevries](https://github.com/tjdevries)'s [educational-lsp](https://github.com/tjdevries/educationalsp), and that educational example served as a great Golang-based starting point for this project. 26 | -------------------------------------------------------------------------------- /internal/tg/text/text_test.go: -------------------------------------------------------------------------------- 1 | package text_test 2 | 3 | import ( 4 | "terragrunt-ls/internal/tg/text" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "go.lsp.dev/protocol" 9 | ) 10 | 11 | func TestGetCursorWord(t *testing.T) { 12 | t.Parallel() 13 | 14 | tc := []struct { 15 | name string 16 | document string 17 | expected string 18 | position protocol.Position 19 | }{ 20 | { 21 | name: "simple word", 22 | document: "hello", 23 | position: protocol.Position{Line: 0, Character: 0}, 24 | expected: "hello", 25 | }, 26 | { 27 | name: "local variable", 28 | document: "local.var", 29 | position: protocol.Position{Line: 0, Character: 0}, 30 | expected: "local.var", 31 | }, 32 | { 33 | name: "two words", 34 | document: "hello world", 35 | position: protocol.Position{Line: 0, Character: 6}, 36 | expected: "world", 37 | }, 38 | { 39 | name: "two words with cursor at the start", 40 | document: "hello world", 41 | position: protocol.Position{Line: 0, Character: 0}, 42 | expected: "hello", 43 | }, 44 | { 45 | name: "two words with cursor in the middle", 46 | document: "hello world", 47 | position: protocol.Position{Line: 0, Character: 5}, 48 | expected: "hello", 49 | }, 50 | { 51 | name: "two words with cursor at the end", 52 | document: "hello world", 53 | position: protocol.Position{Line: 0, Character: 11}, 54 | expected: "world", 55 | }, 56 | } 57 | 58 | for _, tt := range tc { 59 | t.Run(tt.name, func(t *testing.T) { 60 | t.Parallel() 61 | 62 | actual := text.GetCursorWord(tt.document, tt.position) 63 | assert.Equal(t, tt.expected, actual) 64 | }) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /internal/tg/definition/definition_test.go: -------------------------------------------------------------------------------- 1 | package definition_test 2 | 3 | import ( 4 | "terragrunt-ls/internal/testutils" 5 | "terragrunt-ls/internal/tg" 6 | "terragrunt-ls/internal/tg/definition" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "go.lsp.dev/protocol" 11 | ) 12 | 13 | func TestGetDefinitionTargetWithContext(t *testing.T) { 14 | t.Parallel() 15 | 16 | tc := []struct { 17 | name string 18 | document string 19 | expectedTarget string 20 | expectedContext string 21 | position protocol.Position 22 | }{ 23 | { 24 | name: "empty store", 25 | document: "", 26 | position: protocol.Position{Line: 0, Character: 0}, 27 | expectedTarget: "", 28 | expectedContext: "null", 29 | }, 30 | { 31 | name: "include definition", 32 | document: `include "root" { 33 | path = find_in_parent_folders("root") 34 | }`, 35 | position: protocol.Position{Line: 1, Character: 8}, 36 | expectedTarget: "root", 37 | expectedContext: "include", 38 | }, 39 | { 40 | name: "dependency definition", 41 | document: `dependency "vpc" { 42 | config_path = "../vpc" 43 | }`, 44 | position: protocol.Position{Line: 1, Character: 18}, 45 | expectedTarget: "vpc", 46 | expectedContext: "dependency", 47 | }, 48 | } 49 | 50 | for _, tt := range tc { 51 | t.Run(tt.name, func(t *testing.T) { 52 | t.Parallel() 53 | 54 | l := testutils.NewTestLogger(t) 55 | 56 | s := tg.NewState() 57 | 58 | s.OpenDocument(l, "file:///test.hcl", tt.document) 59 | 60 | target, context := definition.GetDefinitionTargetWithContext(l, s.Configs["/test.hcl"], tt.position) 61 | 62 | assert.Equal(t, tt.expectedTarget, target) 63 | assert.Equal(t, tt.expectedContext, context) 64 | }) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /internal/tg/definition/definition.go: -------------------------------------------------------------------------------- 1 | // Package definition provides the logic for finding 2 | // definitions in Terragrunt configurations. 3 | package definition 4 | 5 | import ( 6 | "terragrunt-ls/internal/ast" 7 | "terragrunt-ls/internal/logger" 8 | "terragrunt-ls/internal/tg/store" 9 | 10 | "go.lsp.dev/protocol" 11 | ) 12 | 13 | const ( 14 | // DefinitionContextInclude is the context for an include definition. 15 | // This means that the user is trying to find the definition of an include. 16 | DefinitionContextInclude = "include" 17 | 18 | // DefinitionContextDependency is the context for a dependency definition. 19 | // This means that the user is trying to find the definition of a dependency. 20 | DefinitionContextDependency = "dependency" 21 | 22 | // DefinitionContextNull is the context for a null definition. 23 | // This means that the user is trying to go to the definition of nothing useful. 24 | DefinitionContextNull = "null" 25 | ) 26 | 27 | func GetDefinitionTargetWithContext(l logger.Logger, store store.Store, position protocol.Position) (string, string) { 28 | if store.AST == nil { 29 | l.Debug("No AST found") 30 | return "", DefinitionContextNull 31 | } 32 | 33 | node := store.AST.FindNodeAt(ast.ToHCLPos(position)) 34 | if node == nil { 35 | l.Debug("No node found at", "line", position.Line, "character", position.Character) 36 | return "", DefinitionContextNull 37 | } 38 | 39 | if include, ok := ast.GetNodeIncludeLabel(node); ok { 40 | l.Debug("Found include", "label", include) 41 | return include, DefinitionContextInclude 42 | } 43 | 44 | if dep, ok := ast.GetNodeDependencyLabel(node); ok { 45 | l.Debug("Found dependency", "label", dep) 46 | return dep, DefinitionContextDependency 47 | } 48 | 49 | l.Debug("No definition found at", "line", position.Line, "character", position.Character) 50 | 51 | return "", DefinitionContextNull 52 | } 53 | -------------------------------------------------------------------------------- /internal/rpc/rpc.go: -------------------------------------------------------------------------------- 1 | // Package rpc provides the logic for encoding and decoding messages 2 | // sent between the Terragrunt Language Server and the LSP client. 3 | package rpc 4 | 5 | import ( 6 | "bytes" 7 | "encoding/json" 8 | "errors" 9 | "fmt" 10 | "strconv" 11 | ) 12 | 13 | func EncodeMessage(msg any) string { 14 | content, err := json.Marshal(msg) 15 | if err != nil { 16 | panic(err) 17 | } 18 | 19 | return fmt.Sprintf("Content-Length: %d\r\n\r\n%s", len(content), content) 20 | } 21 | 22 | type BaseMessage struct { 23 | Method string `json:"method"` 24 | } 25 | 26 | func DecodeMessage(msg []byte) (string, []byte, error) { 27 | header, content, found := bytes.Cut(msg, []byte{'\r', '\n', '\r', '\n'}) 28 | if !found { 29 | return "", nil, errors.New("did not find separator") 30 | } 31 | 32 | // Content-Length: 33 | contentLengthBytes := header[len("Content-Length: "):] 34 | 35 | contentLength, err := strconv.Atoi(string(contentLengthBytes)) 36 | if err != nil { 37 | return "", nil, err 38 | } 39 | 40 | var baseMessage BaseMessage 41 | if err := json.Unmarshal(content[:contentLength], &baseMessage); err != nil { 42 | return "", nil, err 43 | } 44 | 45 | return baseMessage.Method, content[:contentLength], nil 46 | } 47 | 48 | // Split is a bufio.SplitFunc that splits on the Content-Length header. 49 | // 50 | // This is used to split the headers from the payload in LSP requests. 51 | func Split(data []byte, _ bool) (advance int, token []byte, err error) { 52 | header, content, found := bytes.Cut(data, []byte{'\r', '\n', '\r', '\n'}) 53 | if !found { 54 | return 0, nil, nil 55 | } 56 | 57 | // Content-Length: 58 | contentLengthBytes := header[len("Content-Length: "):] 59 | 60 | contentLength, err := strconv.Atoi(string(contentLengthBytes)) 61 | if err != nil { 62 | return 0, nil, err 63 | } 64 | 65 | if len(content) < contentLength { 66 | return 0, nil, nil 67 | } 68 | 69 | const lenOfTwoRNs = 4 70 | 71 | totalLength := len(header) + lenOfTwoRNs + contentLength 72 | 73 | return totalLength, data[:totalLength], nil 74 | } 75 | -------------------------------------------------------------------------------- /vscode-extension/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "terragrunt-ls", 3 | "displayName": "Terragrunt Language Server", 4 | "description": "Official Terragrunt Language Server extension by Gruntwork", 5 | "author": "Gruntwork", 6 | "license": "MPL-2.0", 7 | "icon": "images/icon.png", 8 | "version": "0.0.1", 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/gruntwork-io/terragrunt-ls.git" 12 | }, 13 | "publisher": "Gruntwork", 14 | "categories": [], 15 | "keywords": [ 16 | "terragrunt opentofu terraform" 17 | ], 18 | "engines": { 19 | "vscode": "^1.98.0" 20 | }, 21 | "activationEvents": [ 22 | "workspaceContains:**/terragrunt.hcl" 23 | ], 24 | "main": "./out/extension", 25 | "contributes": { 26 | "configuration": { 27 | "type": "object", 28 | "title": "Example configuration", 29 | "properties": { 30 | "terragrunt-ls.maxNumberOfProblems": { 31 | "scope": "resource", 32 | "type": "number", 33 | "default": 100, 34 | "description": "Controls the maximum number of problems produced by the server." 35 | }, 36 | "terragrunt-ls.trace.server": { 37 | "scope": "window", 38 | "type": "string", 39 | "enum": [ 40 | "off", 41 | "messages", 42 | "verbose" 43 | ], 44 | "default": "off", 45 | "description": "Traces the communication between VS Code and the language server." 46 | } 47 | } 48 | } 49 | }, 50 | "scripts": { 51 | "vscode:prepublish": "npm run compile && ./scripts/package.sh", 52 | "compile": "tsc -b", 53 | "watch": "tsc -b -w", 54 | "lint": "eslint" 55 | }, 56 | "dependencies": { 57 | "glob": "^11.0.0", 58 | "vscode-languageclient": "^9.0.1" 59 | }, 60 | "devDependencies": { 61 | "@eslint/js": "^9.13.0", 62 | "@stylistic/eslint-plugin": "^2.9.0", 63 | "@types/mocha": "^10.0.6", 64 | "@types/node": "^20", 65 | "@types/vscode": "^1.75.1", 66 | "@vscode/test-electron": "^2.3.9", 67 | "eslint": "^9.13.0", 68 | "mocha": "^10.3.0", 69 | "nodemon": "^3.1.9", 70 | "typescript": "^5.8.2", 71 | "typescript-eslint": "^8.26.0" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /docs/server-capabilities.md: -------------------------------------------------------------------------------- 1 | # Server Capabilities 2 | 3 | These are the server capabilities of the Terragrunt Language Server, as defined in the [LSP spec](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#serverCapabilities). 4 | 5 | ## TextDocumentSync 6 | 7 | The server supports full text document sync. 8 | 9 | Every time a document is opened or changed, the server will receive an event with the full document. 10 | 11 | When loading a document, the server will use Terragrunt's configuration parsing to parse the HCL file, and then provide the same diagnostics that Terragrunt would provide. 12 | 13 | ## HoverProvider 14 | 15 | The server provides hover information. 16 | 17 | When a Language Server client hovers over a token, the server will provide information about that token. 18 | 19 | At the moment, the only hover target that is supported is local variables. When hovering over a local variable, the server will provide the evaluated value of that local. 20 | 21 | ## DefinitionProvider 22 | 23 | The server provides the ability to go to definitions. 24 | 25 | When a Language Server client requests to go to a definition, the server will provide the location of the definition. 26 | 27 | At the moment, the only definition target that is supported is includes. When requesting to go to the definition of an include, the server will provide the location of the included file. 28 | 29 | ## CompletionProvider 30 | 31 | The server provides completion suggestions. 32 | 33 | When a Language Server client requests completions for a token, the server will provide a list of suggestions. 34 | 35 | At the moment, the only completions that are supported are the names of attributes and blocks. When requesting completions for an attribute or block name, the server will provide a list of suggestions based on the current context. 36 | 37 | ## FormatProvider 38 | 39 | The server provides the ability to format Terragrunt configuration files. 40 | 41 | When a Language Server client requests formatting, the server will format the document and return the formatted document to the client. 42 | -------------------------------------------------------------------------------- /vscode-extension/README.md: -------------------------------------------------------------------------------- 1 | # Language Server for Terragrunt 2 | 3 | This is the official Language Server for [Terragrunt](https://terragrunt.gruntwork.io/). 4 | 5 | ## Functionality 6 | 7 | See the [Language Server README](https://github.com/gruntwork-io/terragrunt-ls) for a full list of features. 8 | 9 | Some highlights: 10 | 11 | ### TextDocumentSync 12 | 13 | The server supports full text document sync. 14 | 15 | Every time a document is opened or changed, the server will receive an event with the full document. 16 | 17 | When loading a document, the server will use Terragrunt's configuration parsing to parse the HCL file, and then provide the same diagnostics that Terragrunt would provide. 18 | 19 | ### HoverProvider 20 | 21 | The server provides hover information. 22 | 23 | When a Language Server client hovers over a token, the server will provide information about that token. 24 | 25 | At the moment, the only hover target that is supported is local variables. When hovering over a local variable, the server will provide the evaluated value of that local. 26 | 27 | ### DefinitionProvider 28 | 29 | The server provides the ability to go to definitions. 30 | 31 | When a Language Server client requests to go to a definition, the server will provide the location of the definition. 32 | 33 | At the moment, the only definition target that is supported is includes. When requesting to go to the definition of an include, the server will provide the location of the included file. 34 | 35 | ### CompletionProvider 36 | 37 | The server provides completion suggestions. 38 | 39 | When a Language Server client requests completions for a token, the server will provide a list of suggestions. 40 | 41 | At the moment, the only completions that are supported are the names of attributes and blocks. When requesting completions for an attribute or block name, the server will provide a list of suggestions based on the current context. 42 | 43 | ### FormatProvider 44 | 45 | The server provides the ability to format Terragrunt configuration files. 46 | 47 | When a Language Server client requests formatting, the server will format the document and return the formatted document to the client. 48 | 49 | 50 | 51 | ## Development 52 | 53 | If you are reading this in the Git repository, you can find instructions on how to set up your local development environment and run the extension in development mode in the [Development README](./docs/development.md). 54 | -------------------------------------------------------------------------------- /internal/logger/logger.go: -------------------------------------------------------------------------------- 1 | // Package logger provides a simple logger for terragrunt-ls. 2 | package logger 3 | 4 | import ( 5 | "io" 6 | "log/slog" 7 | "os" 8 | ) 9 | 10 | var _ Logger = &slogLogger{} 11 | 12 | // slogLogger is a wrapper around slog.Logger that provides additional methods 13 | type slogLogger struct { 14 | *slog.Logger 15 | writer io.WriteCloser 16 | level slog.Level 17 | } 18 | 19 | type Logger interface { 20 | Close() error 21 | Writer() io.WriteCloser 22 | Level() slog.Level 23 | Debug(msg string, args ...any) 24 | Info(msg string, args ...any) 25 | Warn(msg string, args ...any) 26 | Error(msg string, args ...any) 27 | } 28 | 29 | // NewLogger builds the standard logger for terragrunt-ls. 30 | // 31 | // When supplied with a filename, it'll create a new file and write logs to it. 32 | // Otherwise, it'll write logs to stderr. 33 | func NewLogger(filename string, level slog.Level) *slogLogger { 34 | if filename == "" { 35 | handler := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ 36 | Level: level, 37 | }) 38 | logger := slog.New(handler) 39 | 40 | return &slogLogger{ 41 | Logger: logger, 42 | level: level, 43 | } 44 | } 45 | 46 | const readWritePerm = 0666 47 | 48 | file, err := os.OpenFile(filename, os.O_CREATE|os.O_WRONLY|os.O_APPEND, readWritePerm) 49 | if err != nil { 50 | slog.Error("Failed to open log file", "error", err) 51 | os.Exit(1) 52 | } 53 | 54 | handler := slog.NewJSONHandler(file, &slog.HandlerOptions{ 55 | Level: level, 56 | }) 57 | logger := slog.New(handler) 58 | 59 | return &slogLogger{ 60 | Logger: logger, 61 | writer: file, 62 | level: level, 63 | } 64 | } 65 | 66 | // Close closes the logger 67 | func (l *slogLogger) Close() error { 68 | if l.writer != nil { 69 | return l.writer.Close() 70 | } 71 | 72 | return nil 73 | } 74 | 75 | // Writer returns the writer for the logger 76 | func (l *slogLogger) Writer() io.WriteCloser { 77 | return l.writer 78 | } 79 | 80 | // Level returns the level of the logger 81 | func (l *slogLogger) Level() slog.Level { 82 | return l.level 83 | } 84 | 85 | // Debug logs a debug message 86 | func (l *slogLogger) Debug(msg string, args ...any) { 87 | l.Logger.Debug(msg, args...) 88 | } 89 | 90 | // Info logs an info message 91 | func (l *slogLogger) Info(msg string, args ...any) { 92 | l.Logger.Info(msg, args...) 93 | } 94 | 95 | // Warn logs a warning message 96 | func (l *slogLogger) Warn(msg string, args ...any) { 97 | l.Logger.Warn(msg, args...) 98 | } 99 | 100 | // Error logs an error message 101 | func (l *slogLogger) Error(msg string, args ...any) { 102 | l.Logger.Error(msg, args...) 103 | } 104 | -------------------------------------------------------------------------------- /vscode-extension/src/extension.ts: -------------------------------------------------------------------------------- 1 | /* -------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | * ------------------------------------------------------------------------------------------ */ 5 | 6 | import { workspace, ExtensionContext } from 'vscode'; 7 | import * as vscode from "vscode"; 8 | import * as path from 'path'; 9 | 10 | import { 11 | LanguageClient, 12 | LanguageClientOptions, 13 | ServerOptions, 14 | TransportKind 15 | } from 'vscode-languageclient/node'; 16 | 17 | let client: LanguageClient; 18 | 19 | export function activate(context: ExtensionContext) { 20 | const isDevMode = context.extensionMode === vscode.ExtensionMode.Development; 21 | 22 | const serverOptions: ServerOptions = { 23 | run: { 24 | command: isDevMode 25 | ? context.asAbsolutePath(path.join("node_modules", ".bin", "nodemon")) 26 | : context.asAbsolutePath(path.join('out', 'terragrunt-ls')), 27 | args: isDevMode ? [ 28 | "-q", 29 | "--watch", "./**/*.go", 30 | "--signal", "SIGTERM", 31 | "--exec", "go", "run", "./main.go" 32 | ] : [], 33 | transport: TransportKind.stdio, 34 | options: isDevMode ? { 35 | cwd: context.asAbsolutePath(".."), 36 | env: { ...process.env, TG_LS_LOG: "debug.log" } 37 | } : undefined 38 | }, 39 | debug: { 40 | command: context.asAbsolutePath(path.join("node_modules", ".bin", "nodemon")), 41 | args: [ 42 | "-q", 43 | "--watch", "./**/*.go", 44 | "--signal", "SIGTERM", 45 | "--exec", "go", "run", "./main.go" 46 | ], 47 | transport: TransportKind.stdio, 48 | options: { 49 | cwd: context.asAbsolutePath(".."), 50 | env: { ...process.env, TG_LS_LOG: "debug.log" } 51 | } 52 | } 53 | }; 54 | 55 | // Options to control the language client 56 | const clientOptions: LanguageClientOptions = { 57 | // Register the server for Terragrunt files 58 | documentSelector: [{ scheme: 'file', language: 'hcl' }], 59 | synchronize: { 60 | // Notify the server about file changes to Terragrunt files 61 | fileEvents: workspace.createFileSystemWatcher('**/*.hcl') 62 | } 63 | }; 64 | 65 | // Create the language client and start the client. 66 | client = new LanguageClient( 67 | 'terragrunt-ls', 68 | 'Terragrunt Language Server', 69 | serverOptions, 70 | clientOptions 71 | ); 72 | 73 | // Start the client. This will also launch the server 74 | client.start(); 75 | } 76 | 77 | export function deactivate(): Thenable | undefined { 78 | if (!client) { 79 | return undefined; 80 | } 81 | return client.stop(); 82 | } 83 | -------------------------------------------------------------------------------- /internal/tg/parse_test.go: -------------------------------------------------------------------------------- 1 | package tg_test 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "terragrunt-ls/internal/testutils" 7 | "terragrunt-ls/internal/tg" 8 | "testing" 9 | 10 | "github.com/gruntwork-io/terragrunt/config" 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | func TestParseTerragruntBuffer(t *testing.T) { 16 | t.Parallel() 17 | 18 | tests := []struct { 19 | setup func(t *testing.T, tmpDir string) string 20 | wantCfg *config.TerragruntConfig 21 | name string 22 | content string 23 | wantDiag bool 24 | }{ 25 | { 26 | name: "basic terragrunt config", 27 | setup: func(t *testing.T, tmpDir string) string { 28 | t.Helper() 29 | 30 | path := filepath.Join(tmpDir, "terragrunt.hcl") 31 | return path 32 | }, 33 | content: ` 34 | terraform { 35 | source = "./modules/example" 36 | } 37 | `, 38 | wantDiag: false, 39 | }, 40 | { 41 | name: "dependency with missing outputs should not show diagnostic", 42 | setup: func(t *testing.T, tmpDir string) string { 43 | t.Helper() 44 | 45 | // Create base module directory and its terragrunt.hcl 46 | baseDir := filepath.Join(tmpDir, "base") 47 | require.NoError(t, os.MkdirAll(baseDir, 0755)) 48 | 49 | baseTg := filepath.Join(baseDir, "terragrunt.hcl") 50 | require.NoError(t, os.WriteFile(baseTg, []byte(` 51 | terraform { 52 | source = "./module" 53 | } 54 | `), 0644)) 55 | 56 | // Create unit directory and return path to its terragrunt.hcl 57 | unitDir := filepath.Join(tmpDir, "unit") 58 | require.NoError(t, os.MkdirAll(unitDir, 0755)) 59 | return filepath.Join(unitDir, "terragrunt.hcl") 60 | }, 61 | content: ` 62 | dependency "base" { 63 | config_path = "../base" 64 | 65 | mock_outputs = { 66 | vpc_id = "vpc-1234567890" 67 | } 68 | } 69 | 70 | terraform { 71 | source = "./modules/example" 72 | } 73 | 74 | inputs = { 75 | vpc_id = dependency.base.outputs.vpc_id 76 | } 77 | `, 78 | wantDiag: false, 79 | }, 80 | } 81 | 82 | for _, tt := range tests { 83 | t.Run(tt.name, func(t *testing.T) { 84 | t.Parallel() 85 | 86 | // Create temporary directory for test 87 | tmpDir := t.TempDir() 88 | 89 | // Run setup and get file to parse 90 | filename := tt.setup(t, tmpDir) 91 | 92 | l := testutils.NewTestLogger(t) 93 | cfg, diags := tg.ParseTerragruntBuffer(l, filename, tt.content) 94 | 95 | if tt.wantDiag { 96 | assert.NotEmpty(t, diags, "expected diagnostics but got none") 97 | } else { 98 | assert.Empty(t, diags, "expected no diagnostics but got: %v", diags) 99 | } 100 | 101 | assert.NotNil(t, cfg, "expected config to not be nil") 102 | }) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /internal/testutils/testutils.go: -------------------------------------------------------------------------------- 1 | // Package testutils provides utilities for testing. 2 | package testutils 3 | 4 | import ( 5 | "io" 6 | "log/slog" 7 | "os" 8 | "path/filepath" 9 | "terragrunt-ls/internal/logger" 10 | "testing" 11 | ) 12 | 13 | var _ logger.Logger = &testLogger{} 14 | 15 | // Logger is a wrapper around slog.Logger that provides additional methods 16 | type testLogger struct { 17 | *slog.Logger 18 | writer io.WriteCloser 19 | level slog.Level 20 | } 21 | 22 | func NewTestLogger(t *testing.T) *testLogger { 23 | t.Helper() 24 | 25 | // Create a test logger that writes to the test log 26 | testWriter := testWriter{t} 27 | handler := slog.NewJSONHandler(testWriter, &slog.HandlerOptions{ 28 | Level: slog.LevelDebug, 29 | }) 30 | slogger := slog.New(handler) 31 | 32 | // Create a new logger with the test writer 33 | return &testLogger{ 34 | Logger: slogger, 35 | writer: testWriter, 36 | level: slog.LevelDebug, 37 | } 38 | } 39 | 40 | // testWriter implements io.Writer and writes to the test log 41 | type testWriter struct { 42 | t *testing.T 43 | } 44 | 45 | func (tw testWriter) Write(p []byte) (n int, err error) { 46 | tw.t.Log(string(p)) 47 | return len(p), nil 48 | } 49 | 50 | func (tw testWriter) Close() error { 51 | return nil 52 | } 53 | 54 | func PointerOfInt(i int) *int { 55 | return &i 56 | } 57 | 58 | func CreateFile(dir, name, content string) (string, error) { 59 | const ownerRWGlobalR = 0644 60 | 61 | return CreateFileWithMode(dir, name, content, ownerRWGlobalR) 62 | } 63 | 64 | func CreateFileWithMode(dir, name, content string, mode os.FileMode) (string, error) { 65 | path := filepath.Join(dir, name) 66 | 67 | if err := os.WriteFile(path, []byte(content), mode); err != nil { 68 | return "", err 69 | } 70 | 71 | return path, nil 72 | } 73 | 74 | // Close closes the logger 75 | func (l *testLogger) Close() error { 76 | if l.writer != nil { 77 | return l.writer.Close() 78 | } 79 | 80 | return nil 81 | } 82 | 83 | // Writer returns the writer for the logger 84 | func (l *testLogger) Writer() io.WriteCloser { 85 | return l.writer 86 | } 87 | 88 | // Level returns the level of the logger 89 | func (l *testLogger) Level() slog.Level { 90 | return l.level 91 | } 92 | 93 | // Debug logs a debug message 94 | func (l *testLogger) Debug(msg string, args ...any) { 95 | l.Logger.Debug(msg, args...) 96 | } 97 | 98 | // Info logs an info message 99 | func (l *testLogger) Info(msg string, args ...any) { 100 | l.Logger.Info(msg, args...) 101 | } 102 | 103 | // Warn logs a warning message 104 | func (l *testLogger) Warn(msg string, args ...any) { 105 | l.Logger.Warn(msg, args...) 106 | } 107 | 108 | // Error logs an error message 109 | func (l *testLogger) Error(msg string, args ...any) { 110 | l.Logger.Error(msg, args...) 111 | } 112 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | run: 3 | go: "1.24" 4 | issues-exit-code: 1 5 | tests: true 6 | output: 7 | formats: 8 | text: 9 | path: stdout 10 | print-linter-name: true 11 | print-issued-lines: true 12 | linters: 13 | enable: 14 | - asasalint 15 | - asciicheck 16 | - bidichk 17 | - bodyclose 18 | - contextcheck 19 | - dupl 20 | - durationcheck 21 | - errchkjson 22 | - errorlint 23 | - exhaustive 24 | - fatcontext 25 | - gocheckcompilerdirectives 26 | - gochecksumtype 27 | - goconst 28 | - gocritic 29 | - gosmopolitan 30 | - loggercheck 31 | - makezero 32 | - misspell 33 | - mnd 34 | - musttag 35 | - nilerr 36 | - nilnesserr 37 | - noctx 38 | - paralleltest 39 | - perfsprint 40 | - prealloc 41 | - protogetter 42 | - reassign 43 | - rowserrcheck 44 | - spancheck 45 | - sqlclosecheck 46 | - staticcheck 47 | - testableexamples 48 | - testifylint 49 | - testpackage 50 | - thelper 51 | - tparallel 52 | - unconvert 53 | - unparam 54 | - usetesting 55 | - wastedassign 56 | - wsl 57 | - zerologlint 58 | disable: 59 | - depguard 60 | - exhaustruct 61 | - gocyclo 62 | - gosec 63 | - nolintlint 64 | - recvcheck 65 | - varnamelen 66 | - wrapcheck 67 | settings: 68 | dupl: 69 | threshold: 120 70 | errcheck: 71 | check-type-assertions: false 72 | check-blank: false 73 | exclude-functions: 74 | - (*os.File).Close 75 | errorlint: 76 | errorf: true 77 | asserts: true 78 | comparison: true 79 | goconst: 80 | min-len: 3 81 | min-occurrences: 5 82 | gocritic: 83 | disabled-checks: 84 | - regexpMust 85 | - rangeValCopy 86 | - appendAssign 87 | - hugeParam 88 | enabled-tags: 89 | - performance 90 | disabled-tags: 91 | - experimental 92 | govet: 93 | enable: 94 | - fieldalignment 95 | nakedret: 96 | max-func-lines: 20 97 | staticcheck: 98 | checks: 99 | - all 100 | - -SA9005 101 | - -QF1008 102 | - -ST1001 103 | unparam: 104 | check-exported: false 105 | exclusions: 106 | generated: lax 107 | rules: 108 | - linters: 109 | - dupl 110 | - errcheck 111 | - gocyclo 112 | - lll 113 | - mnd 114 | - unparam 115 | - wsl 116 | path: _test\.go 117 | paths: 118 | - docs 119 | - _ci 120 | - .github 121 | - .circleci 122 | - third_party$ 123 | - builtin$ 124 | - examples$ 125 | issues: 126 | max-issues-per-linter: 0 127 | max-same-issues: 0 128 | formatters: 129 | enable: 130 | - goimports 131 | settings: 132 | gofmt: 133 | simplify: true 134 | exclusions: 135 | generated: lax 136 | paths: 137 | - docs 138 | - _ci 139 | - .github 140 | - .circleci 141 | - third_party$ 142 | - builtin$ 143 | - examples$ 144 | -------------------------------------------------------------------------------- /docs/setup.md: -------------------------------------------------------------------------------- 1 | # Setup 2 | 3 | ## Setting up build dependencies 4 | 5 | To bootstrap your development environment, the most convenient method is to [install mise](https://mise.jdx.dev/installing-mise.html). 6 | 7 | After installing `mise`, you can run the following command to install all necessary build dependencies for this project: 8 | 9 | ```bash 10 | mise install 11 | ``` 12 | 13 | Alternatively, you can install the relevant dependencies manually by reading the [mise.toml](../mise.toml) file, and installing the dependencies listed there. 14 | 15 | ## Building the Language Server 16 | 17 | To setup the language server in your editor, first install `terragrunt-ls` by running the following at the root of this repository: 18 | 19 | ```bash 20 | go install 21 | ``` 22 | 23 | (In the future, this will be available as a precompiled binary for download) 24 | 25 | Then follow the instructions below for your editor: 26 | 27 | ## Visual Studio Code 28 | 29 | To install the Visual Studio Code extension, you can manually compile the extension locally, then install it from the `.vsix` file. 30 | 31 | 1. Navigate to the `vscode-extension` directory: 32 | 33 | ```bash 34 | cd vscode-extension 35 | ``` 36 | 37 | 2. Ensure you have vsce (Visual Studio Code Extension CLI) & the typescript compiler installed. If you don't have it, you can install it globally using npm: 38 | 39 | ```bash 40 | npm install -g @vscode/vsce 41 | npm install -g typescript 42 | ``` 43 | 44 | 3. Install local javascript packages 45 | 46 | ```bash 47 | npm install 48 | ``` 49 | 50 | 4. Run the following command to package the extension: 51 | 52 | ```bash 53 | vsce package 54 | ``` 55 | 56 | 5. This will create a `.vsix` file in the `vscode-extension` directory (e.g. `terragrunt-ls-0.0.1.vsix`). You can install this file directly as a Visual Studio Code extension, like so: 57 | 58 | ```bash 59 | code --install-extension terragrunt-ls-0.0.1.vsix 60 | ``` 61 | 62 | Installation from the Visual Studio Extensions Marketplace coming soon! 63 | 64 | ## Neovim 65 | 66 | For Neovim, you can install the neovim plugin by adding the following to your editor: 67 | 68 | ```lua 69 | -- ~/.config/nvim/lua/custom/plugins/terragrunt-ls.lua 70 | 71 | return { 72 | { 73 | "gruntwork-io/terragrunt-ls", 74 | -- To use a local version of the Neovim plugin, you can use something like following: 75 | -- dir = vim.fn.expand '~/repos/src/github.com/gruntwork-io/terragrunt-ls', 76 | ft = 'hcl', 77 | config = function() 78 | local terragrunt_ls = require 'terragrunt-ls' 79 | terragrunt_ls.setup { 80 | cmd_env = { 81 | -- If you want to see language server logs, 82 | -- set this to the path you want. 83 | -- TG_LS_LOG = vim.fn.expand '/tmp/terragrunt-ls.log', 84 | }, 85 | } 86 | if terragrunt_ls.client then 87 | vim.api.nvim_create_autocmd('FileType', { 88 | pattern = 'hcl', 89 | callback = function() 90 | vim.lsp.buf_attach_client(0, terragrunt_ls.client) 91 | end, 92 | }) 93 | end 94 | end, 95 | }, 96 | } 97 | ``` 98 | 99 | Installation from Mason coming soon! 100 | 101 | ## Zed 102 | 103 | For now, clone this repo and point to the `zed-extension` directory when [installing dev extension](https://zed.dev/docs/extensions/developing-extensions#developing-an-extension-locally) 104 | 105 | Installing from extension page coming soon! 106 | -------------------------------------------------------------------------------- /zed-extension/languages/terragrunt/highlights.scm: -------------------------------------------------------------------------------- 1 | ; https://github.com/nvim-treesitter/nvim-treesitter/blob/cb79d2446196d25607eb1d982c96939abdf67b8e/queries/hcl/highlights.scm 2 | ; highlights.scm 3 | [ 4 | "!" 5 | "\*" 6 | "/" 7 | "%" 8 | "\+" 9 | "-" 10 | ">" 11 | ">=" 12 | "<" 13 | "<=" 14 | "==" 15 | "!=" 16 | "&&" 17 | "||" 18 | ] @operator 19 | 20 | [ 21 | "{" 22 | "}" 23 | "[" 24 | "]" 25 | "(" 26 | ")" 27 | ] @punctuation.bracket 28 | 29 | [ 30 | "." 31 | ".*" 32 | "," 33 | "[*]" 34 | ] @punctuation.delimiter 35 | 36 | [ 37 | (ellipsis) 38 | "\?" 39 | "=>" 40 | ] @punctuation.special 41 | 42 | [ 43 | ":" 44 | "=" 45 | ] @punctuation 46 | 47 | [ 48 | "for" 49 | "endfor" 50 | "in" 51 | "if" 52 | "else" 53 | "endif" 54 | ] @keyword 55 | 56 | [ 57 | (quoted_template_start) ; " 58 | (quoted_template_end) ; " 59 | (template_literal) ; non-interpolation/directive content 60 | ] @string 61 | 62 | [ 63 | (heredoc_identifier) ; END 64 | (heredoc_start) ; << or <<- 65 | ] @punctuation.delimiter 66 | 67 | [ 68 | (template_interpolation_start) ; ${ 69 | (template_interpolation_end) ; } 70 | (template_directive_start) ; %{ 71 | (template_directive_end) ; } 72 | (strip_marker) ; ~ 73 | ] @punctuation.special 74 | 75 | (numeric_lit) @number 76 | 77 | (bool_lit) @boolean 78 | 79 | (null_lit) @constant 80 | 81 | (comment) @comment 82 | 83 | (identifier) @variable 84 | 85 | (body 86 | (block 87 | (identifier) @keyword)) 88 | 89 | (body 90 | (block 91 | (body 92 | (block 93 | (identifier) @type)))) 94 | 95 | (function_call 96 | (identifier) @function) 97 | 98 | (attribute 99 | (identifier) @variable) 100 | 101 | ; { key: val } 102 | ; 103 | ; highlight identifier keys as though they were block attributes 104 | (object_elem 105 | key: 106 | (expression 107 | (variable_expr 108 | (identifier) @variable))) 109 | 110 | ; var.foo, data.bar 111 | ; 112 | ; first element in get_attr is a variable.builtin or a reference to a variable.builtin 113 | (expression 114 | (variable_expr 115 | (identifier) @variable) 116 | (get_attr 117 | (identifier) @variable)) 118 | 119 | ; https://github.com/nvim-treesitter/nvim-treesitter/blob/cb79d2446196d25607eb1d982c96939abdf67b8e/queries/terraform/highlights.scm 120 | ; Terraform specific references 121 | ; 122 | ; 123 | ; local/module/data/var/output 124 | (expression 125 | (variable_expr 126 | (identifier) @variable 127 | (#any-of? @variable "data" "var" "local" "module" "output")) 128 | (get_attr 129 | (identifier) @variable)) 130 | 131 | ; path.root/cwd/module 132 | (expression 133 | (variable_expr 134 | (identifier) @type 135 | (#eq? @type "path")) 136 | (get_attr 137 | (identifier) @variable 138 | (#any-of? @variable "root" "cwd" "module"))) 139 | 140 | ; terraform.workspace 141 | (expression 142 | (variable_expr 143 | (identifier) @type 144 | (#eq? @type "terraform")) 145 | (get_attr 146 | (identifier) @variable 147 | (#any-of? @variable "workspace"))) 148 | 149 | ; Terraform specific keywords 150 | ; TODO: ideally only for identifiers under a `variable` block to minimize false positives 151 | ((identifier) @type 152 | (#any-of? @type "bool" "string" "number" "object" "tuple" "list" "map" "set" "any")) 153 | 154 | (object_elem 155 | val: 156 | (expression 157 | (variable_expr 158 | (identifier) @type 159 | (#any-of? @type "bool" "string" "number" "object" "tuple" "list" "map" "set" "any")))) 160 | 161 | ; Terragrunt specific 162 | ; more visible values, dependency & feature 163 | (expression 164 | (variable_expr 165 | (identifier) @type 166 | (#any-of? @type "values" "dependency" "feature")) 167 | (get_attr 168 | (identifier) @variable)) 169 | -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Although this is a side project for the Terragrunt maintainers, you are still advised to review [Terragrunt contributing guidelines](https://terragrunt.gruntwork.io/docs/community/contributing/) before contributing to this project. 4 | 5 | ## Dependencies 6 | 7 | To get started with development, you will want to install [mise](https://mise.jdx.dev/getting-started.html#_1-install-mise-cli), and then run the following command: 8 | 9 | ```bash 10 | mise install 11 | ``` 12 | 13 | This will install all the necessary dependencies for the project. 14 | 15 | ## Building 16 | 17 | To build the project, you can run the following command: 18 | 19 | ```bash 20 | go build 21 | ``` 22 | 23 | This will build the project and create an executable `terragrunt-ls` in the root of the project. 24 | 25 | ## Testing 26 | 27 | To run the tests for the project, you can run the following command: 28 | 29 | ```bash 30 | go test ./... 31 | ``` 32 | 33 | This will run all the tests for the project. 34 | 35 | ## Linting 36 | 37 | To lint the project, you can run the following command: 38 | 39 | ```bash 40 | golangci-lint run ./... 41 | ``` 42 | 43 | This will run the linter on the project. 44 | 45 | ## Logging 46 | 47 | The terragrunt-ls language server includes basic logging capabilities to help with development and debugging. The logging system is built on Go's structured logging (`slog`) package and provides multiple log levels and output formats. 48 | 49 | ### Configuration 50 | 51 | Logging is configured using the `TG_LS_LOG` environment variable. 52 | 53 | ### Log Levels and Output Formats 54 | 55 | #### File Logging 56 | 57 | When `TG_LS_LOG` is set to a file path: 58 | 59 | - **Format**: JSON structured logs 60 | - **Level**: Debug and above (Debug, Info, Warn, Error) 61 | - **Output**: Specified file 62 | 63 | ```bash 64 | # Enable detailed file logging 65 | export TG_LS_LOG=/tmp/terragrunt-ls.log 66 | ./terragrunt-ls 67 | ``` 68 | 69 | #### Console Logging 70 | 71 | When `TG_LS_LOG` is not set: 72 | 73 | - **Format**: Human-readable text 74 | - **Level**: Info and above (Info, Warn, Error) 75 | - **Output**: stderr 76 | 77 | ```bash 78 | # Basic console logging 79 | ./terragrunt-ls 80 | ``` 81 | 82 | #### Log Levels 83 | 84 | The log levels are the typical log levels you would expect. 85 | 86 | - **Debug**: Detailed information for debugging. 87 | - **Info**: General information about operations. 88 | - **Warn**: Warning conditions that don't prevent operation. 89 | - **Error**: Error conditions that may affect functionality. 90 | 91 | Set the log level using the `TG_LS_LOG_LEVEL` environment variable. 92 | 93 | ### Development Usage 94 | 95 | For development and debugging, it's recommended to use file logging: 96 | 97 | ```bash 98 | # Set up file logging 99 | export TG_LS_LOG=/tmp/terragrunt-ls-debug.log 100 | 101 | # Build and run the language server 102 | go build && ./terragrunt-ls 103 | 104 | # Monitor logs in real-time 105 | tail -f /tmp/terragrunt-ls-debug.log 106 | ``` 107 | 108 | ### Log Structure 109 | 110 | The logger uses structured logging with key-value pairs for better searchability and parsing: 111 | 112 | ```go 113 | // Example usage in code 114 | logger.Debug("Processing completion request", 115 | "uri", documentURI, 116 | "position", position) 117 | 118 | logger.Error("Failed to parse request", 119 | "error", err, 120 | "method", method) 121 | ``` 122 | 123 | ### Integration with the Terragrunt logger 124 | 125 | The terragrunt-ls language server integrates with Terragrunt's internal logging system when parsing Terragrunt configuration files. This ensures consistent logging behavior and allows Terragrunt's internal operations to be observed through the language server's logging infrastructure. 126 | 127 | When parsing Terragrunt buffers (in `internal/tg/parse.go`), a Terragrunt logger is created that: 128 | 129 | - **Shares the same output destination** as the terragrunt-ls logger. 130 | - **Matches the log level** by converting the terragrunt-ls logger level to Terragrunt's log level. 131 | - **Uses JSON formatting** for structured logging. 132 | 133 | This integration means that logging for terragrunt-ls will also include Terragrunt's internal parsing and processing logs, providing a complete view of what's happening during Terragrunt configuration analysis. 134 | 135 | ## Installing 136 | 137 | To install the project, you can run the following command: 138 | 139 | ```bash 140 | go install 141 | ``` 142 | 143 | This will install the `terragrunt-ls` binary to your `$GOBIN`, which defaults to `$GOPATH/bin` see [GOPATH](https://go.dev/wiki/GOPATH) for more info. 144 | -------------------------------------------------------------------------------- /internal/tg/parse.go: -------------------------------------------------------------------------------- 1 | package tg 2 | 3 | import ( 4 | "context" 5 | "path/filepath" 6 | "strings" 7 | 8 | "terragrunt-ls/internal/logger" 9 | 10 | "github.com/gruntwork-io/terragrunt/config" 11 | "github.com/gruntwork-io/terragrunt/config/hclparse" 12 | "github.com/gruntwork-io/terragrunt/options" 13 | tgLog "github.com/gruntwork-io/terragrunt/pkg/log" 14 | "github.com/gruntwork-io/terragrunt/pkg/log/format" 15 | "github.com/hashicorp/hcl/v2" 16 | "github.com/sirupsen/logrus" 17 | "go.lsp.dev/protocol" 18 | ) 19 | 20 | func ParseTerragruntBuffer(l logger.Logger, filename, text string) (*config.TerragruntConfig, []protocol.Diagnostic) { 21 | var parseDiags hcl.Diagnostics 22 | 23 | parseOptions := []hclparse.Option{ 24 | hclparse.WithDiagnosticsHandler(func(file *hcl.File, hclDiags hcl.Diagnostics) (hcl.Diagnostics, error) { 25 | parseDiags = append(parseDiags, hclDiags...) 26 | return hclDiags, nil 27 | }), 28 | } 29 | 30 | opts, err := options.NewTerragruntOptionsWithConfigPath(filename) 31 | if err != nil { 32 | return nil, []protocol.Diagnostic{ 33 | { 34 | Range: protocol.Range{ 35 | Start: protocol.Position{Line: 0, Character: 0}, 36 | End: protocol.Position{Line: 0, Character: 0}, 37 | }, 38 | Message: err.Error(), 39 | Severity: protocol.DiagnosticSeverityError, 40 | Source: "HCL", 41 | }, 42 | } 43 | } 44 | 45 | opts.SkipOutput = true 46 | opts.NonInteractive = true 47 | 48 | tgLogger := tgLog.New( 49 | tgLog.WithOutput(l.Writer()), 50 | tgLog.WithLevel(tgLog.FromLogrusLevel(logrus.Level(l.Level()))), 51 | tgLog.WithFormatter(format.NewFormatter(format.NewJSONFormatPlaceholders())), 52 | ) 53 | 54 | ctx := config.NewParsingContext(context.TODO(), tgLogger, opts) 55 | ctx.ParserOptions = parseOptions 56 | 57 | cfg, err := config.ParseConfigString(ctx, tgLogger, filename, text, nil) 58 | if err != nil { 59 | // Just log the error for now 60 | l.Error("Error parsing Terragrunt config", "error", err) 61 | } 62 | 63 | filteredDiags := filterHCLDiags(l, parseDiags, filename) 64 | 65 | diags := hclDiagsToLSPDiags(filteredDiags) 66 | 67 | return cfg, diags 68 | } 69 | 70 | func filterHCLDiags(l logger.Logger, diags hcl.Diagnostics, filename string) hcl.Diagnostics { 71 | filtered := hcl.Diagnostics{} 72 | 73 | for _, diag := range diags { 74 | l.Debug( 75 | "Checking to see diag can be filtered.", 76 | "diag", diag, 77 | "filename", filename, 78 | ) 79 | 80 | if isMissingOutputDiag(diag) { 81 | l.Debug( 82 | "Filtering output missing diag", 83 | "diag", diag, 84 | "filename", filename, 85 | ) 86 | 87 | continue 88 | } 89 | 90 | if isParentFileNotFoundDiag(diag) { 91 | l.Debug( 92 | "Filtering parent file not found diag", 93 | "diag", diag, 94 | "filename", filename, 95 | ) 96 | 97 | continue 98 | } 99 | 100 | if diag.Subject.Filename == filename { 101 | filtered = append(filtered, diag) 102 | } 103 | } 104 | 105 | return filtered 106 | } 107 | 108 | const ( 109 | // UnsupportedAttributeSummary is the summary for an unsupported attribute diagnostic. 110 | UnsupportedAttributeSummary = "Unsupported attribute" 111 | 112 | // OutputsMissingDetail is the detail for a missing outputs attribute diagnostic. 113 | OutputsMissingDetail = "This object does not have an attribute named \"outputs\"." 114 | ) 115 | 116 | func isMissingOutputDiag(diag *hcl.Diagnostic) bool { 117 | if diag.Summary != UnsupportedAttributeSummary { 118 | return false 119 | } 120 | 121 | if filepath.Base(diag.Subject.Filename) == "terragrunt.hcl" { 122 | return false 123 | } 124 | 125 | return diag.Detail == OutputsMissingDetail 126 | } 127 | 128 | const ( 129 | // ErrorInFunctionCallSummary is the summary for an error in a function call diagnostic. 130 | ErrorInFunctionCallSummary = "Error in function call" 131 | 132 | // ParentFileNotFoundErrorDetailPartial is the partial detail for a parent file not found diagnostic. 133 | ParentFileNotFoundErrorDetailPartial = `Call to function "find_in_parent_folders" failed: ParentFileNotFoundError` 134 | ) 135 | 136 | func isParentFileNotFoundDiag(diag *hcl.Diagnostic) bool { 137 | if diag.Summary != ErrorInFunctionCallSummary { 138 | return false 139 | } 140 | 141 | return strings.HasPrefix(diag.Detail, ParentFileNotFoundErrorDetailPartial) 142 | } 143 | 144 | func hclDiagsToLSPDiags(hclDiags hcl.Diagnostics) []protocol.Diagnostic { 145 | diags := []protocol.Diagnostic{} 146 | 147 | for _, diag := range hclDiags { 148 | diags = append(diags, protocol.Diagnostic{ 149 | Range: protocol.Range{ 150 | Start: protocol.Position{ 151 | Line: uint32(diag.Subject.Start.Line) - 1, 152 | Character: uint32(diag.Subject.Start.Column) - 1, 153 | }, 154 | End: protocol.Position{ 155 | Line: uint32(diag.Subject.End.Line) - 1, 156 | Character: uint32(diag.Subject.End.Column) - 1, 157 | }, 158 | }, 159 | Severity: protocol.DiagnosticSeverity(diag.Severity), 160 | Source: "HCL", 161 | Message: diag.Summary + ": " + diag.Detail, 162 | }) 163 | } 164 | 165 | return diags 166 | } 167 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "encoding/json" 6 | "io" 7 | "os" 8 | "terragrunt-ls/internal/config" 9 | "terragrunt-ls/internal/logger" 10 | "terragrunt-ls/internal/lsp" 11 | "terragrunt-ls/internal/rpc" 12 | "terragrunt-ls/internal/tg" 13 | 14 | "go.lsp.dev/protocol" 15 | ) 16 | 17 | func main() { 18 | cfg := config.Load() 19 | 20 | l := logger.NewLogger(cfg.LogFile, cfg.LogLevel) 21 | defer func() { 22 | if err := l.Close(); err != nil { 23 | panic(err) 24 | } 25 | }() 26 | 27 | l.Info("Initializing terragrunt-ls") 28 | 29 | scanner := bufio.NewScanner(os.Stdin) 30 | scanner.Split(rpc.Split) 31 | 32 | state := tg.NewState() 33 | writer := os.Stdout 34 | 35 | for scanner.Scan() { 36 | msg := scanner.Bytes() 37 | 38 | method, contents, err := rpc.DecodeMessage(msg) 39 | if err != nil { 40 | l.Error("Got an error decoding message from client", "err", err) 41 | 42 | continue 43 | } 44 | 45 | handleMessage(l, writer, state, method, contents) 46 | } 47 | } 48 | 49 | func handleMessage(l logger.Logger, writer io.Writer, state tg.State, method string, contents []byte) { 50 | l.Debug("Received msg", "method", method, "contents", string(contents)) 51 | 52 | switch method { 53 | case protocol.MethodInitialize: 54 | var request lsp.InitializeRequest 55 | if err := json.Unmarshal(contents, &request); err != nil { 56 | l.Error("Failed to parse initialize request", "err", err) 57 | } 58 | 59 | l.Debug("Connected", 60 | "Name", request.Params.ClientInfo.Name, 61 | "Version", request.Params.ClientInfo.Version) 62 | 63 | msg := lsp.NewInitializeResponse(request.ID) 64 | writeResponse(l, writer, msg) 65 | 66 | l.Debug("Initialized") 67 | 68 | case protocol.MethodTextDocumentDidOpen: 69 | var notification lsp.DidOpenTextDocumentNotification 70 | if err := json.Unmarshal(contents, ¬ification); err != nil { 71 | l.Error( 72 | "Failed to parse didOpen request", 73 | "error", 74 | err, 75 | ) 76 | } 77 | 78 | l.Debug( 79 | "Opened", 80 | "URI", notification.Params.TextDocument.URI, 81 | "LanguageID", notification.Params.TextDocument.LanguageID, 82 | "Version", notification.Params.TextDocument.Version, 83 | "Text", notification.Params.TextDocument.Text, 84 | ) 85 | 86 | diagnostics := state.OpenDocument(l, notification.Params.TextDocument.URI, notification.Params.TextDocument.Text) 87 | writeResponse(l, writer, lsp.PublishDiagnosticsNotification{ 88 | Notification: lsp.Notification{ 89 | RPC: lsp.RPCVersion, 90 | Method: protocol.MethodTextDocumentPublishDiagnostics, 91 | }, 92 | Params: protocol.PublishDiagnosticsParams{ 93 | URI: notification.Params.TextDocument.URI, 94 | Diagnostics: diagnostics, 95 | }, 96 | }) 97 | 98 | l.Debug( 99 | "Document opened", 100 | "URI", notification.Params.TextDocument.URI, 101 | ) 102 | 103 | case protocol.MethodTextDocumentDidChange: 104 | var notification lsp.DidChangeTextDocumentNotification 105 | if err := json.Unmarshal(contents, ¬ification); err != nil { 106 | l.Error( 107 | "Failed to parse didChange request", 108 | "error", 109 | err, 110 | ) 111 | } 112 | 113 | l.Debug( 114 | "Changed", 115 | "URI", notification.Params.TextDocument.URI, 116 | "Changes", notification.Params.ContentChanges, 117 | ) 118 | 119 | for _, change := range notification.Params.ContentChanges { 120 | l.Debug( 121 | "Change", 122 | "Range", change.Range, 123 | "Text", change.Text, 124 | ) 125 | 126 | diagnostics := state.UpdateDocument(l, notification.Params.TextDocument.URI, change.Text) 127 | writeResponse(l, writer, lsp.PublishDiagnosticsNotification{ 128 | Notification: lsp.Notification{ 129 | RPC: lsp.RPCVersion, 130 | Method: protocol.MethodTextDocumentPublishDiagnostics, 131 | }, 132 | Params: protocol.PublishDiagnosticsParams{ 133 | URI: notification.Params.TextDocument.URI, 134 | Diagnostics: diagnostics, 135 | }, 136 | }) 137 | } 138 | 139 | l.Debug( 140 | "Document changed", 141 | "URI", notification.Params.TextDocument.URI, 142 | ) 143 | 144 | case protocol.MethodTextDocumentHover: 145 | var request lsp.HoverRequest 146 | if err := json.Unmarshal(contents, &request); err != nil { 147 | l.Debug( 148 | "Failed to parse hover request", 149 | "error", 150 | err, 151 | ) 152 | } 153 | 154 | l.Debug( 155 | "Hover", 156 | "URI", request.Params.TextDocument.URI, 157 | "Position", request.Params.Position, 158 | ) 159 | 160 | response := state.Hover(l, request.ID, request.Params.TextDocument.URI, request.Params.Position) 161 | 162 | writeResponse(l, writer, response) 163 | 164 | case protocol.MethodTextDocumentDefinition: 165 | var request lsp.DefinitionRequest 166 | if err := json.Unmarshal(contents, &request); err != nil { 167 | l.Error( 168 | "Failed to parse definition request", 169 | "error", 170 | err, 171 | ) 172 | } 173 | 174 | l.Debug( 175 | "Definition", 176 | "URI", request.Params.TextDocument.URI, 177 | "Position", request.Params.Position, 178 | ) 179 | 180 | response := state.Definition(l, request.ID, request.Params.TextDocument.URI, request.Params.Position) 181 | 182 | writeResponse(l, writer, response) 183 | 184 | case protocol.MethodTextDocumentCompletion: 185 | var request lsp.CompletionRequest 186 | if err := json.Unmarshal(contents, &request); err != nil { 187 | l.Error( 188 | "Failed to parse completion request", 189 | "error", 190 | err, 191 | ) 192 | } 193 | 194 | l.Debug( 195 | "Completion", 196 | "URI", request.Params.TextDocument.URI, 197 | "Position", request.Params.Position, 198 | ) 199 | 200 | response := state.TextDocumentCompletion(l, request.ID, request.Params.TextDocument.URI, request.Params.Position) 201 | 202 | l.Debug( 203 | "Completion response", 204 | "Response", response, 205 | ) 206 | 207 | writeResponse(l, writer, response) 208 | 209 | case protocol.MethodTextDocumentFormatting: 210 | var request lsp.FormatRequest 211 | if err := json.Unmarshal(contents, &request); err != nil { 212 | l.Error( 213 | "Failed to parse format request", 214 | "error", 215 | err, 216 | ) 217 | } 218 | 219 | l.Debug( 220 | "Formatting", 221 | "URI", request.Params.TextDocument.URI, 222 | ) 223 | 224 | response := state.TextDocumentFormatting(l, request.ID, request.Params.TextDocument.URI) 225 | 226 | writeResponse(l, writer, response) 227 | } 228 | } 229 | 230 | func writeResponse(l logger.Logger, writer io.Writer, msg any) { 231 | reply := rpc.EncodeMessage(msg) 232 | 233 | _, err := writer.Write([]byte(reply)) 234 | if err != nil { 235 | l.Error( 236 | "Failed to write response", 237 | "error", 238 | err, 239 | ) 240 | } 241 | } 242 | -------------------------------------------------------------------------------- /internal/tg/completion/completion_test.go: -------------------------------------------------------------------------------- 1 | package completion_test 2 | 3 | import ( 4 | "terragrunt-ls/internal/testutils" 5 | "terragrunt-ls/internal/tg/completion" 6 | "terragrunt-ls/internal/tg/store" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "go.lsp.dev/protocol" 11 | ) 12 | 13 | func TestGetCompletions(t *testing.T) { 14 | t.Parallel() 15 | 16 | tc := []struct { 17 | store store.Store 18 | name string 19 | completions []protocol.CompletionItem 20 | position protocol.Position 21 | }{ 22 | { 23 | name: "complete dep", 24 | store: store.Store{ 25 | Document: `dep`, 26 | }, 27 | position: protocol.Position{Line: 0, Character: 3}, 28 | completions: []protocol.CompletionItem{ 29 | { 30 | Label: "dependency", 31 | Documentation: protocol.MarkupContent{ 32 | Kind: protocol.Markdown, 33 | Value: "# dependency\nThe dependency block is used to configure unit dependencies.\nEach dependency block exposes outputs of the dependency unit as variables you can reference in dependent unit configuration.", 34 | }, 35 | Kind: protocol.CompletionItemKindClass, 36 | InsertTextFormat: protocol.InsertTextFormatSnippet, 37 | TextEdit: &protocol.TextEdit{ 38 | Range: protocol.Range{ 39 | Start: protocol.Position{Line: 0, Character: 0}, 40 | End: protocol.Position{Line: 0, Character: 3}, 41 | }, 42 | NewText: `dependency "${1}" { 43 | config_path = "${2}" 44 | }`, 45 | }, 46 | }, 47 | { 48 | Label: "dependencies", 49 | Documentation: protocol.MarkupContent{ 50 | Kind: protocol.Markdown, 51 | Value: "# dependencies\nThe dependencies block is used to enumerate all the Terragrunt units that need to be applied before this unit.", 52 | }, 53 | Kind: protocol.CompletionItemKindClass, 54 | InsertTextFormat: protocol.InsertTextFormatSnippet, 55 | TextEdit: &protocol.TextEdit{ 56 | Range: protocol.Range{ 57 | Start: protocol.Position{Line: 0, Character: 0}, 58 | End: protocol.Position{Line: 0, Character: 3}, 59 | }, 60 | NewText: `dependencies { 61 | paths = ["${1}"] 62 | }`, 63 | }, 64 | }, 65 | }, 66 | }, 67 | { 68 | name: "complete dependency", 69 | store: store.Store{ 70 | Document: `dependency`, 71 | }, 72 | position: protocol.Position{Line: 0, Character: 3}, 73 | completions: []protocol.CompletionItem{ 74 | { 75 | Label: "dependency", 76 | Documentation: protocol.MarkupContent{ 77 | Kind: protocol.Markdown, 78 | Value: "# dependency\nThe dependency block is used to configure unit dependencies.\nEach dependency block exposes outputs of the dependency unit as variables you can reference in dependent unit configuration.", 79 | }, 80 | Kind: protocol.CompletionItemKindClass, 81 | InsertTextFormat: protocol.InsertTextFormatSnippet, 82 | TextEdit: &protocol.TextEdit{ 83 | Range: protocol.Range{ 84 | Start: protocol.Position{Line: 0, Character: 0}, 85 | End: protocol.Position{Line: 0, Character: 3}, 86 | }, 87 | NewText: `dependency "${1}" { 88 | config_path = "${2}" 89 | }`, 90 | }, 91 | }, 92 | }, 93 | }, 94 | { 95 | name: "complete include", 96 | store: store.Store{ 97 | Document: `in`, 98 | }, 99 | position: protocol.Position{Line: 0, Character: 1}, 100 | completions: []protocol.CompletionItem{ 101 | { 102 | Label: "include", 103 | Documentation: protocol.MarkupContent{ 104 | Kind: protocol.Markdown, 105 | Value: "# include\nThe include block is used to specify the inclusion of partial Terragrunt configuration.", 106 | }, 107 | Kind: protocol.CompletionItemKindClass, 108 | InsertTextFormat: protocol.InsertTextFormatSnippet, 109 | TextEdit: &protocol.TextEdit{ 110 | Range: protocol.Range{ 111 | Start: protocol.Position{Line: 0, Character: 0}, 112 | End: protocol.Position{Line: 0, Character: 1}, 113 | }, 114 | NewText: `include "${1:root}" { 115 | path = ${2:find_in_parent_folders("root.hcl")} 116 | }`, 117 | }, 118 | }, 119 | { 120 | Label: "inputs", 121 | Documentation: protocol.MarkupContent{ 122 | Kind: protocol.Markdown, 123 | Value: "# inputs\nThe inputs attribute is a map that is used to specify the input variables and their values to pass in to OpenTofu/Terraform.", 124 | }, 125 | Kind: protocol.CompletionItemKindField, 126 | InsertTextFormat: protocol.InsertTextFormatSnippet, 127 | TextEdit: &protocol.TextEdit{ 128 | Range: protocol.Range{ 129 | Start: protocol.Position{Line: 0, Character: 0}, 130 | End: protocol.Position{Line: 0, Character: 1}, 131 | }, 132 | NewText: `inputs = { 133 | ${1} = ${2} 134 | }`, 135 | }, 136 | }, 137 | }, 138 | }, 139 | { 140 | name: "complete include", 141 | store: store.Store{ 142 | Document: `include`, 143 | }, 144 | position: protocol.Position{Line: 0, Character: 3}, 145 | completions: []protocol.CompletionItem{ 146 | { 147 | Label: "include", 148 | Documentation: protocol.MarkupContent{ 149 | Kind: protocol.Markdown, 150 | Value: "# include\nThe include block is used to specify the inclusion of partial Terragrunt configuration.", 151 | }, 152 | Kind: protocol.CompletionItemKindClass, 153 | InsertTextFormat: protocol.InsertTextFormatSnippet, 154 | TextEdit: &protocol.TextEdit{ 155 | Range: protocol.Range{ 156 | Start: protocol.Position{Line: 0, Character: 0}, 157 | End: protocol.Position{Line: 0, Character: 3}, 158 | }, 159 | NewText: `include "${1:root}" { 160 | path = ${2:find_in_parent_folders("root.hcl")} 161 | }`, 162 | }, 163 | }, 164 | }, 165 | }, 166 | { 167 | name: "complete generate", 168 | store: store.Store{ 169 | Document: `generate`, 170 | }, 171 | position: protocol.Position{Line: 0, Character: 3}, 172 | completions: []protocol.CompletionItem{ 173 | { 174 | Label: "generate", 175 | Documentation: protocol.MarkupContent{ 176 | Kind: protocol.Markdown, 177 | Value: "# generate\nThe generate block can be used to arbitrarily generate a file in the terragrunt working directory.", 178 | }, 179 | Kind: protocol.CompletionItemKindClass, 180 | InsertTextFormat: protocol.InsertTextFormatSnippet, 181 | TextEdit: &protocol.TextEdit{ 182 | Range: protocol.Range{ 183 | Start: protocol.Position{Line: 0, Character: 0}, 184 | End: protocol.Position{Line: 0, Character: 3}, 185 | }, 186 | NewText: `generate "provider" { 187 | path = "${1:provider.tf}" 188 | if_exists = "${2:overwrite}" 189 | contents = <= 1; i-- { 69 | nodes, ok = d.Index[i] 70 | if !ok || len(nodes) == 0 { 71 | continue 72 | } 73 | 74 | closest = nodes[len(nodes)-1] 75 | 76 | break 77 | } 78 | } 79 | 80 | if closest == nil { 81 | return nil 82 | } 83 | // Navigate up the AST to find the first node that contains the position. 84 | node := closest 85 | for node != nil { 86 | end := node.Range().End 87 | if end.Line > pos.Line || end.Line == pos.Line && end.Column > pos.Column { 88 | return node 89 | } 90 | 91 | node = node.Parent 92 | } 93 | 94 | return nil 95 | } 96 | 97 | type Scope map[string]*IndexedNode 98 | 99 | func (s Scope) Add(node *IndexedNode) { 100 | switch n := node.Node.(type) { 101 | case *hclsyntax.Attribute: 102 | s[n.Name] = node 103 | case *hclsyntax.Block: 104 | s[n.Labels[0]] = node 105 | default: 106 | panic("invalid node type " + reflect.TypeOf(node.Node).String()) 107 | } 108 | } 109 | 110 | // NodeIndex is a map from line number to an ordered list of nodes that start on that line. 111 | type NodeIndex map[int][]*IndexedNode 112 | 113 | type nodeIndexBuilder struct { 114 | index NodeIndex 115 | locals Scope 116 | includes Scope 117 | stack []*IndexedNode 118 | } 119 | 120 | func newNodeIndexBuilder() *nodeIndexBuilder { 121 | return &nodeIndexBuilder{ 122 | index: make(map[int][]*IndexedNode), 123 | locals: make(Scope), 124 | includes: make(Scope), 125 | } 126 | } 127 | 128 | func (w *nodeIndexBuilder) Enter(node hclsyntax.Node) hcl.Diagnostics { 129 | var parent *IndexedNode 130 | if len(w.stack) > 0 { 131 | parent = w.stack[len(w.stack)-1] 132 | } 133 | 134 | line := node.Range().Start.Line 135 | inode := &IndexedNode{ 136 | Parent: parent, 137 | Node: node, 138 | } 139 | w.stack = append(w.stack, inode) 140 | w.index[line] = append(w.index[line], inode) 141 | 142 | if IsLocalAttribute(inode) { 143 | w.locals.Add(inode) 144 | } else if IsIncludeBlock(inode) { 145 | w.includes.Add(inode) 146 | } 147 | 148 | return nil 149 | } 150 | 151 | func (w *nodeIndexBuilder) Exit(node hclsyntax.Node) hcl.Diagnostics { 152 | w.stack = w.stack[0 : len(w.stack)-1] 153 | return nil 154 | } 155 | 156 | // IsLocalAttribute returns TRUE if the node is a hclsyntax.Attribute within a locals {} block. 157 | func IsLocalAttribute(inode *IndexedNode) bool { 158 | if inode.Parent == nil || inode.Parent.Parent == nil || inode.Parent.Parent.Parent == nil { 159 | return false 160 | } 161 | 162 | if _, ok := inode.Parent.Node.(hclsyntax.Attributes); !ok { 163 | return false 164 | } 165 | 166 | if _, ok := inode.Parent.Parent.Node.(*hclsyntax.Body); !ok { 167 | return false 168 | } 169 | 170 | return IsLocalsBlock(inode.Parent.Parent.Parent) 171 | } 172 | 173 | // IsLocalsBlock returns TRUE if the node is an HCL block of type "locals". 174 | func IsLocalsBlock(inode *IndexedNode) bool { 175 | block, ok := inode.Node.(*hclsyntax.Block) 176 | return ok && block.Type == "locals" 177 | } 178 | 179 | // IsIncludeBlock returns TRUE if the node is an HCL block of type "include". 180 | func IsIncludeBlock(inode *IndexedNode) bool { 181 | block, ok := inode.Node.(*hclsyntax.Block) 182 | return ok && block.Type == "include" && len(block.Labels) > 0 183 | } 184 | 185 | // IsDependencyBlock returns TRUE if the node is an HCL block of type "dependency". 186 | func IsDependencyBlock(inode *IndexedNode) bool { 187 | block, ok := inode.Node.(*hclsyntax.Block) 188 | return ok && block.Type == "dependency" && len(block.Labels) > 0 189 | } 190 | 191 | // IsAttribute returns TRUE if the node is an hclsyntax.Attribute. 192 | func IsAttribute(inode *IndexedNode) bool { 193 | _, ok := inode.Node.(*hclsyntax.Attribute) 194 | return ok 195 | } 196 | 197 | // GetNodeIncludeLabel returns the label of the given node, if it is an include block. 198 | // If the node is not an include block, returns an empty string and false. 199 | func GetNodeIncludeLabel(inode *IndexedNode) (string, bool) { 200 | attr := FindFirstParentMatch(inode, IsAttribute) 201 | if attr == nil { 202 | return "", false 203 | } 204 | 205 | local := FindFirstParentMatch(attr, IsIncludeBlock) 206 | if local == nil { 207 | return "", false 208 | } 209 | 210 | name := "" 211 | if labels := local.Node.(*hclsyntax.Block).Labels; len(labels) > 0 { 212 | name = labels[0] 213 | } 214 | 215 | return name, true 216 | } 217 | 218 | // GetNodeDependencyLabel returns whether the node is part of a dependency block's config_path field. 219 | // If it is, returns the name of the dependency block and TRUE, otherwise returns "" and FALSE. 220 | func GetNodeDependencyLabel(inode *IndexedNode) (string, bool) { 221 | attr := FindFirstParentMatch(inode, IsAttribute) 222 | if attr == nil { 223 | return "", false 224 | } 225 | 226 | if attr.Node.(*hclsyntax.Attribute).Name != "config_path" { 227 | return "", false 228 | } 229 | 230 | dep := FindFirstParentMatch(attr, IsDependencyBlock) 231 | if dep == nil { 232 | return "", false 233 | } 234 | 235 | name := "" 236 | if labels := dep.Node.(*hclsyntax.Block).Labels; len(labels) > 0 { 237 | name = labels[0] 238 | } 239 | 240 | return name, true 241 | } 242 | 243 | func FindFirstParentMatch(inode *IndexedNode, matcher func(*IndexedNode) bool) *IndexedNode { 244 | for cur := inode; cur != nil; cur = cur.Parent { 245 | if matcher(cur) { 246 | return cur 247 | } 248 | } 249 | 250 | return nil 251 | } 252 | 253 | var _ hclsyntax.Walker = &nodeIndexBuilder{} 254 | 255 | func indexAST(ast *hcl.File) *IndexedAST { 256 | body := ast.Body.(*hclsyntax.Body) 257 | builder := newNodeIndexBuilder() 258 | _ = hclsyntax.Walk(body, builder) 259 | 260 | return &IndexedAST{ 261 | Index: builder.index, 262 | Locals: builder.locals, 263 | Includes: builder.includes, 264 | HCLFile: ast, 265 | } 266 | } 267 | -------------------------------------------------------------------------------- /internal/tg/state.go: -------------------------------------------------------------------------------- 1 | package tg 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strings" 7 | "terragrunt-ls/internal/ast" 8 | "terragrunt-ls/internal/logger" 9 | "terragrunt-ls/internal/lsp" 10 | "terragrunt-ls/internal/tg/completion" 11 | "terragrunt-ls/internal/tg/definition" 12 | "terragrunt-ls/internal/tg/hover" 13 | "terragrunt-ls/internal/tg/store" 14 | "terragrunt-ls/internal/tg/text" 15 | 16 | "github.com/gruntwork-io/terragrunt/config" 17 | "github.com/hashicorp/hcl/v2/hclwrite" 18 | "github.com/zclconf/go-cty/cty" 19 | "go.lsp.dev/protocol" 20 | "go.lsp.dev/uri" 21 | ) 22 | 23 | type State struct { 24 | // Map of file names to Terragrunt configs 25 | Configs map[string]store.Store 26 | } 27 | 28 | func NewState() State { 29 | return State{Configs: map[string]store.Store{}} 30 | } 31 | 32 | func (s *State) OpenDocument(l logger.Logger, docURI protocol.DocumentURI, text string) []protocol.Diagnostic { 33 | l.Debug( 34 | "Opening document", 35 | "uri", docURI, 36 | "text", text, 37 | ) 38 | 39 | return s.updateState(l, docURI, text) 40 | } 41 | 42 | func (s *State) UpdateDocument(l logger.Logger, docURI protocol.DocumentURI, text string) []protocol.Diagnostic { 43 | l.Debug( 44 | "Updating document", 45 | "uri", docURI, 46 | "text", text, 47 | ) 48 | 49 | return s.updateState(l, docURI, text) 50 | } 51 | 52 | func (s *State) updateState(l logger.Logger, docURI protocol.DocumentURI, text string) []protocol.Diagnostic { 53 | // Ignore errors from AST indexing since we'll get the same errors from the Terragrunt parser just below 54 | ast, _ := ast.ParseHCLFile(docURI.Filename(), []byte(text)) 55 | 56 | cfg, diags := ParseTerragruntBuffer(l, docURI.Filename(), text) 57 | 58 | l.Debug( 59 | "Config", 60 | "uri", docURI, 61 | "config", cfg, 62 | ) 63 | 64 | cfgAsCty := cty.NilVal 65 | 66 | if cfg != nil { 67 | if converted, err := config.TerragruntConfigAsCty(cfg); err == nil { 68 | cfgAsCty = converted 69 | } 70 | } 71 | 72 | s.Configs[docURI.Filename()] = store.Store{ 73 | AST: ast, 74 | Cfg: cfg, 75 | CfgAsCty: cfgAsCty, 76 | Document: text, 77 | } 78 | 79 | return diags 80 | } 81 | 82 | func (s *State) Hover(l logger.Logger, id int, docURI protocol.DocumentURI, position protocol.Position) lsp.HoverResponse { 83 | store := s.Configs[docURI.Filename()] 84 | 85 | l.Debug( 86 | "Hovering over character", 87 | "uri", docURI, 88 | "position", position, 89 | ) 90 | 91 | l.Debug( 92 | "Config", 93 | "uri", docURI, 94 | "config", store.Cfg, 95 | ) 96 | 97 | word, context := hover.GetHoverTargetWithContext(l, store, position) 98 | 99 | l.Debug( 100 | "Hovering with context", 101 | "word", word, 102 | "context", context, 103 | ) 104 | 105 | if word == "" { 106 | return newEmptyHoverResponse(id) 107 | } 108 | 109 | //nolint:gocritic 110 | switch context { 111 | case hover.HoverContextLocal: 112 | if store.Cfg == nil { 113 | return newEmptyHoverResponse(id) 114 | } 115 | 116 | if _, ok := store.Cfg.Locals[word]; !ok { 117 | return newEmptyHoverResponse(id) 118 | } 119 | 120 | if store.CfgAsCty.IsNull() { 121 | return newEmptyHoverResponse(id) 122 | } 123 | 124 | locals := store.CfgAsCty.GetAttr("locals") 125 | localVal := locals.GetAttr(word) 126 | 127 | f := hclwrite.NewEmptyFile() 128 | rootBody := f.Body() 129 | rootBody.SetAttributeValue(word, localVal) 130 | 131 | return lsp.HoverResponse{ 132 | Response: lsp.Response{ 133 | RPC: lsp.RPCVersion, 134 | ID: &id, 135 | }, 136 | Result: lsp.HoverResult{ 137 | Contents: protocol.MarkupContent{ 138 | Kind: protocol.Markdown, 139 | Value: text.WrapAsHCLCodeFence(strings.TrimSpace(string(f.Bytes()))), 140 | }, 141 | }, 142 | } 143 | } 144 | 145 | return newEmptyHoverResponse(id) 146 | } 147 | 148 | func newEmptyHoverResponse(id int) lsp.HoverResponse { 149 | return lsp.HoverResponse{ 150 | Response: lsp.Response{ 151 | RPC: lsp.RPCVersion, 152 | ID: &id, 153 | }, 154 | } 155 | } 156 | 157 | func (s *State) Definition(l logger.Logger, id int, docURI protocol.DocumentURI, position protocol.Position) lsp.DefinitionResponse { 158 | store := s.Configs[docURI.Filename()] 159 | 160 | l.Debug( 161 | "Definition requested", 162 | "uri", docURI, 163 | "position", position, 164 | ) 165 | 166 | target, context := definition.GetDefinitionTargetWithContext(l, store, position) 167 | 168 | l.Debug( 169 | "Definition discovered", 170 | "target", target, 171 | "context", context, 172 | ) 173 | 174 | if target == "" { 175 | return newEmptyDefinitionResponse(id, docURI, position) 176 | } 177 | 178 | //nolint:gocritic 179 | switch context { 180 | case definition.DefinitionContextInclude: 181 | l.Debug( 182 | "Store content", 183 | "store", store, 184 | ) 185 | 186 | if store.Cfg == nil { 187 | return newEmptyDefinitionResponse(id, docURI, position) 188 | } 189 | 190 | l.Debug( 191 | "Includes", 192 | "includes", store.Cfg.ProcessedIncludes, 193 | ) 194 | 195 | for _, include := range store.Cfg.ProcessedIncludes { 196 | if include.Name == target { 197 | l.Debug( 198 | "Jumping to target", 199 | "include", include, 200 | ) 201 | 202 | defURI := uri.File(include.Path) 203 | 204 | l.Debug( 205 | "URI of target", 206 | "URI", defURI, 207 | ) 208 | 209 | return lsp.DefinitionResponse{ 210 | Response: lsp.Response{ 211 | RPC: lsp.RPCVersion, 212 | ID: &id, 213 | }, 214 | Result: protocol.Location{ 215 | URI: defURI, 216 | Range: protocol.Range{ 217 | Start: protocol.Position{ 218 | Line: 0, 219 | Character: 0, 220 | }, 221 | End: protocol.Position{ 222 | Line: 0, 223 | Character: 0, 224 | }, 225 | }, 226 | }, 227 | } 228 | } 229 | } 230 | case definition.DefinitionContextDependency: 231 | l.Debug( 232 | "Store content", 233 | "store", store, 234 | ) 235 | 236 | if store.Cfg == nil { 237 | return newEmptyDefinitionResponse(id, docURI, position) 238 | } 239 | 240 | l.Debug( 241 | "Dependencies", 242 | "dependencies", store.Cfg.TerragruntDependencies, 243 | ) 244 | 245 | for _, dep := range store.Cfg.TerragruntDependencies { 246 | if dep.Name == target { 247 | l.Debug( 248 | "Jumping to target", 249 | "dependency", dep, 250 | ) 251 | 252 | path := dep.ConfigPath.AsString() 253 | 254 | defURI := uri.File(path) 255 | if !filepath.IsAbs(path) { 256 | defURI = uri.File(filepath.Join(filepath.Dir(docURI.Filename()), path, "terragrunt.hcl")) 257 | } 258 | 259 | _, err := os.Stat(defURI.Filename()) 260 | if err != nil { 261 | l.Warn( 262 | "Dependency does not exist", 263 | "dependency", dep, 264 | "error", err, 265 | ) 266 | 267 | return newEmptyDefinitionResponse(id, docURI, position) 268 | } 269 | 270 | l.Debug( 271 | "URI of target", 272 | "URI", defURI, 273 | ) 274 | 275 | return lsp.DefinitionResponse{ 276 | Response: lsp.Response{ 277 | RPC: lsp.RPCVersion, 278 | ID: &id, 279 | }, 280 | Result: protocol.Location{ 281 | URI: defURI, 282 | Range: protocol.Range{ 283 | Start: protocol.Position{ 284 | Line: 0, 285 | Character: 0, 286 | }, 287 | End: protocol.Position{ 288 | Line: 0, 289 | Character: 0, 290 | }, 291 | }, 292 | }, 293 | } 294 | } 295 | } 296 | } 297 | 298 | return newEmptyDefinitionResponse(id, docURI, position) 299 | } 300 | 301 | func newEmptyDefinitionResponse(id int, docURI protocol.DocumentURI, position protocol.Position) lsp.DefinitionResponse { 302 | return lsp.DefinitionResponse{ 303 | Response: lsp.Response{ 304 | RPC: lsp.RPCVersion, 305 | ID: &id, 306 | }, 307 | Result: protocol.Location{ 308 | URI: docURI, 309 | Range: protocol.Range{ 310 | Start: position, 311 | End: position, 312 | }, 313 | }, 314 | } 315 | } 316 | 317 | func (s *State) TextDocumentCompletion(l logger.Logger, id int, docURI protocol.DocumentURI, position protocol.Position) lsp.CompletionResponse { 318 | items := completion.GetCompletions(l, s.Configs[docURI.Filename()], position) 319 | 320 | response := lsp.CompletionResponse{ 321 | Response: lsp.Response{ 322 | RPC: "2.0", 323 | ID: &id, 324 | }, 325 | Result: items, 326 | } 327 | 328 | return response 329 | } 330 | 331 | func (s *State) TextDocumentFormatting(l logger.Logger, id int, docURI protocol.DocumentURI) lsp.FormatResponse { 332 | store := s.Configs[docURI.Filename()] 333 | 334 | l.Debug( 335 | "Formatting requested", 336 | "uri", docURI, 337 | ) 338 | 339 | formatted := hclwrite.Format([]byte(store.Document)) 340 | 341 | return lsp.FormatResponse{ 342 | Response: lsp.Response{ 343 | RPC: lsp.RPCVersion, 344 | ID: &id, 345 | }, 346 | Result: []protocol.TextEdit{ 347 | { 348 | Range: protocol.Range{ 349 | Start: protocol.Position{ 350 | Line: 0, 351 | Character: 0, 352 | }, 353 | End: getEndOfDocument(store.Document), 354 | }, 355 | NewText: string(formatted), 356 | }, 357 | }, 358 | } 359 | } 360 | 361 | func getEndOfDocument(doc string) protocol.Position { 362 | lines := strings.Split(doc, "\n") 363 | 364 | return protocol.Position{ 365 | Line: uint32(len(lines) - 1), 366 | Character: uint32(len(lines[len(lines)-1])), 367 | } 368 | } 369 | -------------------------------------------------------------------------------- /zed-extension/Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "anyhow" 7 | version = "1.0.97" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f" 10 | 11 | [[package]] 12 | name = "bitflags" 13 | version = "2.9.0" 14 | source = "registry+https://github.com/rust-lang/crates.io-index" 15 | checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" 16 | 17 | [[package]] 18 | name = "equivalent" 19 | version = "1.0.2" 20 | source = "registry+https://github.com/rust-lang/crates.io-index" 21 | checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" 22 | 23 | [[package]] 24 | name = "hashbrown" 25 | version = "0.15.2" 26 | source = "registry+https://github.com/rust-lang/crates.io-index" 27 | checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" 28 | 29 | [[package]] 30 | name = "heck" 31 | version = "0.4.1" 32 | source = "registry+https://github.com/rust-lang/crates.io-index" 33 | checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" 34 | dependencies = [ 35 | "unicode-segmentation", 36 | ] 37 | 38 | [[package]] 39 | name = "id-arena" 40 | version = "2.2.1" 41 | source = "registry+https://github.com/rust-lang/crates.io-index" 42 | checksum = "25a2bc672d1148e28034f176e01fffebb08b35768468cc954630da77a1449005" 43 | 44 | [[package]] 45 | name = "indexmap" 46 | version = "2.8.0" 47 | source = "registry+https://github.com/rust-lang/crates.io-index" 48 | checksum = "3954d50fe15b02142bf25d3b8bdadb634ec3948f103d04ffe3031bc8fe9d7058" 49 | dependencies = [ 50 | "equivalent", 51 | "hashbrown", 52 | "serde", 53 | ] 54 | 55 | [[package]] 56 | name = "itoa" 57 | version = "1.0.15" 58 | source = "registry+https://github.com/rust-lang/crates.io-index" 59 | checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" 60 | 61 | [[package]] 62 | name = "leb128" 63 | version = "0.2.5" 64 | source = "registry+https://github.com/rust-lang/crates.io-index" 65 | checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67" 66 | 67 | [[package]] 68 | name = "log" 69 | version = "0.4.26" 70 | source = "registry+https://github.com/rust-lang/crates.io-index" 71 | checksum = "30bde2b3dc3671ae49d8e2e9f044c7c005836e7a023ee57cffa25ab82764bb9e" 72 | 73 | [[package]] 74 | name = "memchr" 75 | version = "2.7.4" 76 | source = "registry+https://github.com/rust-lang/crates.io-index" 77 | checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" 78 | 79 | [[package]] 80 | name = "proc-macro2" 81 | version = "1.0.94" 82 | source = "registry+https://github.com/rust-lang/crates.io-index" 83 | checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84" 84 | dependencies = [ 85 | "unicode-ident", 86 | ] 87 | 88 | [[package]] 89 | name = "quote" 90 | version = "1.0.40" 91 | source = "registry+https://github.com/rust-lang/crates.io-index" 92 | checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" 93 | dependencies = [ 94 | "proc-macro2", 95 | ] 96 | 97 | [[package]] 98 | name = "ryu" 99 | version = "1.0.20" 100 | source = "registry+https://github.com/rust-lang/crates.io-index" 101 | checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" 102 | 103 | [[package]] 104 | name = "semver" 105 | version = "1.0.26" 106 | source = "registry+https://github.com/rust-lang/crates.io-index" 107 | checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" 108 | 109 | [[package]] 110 | name = "serde" 111 | version = "1.0.219" 112 | source = "registry+https://github.com/rust-lang/crates.io-index" 113 | checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" 114 | dependencies = [ 115 | "serde_derive", 116 | ] 117 | 118 | [[package]] 119 | name = "serde_derive" 120 | version = "1.0.219" 121 | source = "registry+https://github.com/rust-lang/crates.io-index" 122 | checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" 123 | dependencies = [ 124 | "proc-macro2", 125 | "quote", 126 | "syn", 127 | ] 128 | 129 | [[package]] 130 | name = "serde_json" 131 | version = "1.0.140" 132 | source = "registry+https://github.com/rust-lang/crates.io-index" 133 | checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" 134 | dependencies = [ 135 | "itoa", 136 | "memchr", 137 | "ryu", 138 | "serde", 139 | ] 140 | 141 | [[package]] 142 | name = "smallvec" 143 | version = "1.14.0" 144 | source = "registry+https://github.com/rust-lang/crates.io-index" 145 | checksum = "7fcf8323ef1faaee30a44a340193b1ac6814fd9b7b4e88e9d4519a3e4abe1cfd" 146 | 147 | [[package]] 148 | name = "spdx" 149 | version = "0.10.8" 150 | source = "registry+https://github.com/rust-lang/crates.io-index" 151 | checksum = "58b69356da67e2fc1f542c71ea7e654a361a79c938e4424392ecf4fa065d2193" 152 | dependencies = [ 153 | "smallvec", 154 | ] 155 | 156 | [[package]] 157 | name = "syn" 158 | version = "2.0.100" 159 | source = "registry+https://github.com/rust-lang/crates.io-index" 160 | checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0" 161 | dependencies = [ 162 | "proc-macro2", 163 | "quote", 164 | "unicode-ident", 165 | ] 166 | 167 | [[package]] 168 | name = "terragrunt-ls" 169 | version = "0.0.1" 170 | dependencies = [ 171 | "zed_extension_api", 172 | ] 173 | 174 | [[package]] 175 | name = "unicode-ident" 176 | version = "1.0.18" 177 | source = "registry+https://github.com/rust-lang/crates.io-index" 178 | checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" 179 | 180 | [[package]] 181 | name = "unicode-segmentation" 182 | version = "1.12.0" 183 | source = "registry+https://github.com/rust-lang/crates.io-index" 184 | checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" 185 | 186 | [[package]] 187 | name = "unicode-xid" 188 | version = "0.2.6" 189 | source = "registry+https://github.com/rust-lang/crates.io-index" 190 | checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" 191 | 192 | [[package]] 193 | name = "wasm-encoder" 194 | version = "0.201.0" 195 | source = "registry+https://github.com/rust-lang/crates.io-index" 196 | checksum = "b9c7d2731df60006819b013f64ccc2019691deccf6e11a1804bc850cd6748f1a" 197 | dependencies = [ 198 | "leb128", 199 | ] 200 | 201 | [[package]] 202 | name = "wasm-metadata" 203 | version = "0.201.0" 204 | source = "registry+https://github.com/rust-lang/crates.io-index" 205 | checksum = "0fd83062c17b9f4985d438603cde0a5e8c5c8198201a6937f778b607924c7da2" 206 | dependencies = [ 207 | "anyhow", 208 | "indexmap", 209 | "serde", 210 | "serde_derive", 211 | "serde_json", 212 | "spdx", 213 | "wasm-encoder", 214 | "wasmparser", 215 | ] 216 | 217 | [[package]] 218 | name = "wasmparser" 219 | version = "0.201.0" 220 | source = "registry+https://github.com/rust-lang/crates.io-index" 221 | checksum = "84e5df6dba6c0d7fafc63a450f1738451ed7a0b52295d83e868218fa286bf708" 222 | dependencies = [ 223 | "bitflags", 224 | "indexmap", 225 | "semver", 226 | ] 227 | 228 | [[package]] 229 | name = "wit-bindgen" 230 | version = "0.22.0" 231 | source = "registry+https://github.com/rust-lang/crates.io-index" 232 | checksum = "288f992ea30e6b5c531b52cdd5f3be81c148554b09ea416f058d16556ba92c27" 233 | dependencies = [ 234 | "bitflags", 235 | "wit-bindgen-rt", 236 | "wit-bindgen-rust-macro", 237 | ] 238 | 239 | [[package]] 240 | name = "wit-bindgen-core" 241 | version = "0.22.0" 242 | source = "registry+https://github.com/rust-lang/crates.io-index" 243 | checksum = "e85e72719ffbccf279359ad071497e47eb0675fe22106dea4ed2d8a7fcb60ba4" 244 | dependencies = [ 245 | "anyhow", 246 | "wit-parser", 247 | ] 248 | 249 | [[package]] 250 | name = "wit-bindgen-rt" 251 | version = "0.22.0" 252 | source = "registry+https://github.com/rust-lang/crates.io-index" 253 | checksum = "fcb8738270f32a2d6739973cbbb7c1b6dd8959ce515578a6e19165853272ee64" 254 | 255 | [[package]] 256 | name = "wit-bindgen-rust" 257 | version = "0.22.0" 258 | source = "registry+https://github.com/rust-lang/crates.io-index" 259 | checksum = "d8a39a15d1ae2077688213611209849cad40e9e5cccf6e61951a425850677ff3" 260 | dependencies = [ 261 | "anyhow", 262 | "heck", 263 | "indexmap", 264 | "wasm-metadata", 265 | "wit-bindgen-core", 266 | "wit-component", 267 | ] 268 | 269 | [[package]] 270 | name = "wit-bindgen-rust-macro" 271 | version = "0.22.0" 272 | source = "registry+https://github.com/rust-lang/crates.io-index" 273 | checksum = "d376d3ae5850526dfd00d937faea0d81a06fa18f7ac1e26f386d760f241a8f4b" 274 | dependencies = [ 275 | "anyhow", 276 | "proc-macro2", 277 | "quote", 278 | "syn", 279 | "wit-bindgen-core", 280 | "wit-bindgen-rust", 281 | ] 282 | 283 | [[package]] 284 | name = "wit-component" 285 | version = "0.201.0" 286 | source = "registry+https://github.com/rust-lang/crates.io-index" 287 | checksum = "421c0c848a0660a8c22e2fd217929a0191f14476b68962afd2af89fd22e39825" 288 | dependencies = [ 289 | "anyhow", 290 | "bitflags", 291 | "indexmap", 292 | "log", 293 | "serde", 294 | "serde_derive", 295 | "serde_json", 296 | "wasm-encoder", 297 | "wasm-metadata", 298 | "wasmparser", 299 | "wit-parser", 300 | ] 301 | 302 | [[package]] 303 | name = "wit-parser" 304 | version = "0.201.0" 305 | source = "registry+https://github.com/rust-lang/crates.io-index" 306 | checksum = "196d3ecfc4b759a8573bf86a9b3f8996b304b3732e4c7de81655f875f6efdca6" 307 | dependencies = [ 308 | "anyhow", 309 | "id-arena", 310 | "indexmap", 311 | "log", 312 | "semver", 313 | "serde", 314 | "serde_derive", 315 | "serde_json", 316 | "unicode-xid", 317 | "wasmparser", 318 | ] 319 | 320 | [[package]] 321 | name = "zed_extension_api" 322 | version = "0.2.0" 323 | source = "registry+https://github.com/rust-lang/crates.io-index" 324 | checksum = "9fd16b8b30a9dc920fc1678ff852f696b5bdf5b5843bc745a128be0aac29859e" 325 | dependencies = [ 326 | "serde", 327 | "serde_json", 328 | "wit-bindgen", 329 | ] 330 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module terragrunt-ls 2 | 3 | go 1.24.4 4 | 5 | require ( 6 | github.com/gruntwork-io/terragrunt v0.82.3 7 | github.com/hashicorp/hcl/v2 v2.23.0 8 | github.com/sirupsen/logrus v1.9.3 9 | github.com/stretchr/testify v1.10.0 10 | github.com/zclconf/go-cty v1.16.3 11 | go.lsp.dev/protocol v0.12.0 12 | go.lsp.dev/uri v0.3.0 13 | ) 14 | 15 | require ( 16 | cel.dev/expr v0.24.0 // indirect 17 | cloud.google.com/go v0.121.3 // indirect 18 | cloud.google.com/go/auth v0.16.2 // indirect 19 | cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect 20 | cloud.google.com/go/compute/metadata v0.7.0 // indirect 21 | cloud.google.com/go/iam v1.5.2 // indirect 22 | cloud.google.com/go/kms v1.22.0 // indirect 23 | cloud.google.com/go/longrunning v0.6.7 // indirect 24 | cloud.google.com/go/monitoring v1.24.2 // indirect 25 | cloud.google.com/go/storage v1.55.0 // indirect 26 | dario.cat/mergo v1.0.2 // indirect 27 | filippo.io/age v1.2.1 // indirect 28 | filippo.io/edwards25519 v1.1.0 // indirect 29 | github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 // indirect 30 | github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 // indirect 31 | github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 // indirect 32 | github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0 // indirect 33 | github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 // indirect 34 | github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 // indirect 35 | github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 // indirect 36 | github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 // indirect 37 | github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 // indirect 38 | github.com/ProtonMail/go-crypto v1.3.0 // indirect 39 | github.com/agext/levenshtein v1.2.3 // indirect 40 | github.com/apparentlymart/go-cidr v1.1.0 // indirect 41 | github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect 42 | github.com/apparentlymart/go-versions v1.0.3 // indirect 43 | github.com/aws/aws-sdk-go v1.55.7 // indirect 44 | github.com/aws/aws-sdk-go-v2 v1.36.5 // indirect 45 | github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11 // indirect 46 | github.com/aws/aws-sdk-go-v2/config v1.29.17 // indirect 47 | github.com/aws/aws-sdk-go-v2/credentials v1.17.70 // indirect 48 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.32 // indirect 49 | github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.82 // indirect 50 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.36 // indirect 51 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.36 // indirect 52 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect 53 | github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.36 // indirect 54 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.4 // indirect 55 | github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.4 // indirect 56 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.17 // indirect 57 | github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.17 // indirect 58 | github.com/aws/aws-sdk-go-v2/service/kms v1.41.2 // indirect 59 | github.com/aws/aws-sdk-go-v2/service/s3 v1.82.0 // indirect 60 | github.com/aws/aws-sdk-go-v2/service/sso v1.25.5 // indirect 61 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.3 // indirect 62 | github.com/aws/aws-sdk-go-v2/service/sts v1.34.0 // indirect 63 | github.com/aws/smithy-go v1.22.4 // indirect 64 | github.com/bahlo/generic-list-go v0.2.0 // indirect 65 | github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect 66 | github.com/blang/semver v3.5.1+incompatible // indirect 67 | github.com/bmatcuk/doublestar v1.3.4 // indirect 68 | github.com/buger/jsonparser v1.1.1 // indirect 69 | github.com/cenkalti/backoff/v4 v4.3.0 // indirect 70 | github.com/cenkalti/backoff/v5 v5.0.2 // indirect 71 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 72 | github.com/cloudflare/circl v1.6.1 // indirect 73 | github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect 74 | github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect 75 | github.com/creack/pty v1.1.24 // indirect 76 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 77 | github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect 78 | github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect 79 | github.com/fatih/color v1.18.0 // indirect 80 | github.com/felixge/httpsnoop v1.0.4 // indirect 81 | github.com/getsops/gopgagent v0.0.0-20241224165529-7044f28e491e // indirect 82 | github.com/getsops/sops/v3 v3.10.2 // indirect 83 | github.com/go-errors/errors v1.5.1 // indirect 84 | github.com/go-jose/go-jose/v4 v4.1.1 // indirect 85 | github.com/go-logr/logr v1.4.3 // indirect 86 | github.com/go-logr/stdr v1.2.2 // indirect 87 | github.com/go-test/deep v1.1.1 // indirect 88 | github.com/gofrs/flock v0.12.1 // indirect 89 | github.com/golang-jwt/jwt/v5 v5.2.2 // indirect 90 | github.com/golang/protobuf v1.5.4 // indirect 91 | github.com/google/go-cmp v0.7.0 // indirect 92 | github.com/google/s2a-go v0.1.9 // indirect 93 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect 94 | github.com/google/uuid v1.6.0 // indirect 95 | github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect 96 | github.com/googleapis/gax-go/v2 v2.14.2 // indirect 97 | github.com/goware/prefixer v0.0.0-20160118172347-395022866408 // indirect 98 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 // indirect 99 | github.com/gruntwork-io/go-commons v0.17.2 // indirect 100 | github.com/gruntwork-io/terragrunt-engine-go v0.0.15 // indirect 101 | github.com/hashicorp/errwrap v1.1.0 // indirect 102 | github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 103 | github.com/hashicorp/go-getter v1.7.8 // indirect 104 | github.com/hashicorp/go-getter/v2 v2.2.3 // indirect 105 | github.com/hashicorp/go-hclog v1.6.3 // indirect 106 | github.com/hashicorp/go-multierror v1.1.1 // indirect 107 | github.com/hashicorp/go-plugin v1.6.3 // indirect 108 | github.com/hashicorp/go-retryablehttp v0.7.8 // indirect 109 | github.com/hashicorp/go-rootcerts v1.0.2 // indirect 110 | github.com/hashicorp/go-safetemp v1.0.0 // indirect 111 | github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect 112 | github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect 113 | github.com/hashicorp/go-sockaddr v1.0.7 // indirect 114 | github.com/hashicorp/go-uuid v1.0.3 // indirect 115 | github.com/hashicorp/go-version v1.7.0 // indirect 116 | github.com/hashicorp/hcl v1.0.1-vault-7 // indirect 117 | github.com/hashicorp/terraform v1.12.2 // indirect 118 | github.com/hashicorp/terraform-config-inspect v0.0.0-20250515145901-f4c50e64fd6d // indirect 119 | github.com/hashicorp/terraform-svchost v0.1.1 // indirect 120 | github.com/hashicorp/vault/api v1.20.0 // indirect 121 | github.com/hashicorp/yamux v0.1.2 // indirect 122 | github.com/huandu/go-clone v1.7.3 // indirect 123 | github.com/invopop/jsonschema v0.13.0 // indirect 124 | github.com/jhump/protoreflect v1.16.0 // indirect 125 | github.com/jmespath/go-jmespath v0.4.0 // indirect 126 | github.com/klauspost/compress v1.18.0 // indirect 127 | github.com/kylelemons/godebug v1.1.0 // indirect 128 | github.com/lib/pq v1.10.9 // indirect 129 | github.com/mailru/easyjson v0.9.0 // indirect 130 | github.com/mattn/go-colorable v0.1.14 // indirect 131 | github.com/mattn/go-isatty v0.0.20 // indirect 132 | github.com/mattn/go-zglob v0.0.6 // indirect 133 | github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect 134 | github.com/mitchellh/go-homedir v1.1.0 // indirect 135 | github.com/mitchellh/go-testing-interface v1.14.1 // indirect 136 | github.com/mitchellh/go-wordwrap v1.0.1 // indirect 137 | github.com/mitchellh/mapstructure v1.5.0 // indirect 138 | github.com/mitchellh/panicwrap v1.0.0 // indirect 139 | github.com/oklog/run v1.2.0 // indirect 140 | github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect 141 | github.com/pkg/errors v0.9.1 // indirect 142 | github.com/planetscale/vtprotobuf v0.6.1-0.20250313105119-ba97887b0a25 // indirect 143 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 144 | github.com/posener/complete v1.2.3 // indirect 145 | github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect 146 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 147 | github.com/ryanuber/go-glob v1.0.0 // indirect 148 | github.com/segmentio/asm v1.2.0 // indirect 149 | github.com/segmentio/encoding v0.5.1 // indirect 150 | github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect 151 | github.com/ulikunitz/xz v0.5.12 // indirect 152 | github.com/urfave/cli v1.22.17 // indirect 153 | github.com/urfave/cli/v2 v2.27.7 // indirect 154 | github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect 155 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect 156 | github.com/zclconf/go-cty-yaml v1.1.0 // indirect 157 | github.com/zeebo/errs v1.4.0 // indirect 158 | go.lsp.dev/jsonrpc2 v0.10.0 // indirect 159 | go.lsp.dev/pkg v0.0.0-20210717090340-384b27a52fb2 // indirect 160 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 161 | go.opentelemetry.io/contrib/detectors/gcp v1.37.0 // indirect 162 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0 // indirect 163 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect 164 | go.opentelemetry.io/otel v1.37.0 // indirect 165 | go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.37.0 // indirect 166 | go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.37.0 // indirect 167 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 // indirect 168 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0 // indirect 169 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.37.0 // indirect 170 | go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.37.0 // indirect 171 | go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.37.0 // indirect 172 | go.opentelemetry.io/otel/metric v1.37.0 // indirect 173 | go.opentelemetry.io/otel/sdk v1.37.0 // indirect 174 | go.opentelemetry.io/otel/sdk/metric v1.37.0 // indirect 175 | go.opentelemetry.io/otel/trace v1.37.0 // indirect 176 | go.opentelemetry.io/proto/otlp v1.7.0 // indirect 177 | go.uber.org/multierr v1.11.0 // indirect 178 | go.uber.org/zap v1.27.0 // indirect 179 | golang.org/x/crypto v0.39.0 // indirect 180 | golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect 181 | golang.org/x/mod v0.25.0 // indirect 182 | golang.org/x/net v0.41.0 // indirect 183 | golang.org/x/oauth2 v0.30.0 // indirect 184 | golang.org/x/sync v0.15.0 // indirect 185 | golang.org/x/sys v0.33.0 // indirect 186 | golang.org/x/term v0.32.0 // indirect 187 | golang.org/x/text v0.26.0 // indirect 188 | golang.org/x/time v0.12.0 // indirect 189 | golang.org/x/tools v0.34.0 // indirect 190 | google.golang.org/api v0.239.0 // indirect 191 | google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect 192 | google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect 193 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect 194 | google.golang.org/grpc v1.73.0 // indirect 195 | google.golang.org/protobuf v1.36.6 // indirect 196 | gopkg.in/ini.v1 v1.67.0 // indirect 197 | gopkg.in/yaml.v3 v3.0.1 // indirect 198 | ) 199 | 200 | // These need to track the Terragrunt repo to ensure that we don't accidentally bump 201 | // to versions that have licensing issues. 202 | replace ( 203 | // atomicgo.dev started to return 404 204 | atomicgo.dev/cursor => github.com/atomicgo/cursor v0.2.0 205 | atomicgo.dev/keyboard => github.com/atomicgo/keyboard v0.2.9 206 | atomicgo.dev/schedule => github.com/atomicgo/schedule v0.1.0 207 | // Many functions of terraform was converted to internal to avoid use as a library after v0.15.3. This means that we 208 | // can't use terraform as a library after v0.15.3, so we pull that in here. 209 | github.com/hashicorp/terraform => github.com/hashicorp/terraform v0.15.3 210 | 211 | // This is necessary to workaround go modules error with terraform importing vault incorrectly. 212 | // See https://github.com/hashicorp/vault/issues/7848 for more info 213 | github.com/hashicorp/vault => github.com/hashicorp/vault v1.4.2 214 | 215 | // TFlint introduced a BUSL license in v0.51.0, so we have to be careful not to update past this version. 216 | github.com/terraform-linters/tflint => github.com/terraform-linters/tflint v0.50.3 217 | ) 218 | -------------------------------------------------------------------------------- /internal/ast/ast_test.go: -------------------------------------------------------------------------------- 1 | package ast_test 2 | 3 | import ( 4 | "terragrunt-ls/internal/ast" 5 | "testing" 6 | 7 | "github.com/hashicorp/hcl/v2" 8 | "github.com/hashicorp/hcl/v2/hclsyntax" 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestParseHCLFile(t *testing.T) { 14 | t.Parallel() 15 | 16 | tc := []struct { 17 | expectedNodesAt map[hcl.Pos]string 18 | name string 19 | contents string 20 | }{ 21 | { 22 | name: "empty hcl", 23 | contents: ``, 24 | }, 25 | { 26 | name: "locals", 27 | contents: `locals { 28 | foo = "bar" 29 | } 30 | `, 31 | expectedNodesAt: map[hcl.Pos]string{ 32 | {Line: 1, Column: 1}: "[1:1-3:2] *hclsyntax.Block", 33 | }, 34 | }, 35 | { 36 | name: "include", 37 | contents: `include "root" { 38 | path = "root.hcl" 39 | } 40 | `, 41 | expectedNodesAt: map[hcl.Pos]string{ 42 | {Line: 1, Column: 1}: "[1:1-3:2] *hclsyntax.Block", 43 | }, 44 | }, 45 | { 46 | name: "include with locals and inputs", 47 | contents: `include "root" { 48 | path = local.root_path 49 | } 50 | 51 | locals { 52 | root_path = "root.hcl" 53 | } 54 | 55 | inputs = { 56 | foo = "bar" 57 | } 58 | `, 59 | expectedNodesAt: map[hcl.Pos]string{ 60 | {Line: 1, Column: 1}: "[1:1-3:2] *hclsyntax.Block", 61 | {Line: 6, Column: 1}: "[5:8-7:2] *hclsyntax.Body", 62 | {Line: 9, Column: 1}: "[9:1-11:2] *hclsyntax.Attribute", 63 | {Line: 10, Column: 1}: "[9:10-11:2] *hclsyntax.ObjectConsExpr", 64 | }, 65 | }, 66 | } 67 | 68 | for _, tt := range tc { 69 | t.Run(tt.name, func(t *testing.T) { 70 | t.Parallel() 71 | 72 | indexed, err := ast.ParseHCLFile("test.hcl", []byte(tt.contents)) 73 | require.NoError(t, err) 74 | 75 | require.NotNil(t, indexed) 76 | 77 | if tt.expectedNodesAt == nil { 78 | return 79 | } 80 | 81 | for pos, expected := range tt.expectedNodesAt { 82 | node := indexed.FindNodeAt(pos) 83 | require.NotNil(t, node) 84 | 85 | assert.Equal(t, expected, node.String()) 86 | } 87 | }) 88 | } 89 | } 90 | 91 | func TestParseHCLFile_WithErrors(t *testing.T) { 92 | t.Parallel() 93 | 94 | content := `locals { 95 | foo = "bar 96 | }` 97 | 98 | indexed, err := ast.ParseHCLFile("test.hcl", []byte(content)) 99 | 100 | // We should still get a partially indexed AST 101 | assert.NotNil(t, indexed) 102 | // And the error should be from the HCL parser 103 | assert.Error(t, err) 104 | } 105 | 106 | func TestIndexedAST_FindNodeAt_BasicCases(t *testing.T) { 107 | t.Parallel() 108 | 109 | // Test with empty file 110 | t.Run("empty file", func(t *testing.T) { 111 | t.Parallel() 112 | indexed, err := ast.ParseHCLFile("test.hcl", []byte(``)) 113 | require.NoError(t, err) 114 | require.NotNil(t, indexed) 115 | node := indexed.FindNodeAt(hcl.Pos{Line: 1, Column: 1}) 116 | assert.Nil(t, node, "Should not find a node in an empty file") 117 | }) 118 | 119 | // Test with position within a node span 120 | t.Run("position within node span", func(t *testing.T) { 121 | t.Parallel() 122 | content := `locals { 123 | foo = "bar" 124 | }` 125 | indexed, err := ast.ParseHCLFile("test.hcl", []byte(content)) 126 | require.NoError(t, err) 127 | require.NotNil(t, indexed) 128 | node := indexed.FindNodeAt(hcl.Pos{Line: 2, Column: 1}) 129 | assert.NotNil(t, node, "Should find a node within the node span") 130 | }) 131 | } 132 | 133 | func TestIsLocalAttribute(t *testing.T) { 134 | t.Parallel() 135 | 136 | tc := []struct { 137 | name string 138 | content string 139 | pos hcl.Pos 140 | expected bool 141 | }{ 142 | { 143 | name: "not a local attribute", 144 | content: `inputs = { 145 | foo = "bar" 146 | }`, 147 | pos: hcl.Pos{Line: 2, Column: 2}, 148 | expected: false, 149 | }, 150 | { 151 | name: "local attribute", 152 | content: `locals { 153 | foo = "bar" 154 | }`, 155 | pos: hcl.Pos{Line: 2, Column: 2}, 156 | expected: true, 157 | }, 158 | } 159 | 160 | for _, tt := range tc { 161 | t.Run(tt.name, func(t *testing.T) { 162 | t.Parallel() 163 | 164 | indexed, err := ast.ParseHCLFile("test.hcl", []byte(tt.content)) 165 | require.NoError(t, err) 166 | 167 | require.NotNil(t, indexed) 168 | 169 | node := indexed.FindNodeAt(tt.pos) 170 | 171 | assert.Equal(t, tt.expected, ast.IsLocalAttribute(node)) 172 | }) 173 | } 174 | } 175 | 176 | func TestIsLocalsBlock(t *testing.T) { 177 | t.Parallel() 178 | 179 | tc := []struct { 180 | name string 181 | content string 182 | pos hcl.Pos 183 | expected bool 184 | }{ 185 | { 186 | name: "not a locals block", 187 | content: `inputs = { 188 | foo = "bar" 189 | }`, 190 | pos: hcl.Pos{Line: 1, Column: 1}, 191 | expected: false, 192 | }, 193 | { 194 | name: "locals block", 195 | content: `locals { 196 | foo = "bar" 197 | }`, 198 | pos: hcl.Pos{Line: 1, Column: 1}, 199 | expected: true, 200 | }, 201 | } 202 | 203 | for _, tt := range tc { 204 | t.Run(tt.name, func(t *testing.T) { 205 | t.Parallel() 206 | 207 | indexed, err := ast.ParseHCLFile("test.hcl", []byte(tt.content)) 208 | require.NoError(t, err) 209 | 210 | require.NotNil(t, indexed) 211 | 212 | node := indexed.FindNodeAt(tt.pos) 213 | 214 | assert.Equal(t, tt.expected, ast.IsLocalsBlock(node)) 215 | }) 216 | } 217 | } 218 | 219 | func TestIsIncludeBlock(t *testing.T) { 220 | t.Parallel() 221 | 222 | tc := []struct { 223 | name string 224 | content string 225 | pos hcl.Pos 226 | expected bool 227 | }{ 228 | { 229 | name: "not an include block", 230 | content: `inputs = { 231 | foo = "bar" 232 | }`, 233 | pos: hcl.Pos{Line: 1, Column: 1}, 234 | expected: false, 235 | }, 236 | { 237 | name: "include block", 238 | content: `include "root" { 239 | path = "root.hcl" 240 | }`, 241 | pos: hcl.Pos{Line: 1, Column: 1}, 242 | expected: true, 243 | }, 244 | } 245 | 246 | for _, tt := range tc { 247 | t.Run(tt.name, func(t *testing.T) { 248 | t.Parallel() 249 | 250 | indexed, err := ast.ParseHCLFile("test.hcl", []byte(tt.content)) 251 | require.NoError(t, err) 252 | 253 | require.NotNil(t, indexed) 254 | 255 | node := indexed.FindNodeAt(tt.pos) 256 | 257 | assert.Equal(t, tt.expected, ast.IsIncludeBlock(node)) 258 | }) 259 | } 260 | } 261 | 262 | func TestIsAttribute(t *testing.T) { 263 | t.Parallel() 264 | 265 | tc := []struct { 266 | name string 267 | content string 268 | pos hcl.Pos 269 | expected bool 270 | }{ 271 | { 272 | name: "not an attribute", 273 | content: `locals { 274 | foo = "bar" 275 | }`, 276 | pos: hcl.Pos{Line: 1, Column: 1}, 277 | expected: false, 278 | }, 279 | { 280 | name: "attribute", 281 | content: `inputs = { 282 | foo = "bar" 283 | }`, 284 | pos: hcl.Pos{Line: 1, Column: 1}, 285 | expected: true, 286 | }, 287 | } 288 | 289 | for _, tt := range tc { 290 | t.Run(tt.name, func(t *testing.T) { 291 | t.Parallel() 292 | 293 | indexed, err := ast.ParseHCLFile("test.hcl", []byte(tt.content)) 294 | require.NoError(t, err) 295 | 296 | require.NotNil(t, indexed) 297 | 298 | node := indexed.FindNodeAt(tt.pos) 299 | 300 | assert.Equal(t, tt.expected, ast.IsAttribute(node)) 301 | }) 302 | } 303 | } 304 | 305 | func TestGetNodeIncludeLabel(t *testing.T) { 306 | t.Parallel() 307 | 308 | tc := []struct { 309 | name string 310 | content string 311 | expected string 312 | pos hcl.Pos 313 | }{ 314 | { 315 | name: "not an include block", 316 | content: `inputs = { 317 | foo = "bar" 318 | }`, 319 | pos: hcl.Pos{Line: 1, Column: 1}, 320 | expected: "", 321 | }, 322 | { 323 | name: "include block beginning of path", 324 | content: `include "root" { 325 | path = "root.hcl" 326 | }`, 327 | pos: hcl.Pos{Line: 2, Column: 2}, 328 | expected: "root", 329 | }, 330 | { 331 | name: "include block end of path", 332 | content: `include "root" { 333 | path = "root.hcl" 334 | }`, 335 | pos: hcl.Pos{Line: 2, Column: 18}, 336 | expected: "root", 337 | }, 338 | } 339 | 340 | for _, tt := range tc { 341 | t.Run(tt.name, func(t *testing.T) { 342 | t.Parallel() 343 | 344 | indexed, err := ast.ParseHCLFile("test.hcl", []byte(tt.content)) 345 | require.NoError(t, err) 346 | 347 | require.NotNil(t, indexed) 348 | 349 | node := indexed.FindNodeAt(tt.pos) 350 | 351 | path, ok := ast.GetNodeIncludeLabel(node) 352 | if tt.expected == "" { 353 | assert.False(t, ok) 354 | return 355 | } 356 | 357 | assert.True(t, ok) 358 | assert.Equal(t, tt.expected, path) 359 | }) 360 | } 361 | } 362 | 363 | func TestGetNodeDependencyLabel(t *testing.T) { 364 | t.Parallel() 365 | 366 | tc := []struct { 367 | name string 368 | content string 369 | expected string 370 | pos hcl.Pos 371 | }{ 372 | { 373 | name: "not a dependency block", 374 | content: `inputs = { 375 | foo = "bar" 376 | }`, 377 | pos: hcl.Pos{Line: 1, Column: 1}, 378 | expected: "", 379 | }, 380 | { 381 | name: "dependency block beginning of path", 382 | content: `dependency "vpc" { 383 | config_path = "../vpc" 384 | }`, 385 | pos: hcl.Pos{Line: 2, Column: 2}, 386 | expected: "vpc", 387 | }, 388 | { 389 | name: "dependency block end of path", 390 | content: `dependency "vpc" { 391 | config_path = "../vpc" 392 | }`, 393 | pos: hcl.Pos{Line: 2, Column: 18}, 394 | expected: "vpc", 395 | }, 396 | { 397 | name: "dependency block wrong attribute", 398 | content: `dependency "vpc" { 399 | other_field = "../vpc" 400 | }`, 401 | pos: hcl.Pos{Line: 2, Column: 18}, 402 | expected: "", 403 | }, 404 | } 405 | 406 | for _, tt := range tc { 407 | t.Run(tt.name, func(t *testing.T) { 408 | t.Parallel() 409 | 410 | indexed, err := ast.ParseHCLFile("test.hcl", []byte(tt.content)) 411 | require.NoError(t, err) 412 | 413 | require.NotNil(t, indexed) 414 | 415 | node := indexed.FindNodeAt(tt.pos) 416 | 417 | path, ok := ast.GetNodeDependencyLabel(node) 418 | if tt.expected == "" { 419 | assert.False(t, ok) 420 | return 421 | } 422 | 423 | assert.True(t, ok) 424 | assert.Equal(t, tt.expected, path) 425 | }) 426 | } 427 | } 428 | 429 | func TestFindFirstParentMatch(t *testing.T) { 430 | t.Parallel() 431 | 432 | tc := []struct { 433 | matcher func(node *ast.IndexedNode) bool 434 | name string 435 | content string 436 | pos hcl.Pos 437 | expected bool 438 | }{ 439 | { 440 | name: "find attribute parent", 441 | content: `locals { 442 | foo = "bar" 443 | }`, 444 | pos: hcl.Pos{Line: 2, Column: 2}, 445 | matcher: ast.IsLocalsBlock, 446 | expected: true, 447 | }, 448 | { 449 | name: "no matching parent", 450 | content: `locals { 451 | foo = "bar" 452 | }`, 453 | pos: hcl.Pos{Line: 2, Column: 2}, 454 | matcher: ast.IsDependencyBlock, 455 | expected: false, 456 | }, 457 | } 458 | 459 | for _, tt := range tc { 460 | t.Run(tt.name, func(t *testing.T) { 461 | t.Parallel() 462 | 463 | indexed, err := ast.ParseHCLFile("test.hcl", []byte(tt.content)) 464 | require.NoError(t, err) 465 | 466 | require.NotNil(t, indexed) 467 | 468 | node := indexed.FindNodeAt(tt.pos) 469 | require.NotNil(t, node) 470 | 471 | match := ast.FindFirstParentMatch(node, tt.matcher) 472 | if !tt.expected { 473 | assert.Nil(t, match) 474 | } else { 475 | assert.NotNil(t, match) 476 | } 477 | }) 478 | } 479 | } 480 | func TestScope_Add(t *testing.T) { 481 | t.Parallel() 482 | 483 | // Test manual creation of scope with a block node 484 | t.Run("manually created scope with block", func(t *testing.T) { 485 | t.Parallel() 486 | 487 | // Create a scope 488 | scope := ast.Scope{} 489 | 490 | // Parse some HCL with a block 491 | content := `include "root" { 492 | path = "root.hcl" 493 | }` 494 | 495 | indexed, err := ast.ParseHCLFile("test.hcl", []byte(content)) 496 | require.NoError(t, err) 497 | require.NotNil(t, indexed) 498 | 499 | // Find the block node 500 | node := indexed.FindNodeAt(hcl.Pos{Line: 1, Column: 1}) 501 | require.NotNil(t, node) 502 | 503 | // Add to the scope directly 504 | if block, ok := node.Node.(*hclsyntax.Block); ok && len(block.Labels) > 0 { 505 | scope[block.Labels[0]] = node 506 | 507 | // Verify it was added with the correct key 508 | assert.Len(t, scope, 1) 509 | assert.Contains(t, scope, "root") 510 | } 511 | }) 512 | } 513 | 514 | // Test that include and local scopes are updated in parsing 515 | func TestIndexedAST_Scopes(t *testing.T) { 516 | t.Parallel() 517 | 518 | content := ` 519 | locals { 520 | region = "us-west-2" 521 | env = "dev" 522 | } 523 | 524 | include "root" { 525 | path = find_in_parent_folders() 526 | } 527 | ` 528 | indexed, err := ast.ParseHCLFile("test.hcl", []byte(content)) 529 | require.NoError(t, err) 530 | 531 | // Test locals scope 532 | locals := indexed.Locals 533 | assert.NotNil(t, locals, "Locals scope should not be nil") 534 | assert.Contains(t, locals, "region", "Should contain 'region' local") 535 | assert.Contains(t, locals, "env", "Should contain 'env' local") 536 | 537 | // Test includes scope existence 538 | includes := indexed.Includes 539 | assert.NotNil(t, includes, "Includes scope should not be nil") 540 | assert.Contains(t, includes, "root", "Should contain 'root' include") 541 | } 542 | -------------------------------------------------------------------------------- /internal/tg/state_test.go: -------------------------------------------------------------------------------- 1 | package tg_test 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/gruntwork-io/terragrunt/codegen" 10 | "github.com/gruntwork-io/terragrunt/config" 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | "go.lsp.dev/protocol" 14 | "go.lsp.dev/uri" 15 | 16 | "terragrunt-ls/internal/lsp" 17 | "terragrunt-ls/internal/testutils" 18 | "terragrunt-ls/internal/tg" 19 | ) 20 | 21 | func TestNewState(t *testing.T) { 22 | t.Parallel() 23 | 24 | state := tg.NewState() 25 | 26 | assert.NotNil(t, state.Configs) 27 | } 28 | 29 | func TestState_OpenDocument(t *testing.T) { 30 | t.Parallel() 31 | 32 | tmpDir := t.TempDir() 33 | 34 | _, err := testutils.CreateFile(tmpDir, "root.hcl", "") 35 | require.NoError(t, err) 36 | 37 | rootPath := filepath.Join(tmpDir, "root.hcl") 38 | 39 | // rootURI := uri.File(rootPath) 40 | 41 | unitDir := filepath.Join(tmpDir, "foo") 42 | 43 | err = os.MkdirAll(unitDir, 0755) 44 | require.NoError(t, err) 45 | 46 | // Create the URI for the unit file 47 | unitPath := filepath.Join(unitDir, "bar.hcl") 48 | 49 | unitURI := uri.File(unitPath) 50 | 51 | tc := []struct { 52 | expected *config.TerragruntConfig 53 | name string 54 | document string 55 | }{ 56 | { 57 | name: "empty document", 58 | document: "", 59 | expected: &config.TerragruntConfig{ 60 | GenerateConfigs: map[string]codegen.GenerateConfig{}, 61 | ProcessedIncludes: config.IncludeConfigsMap{}, 62 | }, 63 | }, 64 | { 65 | name: "simple locals", 66 | document: `locals { 67 | foo = "bar" 68 | }`, 69 | expected: &config.TerragruntConfig{ 70 | Locals: map[string]any{ 71 | "foo": "bar", 72 | }, 73 | GenerateConfigs: map[string]codegen.GenerateConfig{}, 74 | ProcessedIncludes: config.IncludeConfigsMap{}, 75 | FieldsMetadata: map[string]map[string]any{ 76 | "locals-foo": { 77 | "found_in_file": unitPath, 78 | }, 79 | }, 80 | }, 81 | }, 82 | { 83 | name: "multiple locals", 84 | document: `locals { 85 | foo = "bar" 86 | baz = "qux" 87 | }`, 88 | expected: &config.TerragruntConfig{ 89 | Locals: map[string]any{ 90 | "baz": "qux", 91 | "foo": "bar", 92 | }, 93 | GenerateConfigs: map[string]codegen.GenerateConfig{}, 94 | ProcessedIncludes: config.IncludeConfigsMap{}, 95 | FieldsMetadata: map[string]map[string]any{ 96 | "locals-baz": { 97 | "found_in_file": unitPath, 98 | }, 99 | "locals-foo": { 100 | "found_in_file": unitPath, 101 | }, 102 | }, 103 | }, 104 | }, 105 | { 106 | name: "root include", 107 | document: `include "root" { 108 | path = find_in_parent_folders("root.hcl") 109 | }`, 110 | expected: &config.TerragruntConfig{ 111 | GenerateConfigs: map[string]codegen.GenerateConfig{}, 112 | ProcessedIncludes: config.IncludeConfigsMap{ 113 | "root": { 114 | Name: "root", 115 | Path: rootPath, 116 | }, 117 | }, 118 | TerragruntDependencies: config.Dependencies{}, 119 | }, 120 | }, 121 | } 122 | 123 | for _, tt := range tc { 124 | t.Run(tt.name, func(t *testing.T) { 125 | t.Parallel() 126 | 127 | state := tg.NewState() 128 | 129 | l := testutils.NewTestLogger(t) 130 | 131 | diags := state.OpenDocument(l, unitURI, tt.document) 132 | require.Empty(t, diags) 133 | 134 | assert.Len(t, state.Configs, 1) 135 | 136 | assert.Equal(t, tt.expected, state.Configs[unitPath].Cfg) 137 | }) 138 | } 139 | } 140 | 141 | func TestState_UpdateDocument(t *testing.T) { 142 | t.Parallel() 143 | 144 | tc := []struct { 145 | expected map[string]any 146 | expectedUpdated map[string]any 147 | name string 148 | document string 149 | updated string 150 | }{ 151 | { 152 | name: "empty document", 153 | document: "", 154 | }, 155 | { 156 | name: "simple locals", 157 | document: `locals { 158 | foo = "bar" 159 | }`, 160 | expected: map[string]any{ 161 | "foo": "bar", 162 | }, 163 | updated: `locals { 164 | foo = "baz" 165 | }`, 166 | expectedUpdated: map[string]any{ 167 | "foo": "baz", 168 | }, 169 | }, 170 | { 171 | name: "multiple locals", 172 | document: `locals { 173 | foo = "bar" 174 | baz = "qux" 175 | }`, 176 | expected: map[string]any{ 177 | "foo": "bar", 178 | "baz": "qux", 179 | }, 180 | updated: `locals { 181 | foo = "baz" 182 | baz = "qux" 183 | }`, 184 | expectedUpdated: map[string]any{ 185 | "foo": "baz", 186 | "baz": "qux", 187 | }, 188 | }, 189 | } 190 | 191 | for _, tt := range tc { 192 | t.Run(tt.name, func(t *testing.T) { 193 | t.Parallel() 194 | 195 | state := tg.NewState() 196 | 197 | l := testutils.NewTestLogger(t) 198 | 199 | diags := state.OpenDocument(l, "file:///foo/bar.hcl", tt.document) 200 | assert.Empty(t, diags) 201 | 202 | require.Len(t, state.Configs, 1) 203 | 204 | if len(tt.expected) != 0 { 205 | assert.Equal(t, tt.expected, state.Configs["/foo/bar.hcl"].Cfg.Locals) 206 | } 207 | 208 | diags = state.UpdateDocument(l, "file:///foo/bar.hcl", tt.updated) 209 | assert.Empty(t, diags) 210 | 211 | assert.Len(t, state.Configs, 1) 212 | 213 | if len(tt.expectedUpdated) != 0 { 214 | assert.Equal(t, tt.expectedUpdated, state.Configs["/foo/bar.hcl"].Cfg.Locals) 215 | } 216 | }) 217 | } 218 | } 219 | 220 | func TestState_Hover(t *testing.T) { 221 | t.Parallel() 222 | 223 | tc := []struct { 224 | expected lsp.HoverResponse 225 | name string 226 | document string 227 | position protocol.Position 228 | }{ 229 | { 230 | name: "simple locals", 231 | document: `locals { 232 | foo = "bar" 233 | bar = local.foo 234 | }`, 235 | position: protocol.Position{ 236 | Line: 2, 237 | Character: 15, 238 | }, 239 | expected: lsp.HoverResponse{ 240 | Response: lsp.Response{ 241 | RPC: "2.0", 242 | ID: testutils.PointerOfInt(1), 243 | }, 244 | Result: lsp.HoverResult{ 245 | Contents: protocol.MarkupContent{ 246 | Kind: protocol.Markdown, 247 | Value: "```hcl\nfoo = \"bar\"\n```", 248 | }, 249 | }, 250 | }, 251 | }, 252 | { 253 | name: "interpolated locals", 254 | document: `locals { 255 | foo = "bar" 256 | baz = "${local.foo}-baz" 257 | qux = local.baz 258 | }`, 259 | position: protocol.Position{ 260 | Line: 3, 261 | Character: 15, 262 | }, 263 | expected: lsp.HoverResponse{ 264 | Response: lsp.Response{ 265 | RPC: "2.0", 266 | ID: testutils.PointerOfInt(1), 267 | }, 268 | Result: lsp.HoverResult{ 269 | Contents: protocol.MarkupContent{ 270 | Kind: protocol.Markdown, 271 | Value: "```hcl\nbaz = \"bar-baz\"\n```", 272 | }, 273 | }, 274 | }, 275 | }, 276 | } 277 | 278 | for _, tt := range tc { 279 | t.Run(tt.name, func(t *testing.T) { 280 | t.Parallel() 281 | 282 | state := tg.NewState() 283 | 284 | l := testutils.NewTestLogger(t) 285 | 286 | diags := state.OpenDocument(l, "file:///foo/bar.hcl", tt.document) 287 | assert.Empty(t, diags) 288 | 289 | require.Len(t, state.Configs, 1) 290 | 291 | hover := state.Hover(l, 1, "file:///foo/bar.hcl", tt.position) 292 | assert.Equal(t, tt.expected, hover) 293 | }) 294 | } 295 | } 296 | 297 | func TestState_Definition(t *testing.T) { 298 | t.Parallel() 299 | 300 | tmpDir := t.TempDir() 301 | 302 | _, err := testutils.CreateFile(tmpDir, "root.hcl", "") 303 | require.NoError(t, err) 304 | 305 | rootURI := uri.File(filepath.Join(tmpDir, "root.hcl")) 306 | 307 | // Create a vpc directory 308 | vpcDir := filepath.Join(tmpDir, "vpc") 309 | err = os.MkdirAll(vpcDir, 0755) 310 | require.NoError(t, err) 311 | 312 | // Create a terragrunt.hcl file in the vpc directory 313 | _, err = testutils.CreateFile(vpcDir, "terragrunt.hcl", "") 314 | require.NoError(t, err) 315 | 316 | vpcURI := uri.File(filepath.Join(vpcDir, "terragrunt.hcl")) 317 | 318 | unitDir := filepath.Join(tmpDir, "foo") 319 | 320 | err = os.MkdirAll(unitDir, 0755) 321 | require.NoError(t, err) 322 | 323 | // Create the URI for the unit file 324 | unitPath := filepath.Join(unitDir, "bar.hcl") 325 | 326 | unitURI := uri.File(unitPath) 327 | 328 | tc := []struct { 329 | name string 330 | document string 331 | expected lsp.DefinitionResponse 332 | position protocol.Position 333 | }{ 334 | { 335 | name: "nothing to jump to", 336 | document: `locals { 337 | foo = "bar" 338 | bar = local.foo 339 | }`, 340 | position: protocol.Position{ 341 | Line: 0, 342 | Character: 0, 343 | }, 344 | expected: lsp.DefinitionResponse{ 345 | Response: lsp.Response{ 346 | RPC: "2.0", 347 | ID: testutils.PointerOfInt(1), 348 | }, 349 | Result: protocol.Location{ 350 | URI: unitURI, 351 | Range: protocol.Range{ 352 | Start: protocol.Position{ 353 | Line: 0, 354 | Character: 0, 355 | }, 356 | End: protocol.Position{ 357 | Line: 0, 358 | Character: 0, 359 | }, 360 | }, 361 | }, 362 | }, 363 | }, 364 | { 365 | name: "go to root include", 366 | document: `include "root" { 367 | path = find_in_parent_folders("root.hcl") 368 | }`, 369 | position: protocol.Position{ 370 | Line: 1, 371 | Character: 8, 372 | }, 373 | expected: lsp.DefinitionResponse{ 374 | Response: lsp.Response{ 375 | RPC: "2.0", 376 | ID: testutils.PointerOfInt(1), 377 | }, 378 | Result: protocol.Location{ 379 | URI: rootURI, 380 | Range: protocol.Range{ 381 | Start: protocol.Position{ 382 | Line: 0, 383 | Character: 0, 384 | }, 385 | End: protocol.Position{ 386 | Line: 0, 387 | Character: 0, 388 | }, 389 | }, 390 | }, 391 | }, 392 | }, 393 | { 394 | name: "go to dependency", 395 | document: `dependency "vpc" { 396 | config_path = "../vpc" 397 | }`, 398 | position: protocol.Position{ 399 | Line: 1, 400 | Character: 18, 401 | }, 402 | expected: lsp.DefinitionResponse{ 403 | Response: lsp.Response{ 404 | RPC: "2.0", 405 | ID: testutils.PointerOfInt(1), 406 | }, 407 | Result: protocol.Location{ 408 | URI: vpcURI, 409 | Range: protocol.Range{ 410 | Start: protocol.Position{ 411 | Line: 0, 412 | Character: 0, 413 | }, 414 | End: protocol.Position{ 415 | Line: 0, 416 | Character: 0, 417 | }, 418 | }, 419 | }, 420 | }, 421 | }, 422 | } 423 | 424 | for _, tt := range tc { 425 | t.Run(tt.name, func(t *testing.T) { 426 | t.Parallel() 427 | 428 | state := tg.NewState() 429 | 430 | l := testutils.NewTestLogger(t) 431 | 432 | diags := state.OpenDocument(l, unitURI, tt.document) 433 | assert.Empty(t, diags) 434 | 435 | require.Len(t, state.Configs, 1) 436 | 437 | definition := state.Definition(l, 1, unitURI, tt.position) 438 | assert.Equal(t, tt.expected, definition) 439 | }) 440 | } 441 | } 442 | 443 | func TestState_TextDocumentCompletion(t *testing.T) { 444 | t.Parallel() 445 | 446 | tc := []struct { 447 | name string 448 | initial string 449 | document string 450 | expected lsp.CompletionResponse 451 | position protocol.Position 452 | expectDiagnostics bool 453 | }{ 454 | { 455 | name: "complete dep", 456 | document: "dep", 457 | position: protocol.Position{ 458 | Line: 0, 459 | Character: 3, 460 | }, 461 | expectDiagnostics: true, 462 | expected: lsp.CompletionResponse{ 463 | Response: lsp.Response{ 464 | RPC: "2.0", 465 | ID: testutils.PointerOfInt(1), 466 | }, 467 | Result: []protocol.CompletionItem{ 468 | { 469 | Label: "dependency", 470 | Documentation: protocol.MarkupContent{ 471 | Kind: protocol.Markdown, 472 | Value: "# dependency\nThe dependency block is used to configure unit dependencies.\nEach dependency block exposes outputs of the dependency unit as variables you can reference in dependent unit configuration.", 473 | }, 474 | Kind: protocol.CompletionItemKindClass, 475 | InsertTextFormat: protocol.InsertTextFormatSnippet, 476 | TextEdit: &protocol.TextEdit{ 477 | Range: protocol.Range{ 478 | Start: protocol.Position{Line: 0, Character: 0}, 479 | End: protocol.Position{Line: 0, Character: 3}, 480 | }, 481 | NewText: `dependency "${1}" { 482 | config_path = "${2}" 483 | }`, 484 | }, 485 | }, 486 | { 487 | Label: "dependencies", 488 | Documentation: protocol.MarkupContent{ 489 | Kind: protocol.Markdown, 490 | Value: "# dependencies\nThe dependencies block is used to enumerate all the Terragrunt units that need to be applied before this unit.", 491 | }, 492 | Kind: protocol.CompletionItemKindClass, 493 | InsertTextFormat: protocol.InsertTextFormatSnippet, 494 | TextEdit: &protocol.TextEdit{ 495 | Range: protocol.Range{ 496 | Start: protocol.Position{Line: 0, Character: 0}, 497 | End: protocol.Position{Line: 0, Character: 3}, 498 | }, 499 | NewText: `dependencies { 500 | paths = ["${1}"] 501 | }`, 502 | }, 503 | }, 504 | }, 505 | }, 506 | }, 507 | } 508 | 509 | for _, tt := range tc { 510 | t.Run(tt.name, func(t *testing.T) { 511 | t.Parallel() 512 | 513 | state := tg.NewState() 514 | l := testutils.NewTestLogger(t) 515 | 516 | diags := state.OpenDocument(l, "file:///test.hcl", tt.document) 517 | if tt.expectDiagnostics { 518 | require.NotEmpty(t, diags) 519 | } else { 520 | require.Empty(t, diags) 521 | } 522 | 523 | completion := state.TextDocumentCompletion(l, 1, "file:///test.hcl", tt.position) 524 | assert.Equal(t, tt.expected, completion) 525 | }) 526 | } 527 | } 528 | 529 | func TestState_TextDocumentFormatting(t *testing.T) { 530 | t.Parallel() 531 | 532 | tc := []struct { 533 | name string 534 | document string 535 | expected string 536 | }{ 537 | { 538 | name: "empty document", 539 | document: "", 540 | expected: "", 541 | }, 542 | { 543 | name: "unformatted locals", 544 | document: `locals{ 545 | foo="bar" 546 | bar= "baz" 547 | }`, 548 | expected: `locals { 549 | foo = "bar" 550 | bar = "baz" 551 | }`, 552 | }, 553 | { 554 | name: "already formatted locals", 555 | document: `locals { 556 | foo = "bar" 557 | bar = "baz" 558 | }`, 559 | expected: `locals { 560 | foo = "bar" 561 | bar = "baz" 562 | }`, 563 | }, 564 | } 565 | 566 | for _, tt := range tc { 567 | t.Run(tt.name, func(t *testing.T) { 568 | t.Parallel() 569 | 570 | state := tg.NewState() 571 | l := testutils.NewTestLogger(t) 572 | 573 | // First open the document to populate the state 574 | diags := state.OpenDocument(l, "file:///test.hcl", tt.document) 575 | require.Empty(t, diags) 576 | 577 | // Request formatting 578 | response := state.TextDocumentFormatting(l, 1, "file:///test.hcl") 579 | 580 | // Verify the formatting result 581 | require.Len(t, response.Result, 1) 582 | assert.Equal(t, tt.expected, response.Result[0].NewText) 583 | 584 | assert.Equal(t, uint32(0), response.Result[0].Range.Start.Line) 585 | assert.Equal(t, uint32(0), response.Result[0].Range.Start.Character) 586 | 587 | lines := strings.Split(tt.document, "\n") 588 | assert.Equal(t, uint32(len(lines)-1), response.Result[0].Range.End.Line) 589 | assert.Equal(t, uint32(len(lines[len(lines)-1])), response.Result[0].Range.End.Character) 590 | }) 591 | } 592 | } 593 | -------------------------------------------------------------------------------- /internal/tg/completion/completion.go: -------------------------------------------------------------------------------- 1 | // Package completion provides the logic for providing completions to the LSP client. 2 | package completion 3 | 4 | import ( 5 | "strings" 6 | "terragrunt-ls/internal/logger" 7 | "terragrunt-ls/internal/tg/store" 8 | "terragrunt-ls/internal/tg/text" 9 | 10 | "go.lsp.dev/protocol" 11 | ) 12 | 13 | func GetCompletions(l logger.Logger, store store.Store, position protocol.Position) []protocol.CompletionItem { 14 | word := text.GetCursorWord(store.Document, position) 15 | completions := []protocol.CompletionItem{} 16 | 17 | for _, completion := range newCompletions(position) { 18 | if strings.HasPrefix(completion.Label, word) { 19 | completions = append(completions, completion) 20 | } 21 | } 22 | 23 | return completions 24 | } 25 | 26 | // newCompletions returns a list of completions for the given position. 27 | // 28 | // TODO: Add detection via the AST index to determine 29 | // whether the cursor is in the context of a block or expression. 30 | func newCompletions(position protocol.Position) []protocol.CompletionItem { 31 | return []protocol.CompletionItem{ 32 | { 33 | Label: "dependency", 34 | Documentation: protocol.MarkupContent{ 35 | Kind: protocol.Markdown, 36 | Value: `# dependency 37 | The dependency block is used to configure unit dependencies. 38 | Each dependency block exposes outputs of the dependency unit as variables you can reference in dependent unit configuration.`, 39 | }, 40 | Kind: protocol.CompletionItemKindClass, 41 | InsertTextFormat: protocol.InsertTextFormatSnippet, 42 | TextEdit: &protocol.TextEdit{ 43 | Range: protocol.Range{ 44 | Start: protocol.Position{Line: position.Line, Character: 0}, 45 | End: protocol.Position{Line: position.Line, Character: position.Character}, 46 | }, 47 | NewText: `dependency "${1}" { 48 | config_path = "${2}" 49 | }`, 50 | }, 51 | }, 52 | { 53 | Label: "inputs", 54 | Documentation: protocol.MarkupContent{ 55 | Kind: protocol.Markdown, 56 | Value: `# inputs 57 | The inputs attribute is a map that is used to specify the input variables and their values to pass in to OpenTofu/Terraform.`, 58 | }, 59 | Kind: protocol.CompletionItemKindField, 60 | InsertTextFormat: protocol.InsertTextFormatSnippet, 61 | TextEdit: &protocol.TextEdit{ 62 | Range: protocol.Range{ 63 | Start: protocol.Position{Line: position.Line, Character: 0}, 64 | End: protocol.Position{Line: position.Line, Character: position.Character}, 65 | }, 66 | NewText: `inputs = { 67 | ${1} = ${2} 68 | }`, 69 | }, 70 | }, 71 | { 72 | Label: "locals", 73 | Documentation: protocol.MarkupContent{ 74 | Kind: protocol.Markdown, 75 | Value: `# locals 76 | The locals block is used to define aliases for Terragrunt expressions that can be referenced elsewhere in configuration.`, 77 | }, 78 | Kind: protocol.CompletionItemKindClass, 79 | InsertTextFormat: protocol.InsertTextFormatSnippet, 80 | TextEdit: &protocol.TextEdit{ 81 | Range: protocol.Range{ 82 | Start: protocol.Position{Line: position.Line, Character: 0}, 83 | End: protocol.Position{Line: position.Line, Character: position.Character}, 84 | }, 85 | NewText: `locals { 86 | ${1} = ${2} 87 | }`, 88 | }, 89 | }, 90 | { 91 | Label: "feature", 92 | Documentation: protocol.MarkupContent{ 93 | Kind: protocol.Markdown, 94 | Value: `# feature 95 | The feature block is used to configure feature flags in HCL for a specific Terragrunt unit.`, 96 | }, 97 | Kind: protocol.CompletionItemKindClass, 98 | InsertTextFormat: protocol.InsertTextFormatSnippet, 99 | TextEdit: &protocol.TextEdit{ 100 | Range: protocol.Range{ 101 | Start: protocol.Position{Line: position.Line, Character: 0}, 102 | End: protocol.Position{Line: position.Line, Character: position.Character}, 103 | }, 104 | NewText: `feature "${1}" { 105 | default = ${2} 106 | }`, 107 | }, 108 | }, 109 | { 110 | Label: "terraform", 111 | Documentation: protocol.MarkupContent{ 112 | Kind: protocol.Markdown, 113 | Value: `# terraform 114 | The terraform block is used to configure how Terragrunt will interact with OpenTofu/Terraform.`, 115 | }, 116 | Kind: protocol.CompletionItemKindClass, 117 | InsertTextFormat: protocol.InsertTextFormatSnippet, 118 | TextEdit: &protocol.TextEdit{ 119 | Range: protocol.Range{ 120 | Start: protocol.Position{Line: position.Line, Character: 0}, 121 | End: protocol.Position{Line: position.Line, Character: position.Character}, 122 | }, 123 | NewText: `terraform { 124 | source = "${1}" 125 | }`, 126 | }, 127 | }, 128 | { 129 | Label: "remote_state", 130 | Documentation: protocol.MarkupContent{ 131 | Kind: protocol.Markdown, 132 | Value: `# remote_state 133 | The remote_state block is used to configure how Terragrunt will set up remote state configuration.`, 134 | }, 135 | Kind: protocol.CompletionItemKindClass, 136 | InsertTextFormat: protocol.InsertTextFormatSnippet, 137 | TextEdit: &protocol.TextEdit{ 138 | Range: protocol.Range{ 139 | Start: protocol.Position{Line: position.Line, Character: 0}, 140 | End: protocol.Position{Line: position.Line, Character: position.Character}, 141 | }, 142 | NewText: `remote_state { 143 | backend = "${1:s3}" 144 | config = { 145 | bucket = "${2}" 146 | key = "${3}" 147 | region = "${4}" 148 | } 149 | }`, 150 | }, 151 | }, 152 | { 153 | Label: "include", 154 | Documentation: protocol.MarkupContent{ 155 | Kind: protocol.Markdown, 156 | Value: `# include 157 | The include block is used to specify the inclusion of partial Terragrunt configuration.`, 158 | }, 159 | Kind: protocol.CompletionItemKindClass, 160 | InsertTextFormat: protocol.InsertTextFormatSnippet, 161 | TextEdit: &protocol.TextEdit{ 162 | Range: protocol.Range{ 163 | Start: protocol.Position{Line: position.Line, Character: 0}, 164 | End: protocol.Position{Line: position.Line, Character: position.Character}, 165 | }, 166 | NewText: `include "${1:root}" { 167 | path = ${2:find_in_parent_folders("root.hcl")} 168 | }`, 169 | }, 170 | }, 171 | { 172 | Label: "dependencies", 173 | Documentation: protocol.MarkupContent{ 174 | Kind: protocol.Markdown, 175 | Value: `# dependencies 176 | The dependencies block is used to enumerate all the Terragrunt units that need to be applied before this unit.`, 177 | }, 178 | Kind: protocol.CompletionItemKindClass, 179 | InsertTextFormat: protocol.InsertTextFormatSnippet, 180 | TextEdit: &protocol.TextEdit{ 181 | Range: protocol.Range{ 182 | Start: protocol.Position{Line: position.Line, Character: 0}, 183 | End: protocol.Position{Line: position.Line, Character: position.Character}, 184 | }, 185 | NewText: `dependencies { 186 | paths = ["${1}"] 187 | }`, 188 | }, 189 | }, 190 | { 191 | Label: "generate", 192 | Documentation: protocol.MarkupContent{ 193 | Kind: protocol.Markdown, 194 | Value: `# generate 195 | The generate block can be used to arbitrarily generate a file in the terragrunt working directory.`, 196 | }, 197 | Kind: protocol.CompletionItemKindClass, 198 | InsertTextFormat: protocol.InsertTextFormatSnippet, 199 | TextEdit: &protocol.TextEdit{ 200 | Range: protocol.Range{ 201 | Start: protocol.Position{Line: position.Line, Character: 0}, 202 | End: protocol.Position{Line: position.Line, Character: position.Character}, 203 | }, 204 | NewText: `generate "provider" { 205 | path = "${1:provider.tf}" 206 | if_exists = "${2:overwrite}" 207 | contents = <