Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions internal/langserver/handlers/document_link.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,14 @@ package handlers

import (
"context"
"fmt"
"net/url"
"strings"

"github.com/hashicorp/hcl-lang/lang"
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/hclsyntax"
"github.com/hashicorp/terraform-ls/internal/document"
ilsp "github.com/hashicorp/terraform-ls/internal/lsp"
lsp "github.com/hashicorp/terraform-ls/internal/protocol"
)
Expand Down Expand Up @@ -42,5 +49,82 @@ func (svc *service) TextDocumentLink(ctx context.Context, params lsp.DocumentLin
return nil, err
}

// Add resource documentation links
resourceLinks, err := svc.generateResourceLinks(dh)
if err != nil {
// Don't fail the entire request if resource link generation fails
// Just continue with existing provider links
} else {
links = append(links, resourceLinks...)
}

return ilsp.Links(links, cc.TextDocument.DocumentLink), nil
}

// generateResourceLinks parses the Terraform file to find resource blocks
// and generates documentation links for them
func (svc *service) generateResourceLinks(dh document.Handle) ([]lang.Link, error) {
doc, err := svc.stateStore.DocumentStore.GetDocument(dh)
if err != nil {
return nil, err
}

f, diags := hclsyntax.ParseConfig(doc.Text, doc.Filename, hcl.InitialPos)
if diags.HasErrors() {
return nil, fmt.Errorf("failed to parse HCL: %s", diags.Error())
}

var links []lang.Link

for _, block := range f.Body.(*hclsyntax.Body).Blocks {
var urlPath string
var blockTypeDescription string

if block.Type == "resource" && len(block.Labels) >= 1 {
urlPath = "r"
blockTypeDescription = "resource"
} else if block.Type == "data" && len(block.Labels) >= 1 {
urlPath = "d"
blockTypeDescription = "data source"
} else {
continue // Skip other block types
}

resourceType := block.Labels[0]

// Extract provider name and resource name from resource type
// e.g., "azurerm_resource_group" -> provider: "azurerm", resource: "resource_group"
parts := strings.SplitN(resourceType, "_", 2)
if len(parts) != 2 {
continue // Skip malformed resource types
}

providerName := parts[0]
resourceName := parts[1]

// Generate documentation URL
docURL := fmt.Sprintf("https://www.terraform.io/docs/providers/%s/%s/%s.html", providerName, urlPath, resourceName)

// Add UTM parameters similar to existing provider links
u, err := url.Parse(docURL)
if err != nil {
continue
}

q := u.Query()
q.Set("utm_source", "terraform-ls")
q.Set("utm_content", "documentLink")
u.RawQuery = q.Encode()

// Create link for the resource type (first label)
if len(block.LabelRanges) > 0 {
links = append(links, lang.Link{
URI: u.String(),
Tooltip: fmt.Sprintf("View documentation for %s %s", blockTypeDescription, resourceType),
Range: block.LabelRanges[0],
})
}
}

return links, nil
}
175 changes: 175 additions & 0 deletions internal/langserver/handlers/document_link_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"

"github.com/hashicorp/go-version"
Expand Down Expand Up @@ -132,3 +133,177 @@ func TestDocumentLink_withValidData(t *testing.T) {
]
}`)
}

func TestDocumentLink_withResourceBlocks(t *testing.T) {
tmpDir := TempDir(t)
InitPluginCache(t, tmpDir.Path())

terraformConfig := `provider "azurerm" {
features {}
}

resource "azurerm_resource_group" "example" {
name = "example-resources"
location = "West Europe"
}

resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1d0"
instance_type = "t2.micro"
}

data "azurerm_client_config" "current" {}

data "aws_ami" "ubuntu" {
most_recent = true
owners = ["099720109477"]
}
`

err := os.WriteFile(filepath.Join(tmpDir.Path(), "main.tf"), []byte(terraformConfig), 0o755)
if err != nil {
t.Fatal(err)
}

var testSchema tfjson.ProviderSchemas
err = json.Unmarshal([]byte(testModuleSchemaOutput), &testSchema)
if err != nil {
t.Fatal(err)
}

ss, err := state.NewStateStore()
if err != nil {
t.Fatal(err)
}
wc := walker.NewWalkerCollector()

ls := langserver.NewLangServerMock(t, NewMockSession(&MockSessionInput{
TerraformCalls: &exec.TerraformMockCalls{
PerWorkDir: map[string][]*mock.Call{
tmpDir.Path(): {
{
Method: "Version",
Repeatability: 1,
Arguments: []interface{}{
mock.AnythingOfType(""),
},
ReturnArguments: []interface{}{
version.Must(version.NewVersion("0.12.1")),
nil,
nil,
},
},
{
Method: "GetExecPath",
Repeatability: 1,
ReturnArguments: []interface{}{
"",
},
},
{
Method: "ProviderSchemas",
Repeatability: 1,
Arguments: []interface{}{
mock.AnythingOfType(""),
},
ReturnArguments: []interface{}{
&testSchema,
nil,
},
},
},
},
},
StateStore: ss,
WalkerCollector: wc,
}))
stop := ls.Start(t)
defer stop()

ls.Call(t, &langserver.CallRequest{
Method: "initialize",
ReqParams: fmt.Sprintf(`{
"capabilities": {},
"rootUri": %q,
"processId": 12345
}`, tmpDir.URI)})
waitForWalkerPath(t, ss, wc, tmpDir)
ls.Notify(t, &langserver.CallRequest{
Method: "initialized",
ReqParams: "{}",
})
ls.Call(t, &langserver.CallRequest{
Method: "textDocument/didOpen",
ReqParams: fmt.Sprintf(`{
"textDocument": {
"version": 0,
"languageId": "terraform",
"text": %q,
"uri": "%s/main.tf"
}
}`, terraformConfig, tmpDir.URI)})
waitForAllJobs(t, ss)

// Test that resource links are included in the response
resp := ls.Call(t, &langserver.CallRequest{
Method: "textDocument/documentLink",
ReqParams: fmt.Sprintf(`{
"textDocument": {
"uri": "%s/main.tf"
}
}`, tmpDir.URI)})

// Parse the response to check that resource links are present
var links []struct {
Range struct {
Start struct {
Line int `json:"line"`
Character int `json:"character"`
} `json:"start"`
End struct {
Line int `json:"line"`
Character int `json:"character"`
} `json:"end"`
} `json:"range"`
Target string `json:"target"`
}

err = json.Unmarshal(resp.Result, &links)
if err != nil {
t.Fatalf("Failed to parse response: %v", err)
}

// Check that we have resource and data source links
foundAzureResourceLink := false
foundAWSResourceLink := false
foundAzureDataLink := false
foundAWSDataLink := false

for _, link := range links {
if strings.Contains(link.Target, "azurerm/r/resource_group.html") {
foundAzureResourceLink = true
}
if strings.Contains(link.Target, "aws/r/instance.html") {
foundAWSResourceLink = true
}
if strings.Contains(link.Target, "azurerm/d/client_config.html") {
foundAzureDataLink = true
}
if strings.Contains(link.Target, "aws/d/ami.html") {
foundAWSDataLink = true
}
}

if !foundAzureResourceLink {
t.Error("Expected to find Azure resource group documentation link")
}
if !foundAWSResourceLink {
t.Error("Expected to find AWS instance documentation link")
}
if !foundAzureDataLink {
t.Error("Expected to find Azure client config data source documentation link")
}
if !foundAWSDataLink {
t.Error("Expected to find AWS AMI data source documentation link")
}
}