diff --git a/internal/langserver/handlers/document_link.go b/internal/langserver/handlers/document_link.go index 967efe39..0bb2ba0e 100644 --- a/internal/langserver/handlers/document_link.go +++ b/internal/langserver/handlers/document_link.go @@ -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" ) @@ -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 +} diff --git a/internal/langserver/handlers/document_link_test.go b/internal/langserver/handlers/document_link_test.go index db6ebb8c..23517469 100644 --- a/internal/langserver/handlers/document_link_test.go +++ b/internal/langserver/handlers/document_link_test.go @@ -8,6 +8,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "testing" "github.com/hashicorp/go-version" @@ -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") + } +}