diff --git a/tools/diff-processor/cmd/detect_missing_docs.go b/tools/diff-processor/cmd/detect_missing_docs.go
new file mode 100644
index 000000000000..60a5a427dac6
--- /dev/null
+++ b/tools/diff-processor/cmd/detect_missing_docs.go
@@ -0,0 +1,79 @@
+package cmd
+
+import (
+ newProvider "google/provider/new/google/provider"
+ oldProvider "google/provider/old/google/provider"
+ "slices"
+ "sort"
+
+ "encoding/json"
+ "fmt"
+ "io"
+ "os"
+
+ "github.com/GoogleCloudPlatform/magic-modules/tools/diff-processor/detector"
+ "github.com/GoogleCloudPlatform/magic-modules/tools/diff-processor/diff"
+ "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
+ "github.com/spf13/cobra"
+ "golang.org/x/exp/maps"
+)
+
+const detectMissingDocDesc = `Compute list of fields missing documents`
+
+type MissingDocsInfo struct {
+ Name string
+ FilePath string
+ Fields []string
+}
+
+type detectMissingDocsOptions struct {
+ rootOptions *rootOptions
+ computeSchemaDiff func() diff.SchemaDiff
+ newResourceSchema map[string]*schema.Resource
+ stdout io.Writer
+}
+
+func newDetectMissingDocsCmd(rootOptions *rootOptions) *cobra.Command {
+ o := &detectMissingDocsOptions{
+ rootOptions: rootOptions,
+ computeSchemaDiff: func() diff.SchemaDiff {
+ return diff.ComputeSchemaDiff(oldProvider.ResourceMap(), newProvider.ResourceMap())
+ },
+ stdout: os.Stdout,
+ }
+ cmd := &cobra.Command{
+ Use: "detect-missing-docs",
+ Short: detectMissingDocDesc,
+ Long: detectMissingDocDesc,
+ Args: cobra.ExactArgs(1),
+ RunE: func(c *cobra.Command, args []string) error {
+ return o.run(args)
+ },
+ }
+ return cmd
+}
+func (o *detectMissingDocsOptions) run(args []string) error {
+ schemaDiff := o.computeSchemaDiff()
+ detectedResources, err := detector.DetectMissingDocs(schemaDiff, args[0], o.newResourceSchema)
+ if err != nil {
+ return err
+ }
+ resources := maps.Keys(detectedResources)
+ slices.Sort(resources)
+ info := []MissingDocsInfo{}
+ for _, r := range resources {
+ details := detectedResources[r]
+ sort.Strings(details.Fields)
+ info = append(info, MissingDocsInfo{
+ Name: r,
+ FilePath: details.FilePath,
+ Fields: details.Fields,
+ })
+ }
+
+ if err := json.NewEncoder(o.stdout).Encode(info); err != nil {
+ return fmt.Errorf("error encoding json: %w", err)
+ }
+
+ return nil
+}
diff --git a/tools/diff-processor/cmd/detect_missing_docs_test.go b/tools/diff-processor/cmd/detect_missing_docs_test.go
new file mode 100644
index 000000000000..e385488c04ba
--- /dev/null
+++ b/tools/diff-processor/cmd/detect_missing_docs_test.go
@@ -0,0 +1,91 @@
+package cmd
+
+import (
+ "bytes"
+ "encoding/json"
+ "testing"
+
+ "github.com/GoogleCloudPlatform/magic-modules/tools/diff-processor/diff"
+ "github.com/google/go-cmp/cmp"
+
+ "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
+)
+
+func TestDetectMissingDocs(t *testing.T) {
+ cases := []struct {
+ name string
+ oldResourceMap map[string]*schema.Resource
+ newResourceMap map[string]*schema.Resource
+ want []MissingDocsInfo
+ }{
+ {
+ name: "no new fields",
+ oldResourceMap: map[string]*schema.Resource{
+ "google_x": {
+ Schema: map[string]*schema.Schema{
+ "field-a": {Description: "beep", Computed: true, Optional: true},
+ "field-b": {Description: "beep", Computed: true},
+ },
+ },
+ },
+ newResourceMap: map[string]*schema.Resource{
+ "google_x": {
+ Schema: map[string]*schema.Schema{
+ "field-a": {Description: "beep", Computed: true, Optional: true},
+ "field-b": {Description: "beep", Computed: true},
+ },
+ },
+ },
+ want: []MissingDocsInfo{},
+ },
+ {
+ name: "multiple new fields missing doc",
+ oldResourceMap: map[string]*schema.Resource{},
+ newResourceMap: map[string]*schema.Resource{
+ "google_x": {
+ Schema: map[string]*schema.Schema{
+ "field-a": {Description: "beep", Computed: true, Optional: true},
+ "field-b": {Description: "beep", Computed: true},
+ },
+ },
+ },
+ want: []MissingDocsInfo{
+ {
+ Name: "google_x",
+ FilePath: "/website/docs/r/x.html.markdown",
+ Fields: []string{"field-a", "field-b"},
+ },
+ },
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ var buf bytes.Buffer
+ o := detectMissingDocsOptions{
+ computeSchemaDiff: func() diff.SchemaDiff {
+ return diff.ComputeSchemaDiff(tc.oldResourceMap, tc.newResourceMap)
+ },
+ newResourceSchema: tc.newResourceMap,
+ stdout: &buf,
+ }
+
+ err := o.run([]string{t.TempDir()})
+ if err != nil {
+ t.Fatalf("Error running command: %s", err)
+ }
+
+ out := make([]byte, buf.Len())
+ buf.Read(out)
+
+ var got []MissingDocsInfo
+ if err = json.Unmarshal(out, &got); err != nil {
+ t.Fatalf("Failed to unmarshall output: %s", err)
+ }
+
+ if diff := cmp.Diff(tc.want, got); diff != "" {
+ t.Errorf("Unexpected result. Want %+v, got %+v. ", tc.want, got)
+ }
+ })
+ }
+}
diff --git a/tools/diff-processor/detector/detector.go b/tools/diff-processor/detector/detector.go
index 8305aaac1407..05e0720f085f 100644
--- a/tools/diff-processor/detector/detector.go
+++ b/tools/diff-processor/detector/detector.go
@@ -1,10 +1,14 @@
package detector
import (
+ "fmt"
+ "os"
+ "path/filepath"
"sort"
"strings"
"github.com/GoogleCloudPlatform/magic-modules/tools/diff-processor/diff"
+ "github.com/GoogleCloudPlatform/magic-modules/tools/diff-processor/documentparser"
"github.com/GoogleCloudPlatform/magic-modules/tools/test-reader/reader"
"github.com/hashicorp/hcl/v2/hclwrite"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
@@ -33,6 +37,12 @@ type Field struct {
Tested bool
}
+// MissingDocDetails denotes the doc file path and the fields that are not shown up in the corresponding doc.
+type MissingDocDetails struct {
+ FilePath string
+ Fields []string
+}
+
// Detect missing tests for the given resource changes map in the given slice of tests.
// Return a map of resource names to missing test info about that resource.
func DetectMissingTests(schemaDiff diff.SchemaDiff, allTests []*reader.Test) (map[string]*MissingTestInfo, error) {
@@ -152,3 +162,91 @@ func suggestedTest(resourceName string, untested []string) string {
}
return strings.ReplaceAll(string(f.Bytes()), `"VALUE"`, "# value needed")
}
+
+// DetectMissingDocs detect new fields that are missing docs given the schema diffs.
+// Return a map of resource names to missing doc info.
+func DetectMissingDocs(schemaDiff diff.SchemaDiff, repoPath string, resourceMap map[string]*schema.Resource) (map[string]MissingDocDetails, error) {
+ ret := make(map[string]MissingDocDetails)
+ for resource, resourceDiff := range schemaDiff {
+ fieldsInDoc := make(map[string]bool)
+
+ docFilePath, err := resourceToDocFile(resource, repoPath)
+ if err != nil {
+ fmt.Printf("Warning: %s.\n", err)
+ } else {
+ content, err := os.ReadFile(docFilePath)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read resource doc %s: %w", docFilePath, err)
+ }
+ parser := documentparser.NewParser()
+ err = parser.Parse(content)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse document %s: %w", docFilePath, err)
+ }
+
+ argumentsInDoc := listToMap(parser.Arguments())
+ attributesInDoc := listToMap(parser.Attributes())
+ for _, m := range []map[string]bool{argumentsInDoc, attributesInDoc} {
+ for k, v := range m {
+ fieldsInDoc[k] = v
+ }
+ }
+ // for iam resource
+ if v, ok := fieldsInDoc["member/members"]; ok {
+ fieldsInDoc["member"] = v
+ fieldsInDoc["members"] = v
+ }
+ }
+ details := MissingDocDetails{
+ FilePath: strings.ReplaceAll(docFilePath, repoPath, ""),
+ }
+
+ for field, fieldDiff := range resourceDiff.Fields {
+ if !isNewField(fieldDiff) {
+ continue
+ }
+ if !fieldsInDoc[field] {
+ details.Fields = append(details.Fields, field)
+ }
+ }
+ if len(details.Fields) > 0 {
+ ret[resource] = details
+ }
+ }
+ return ret, nil
+}
+
+func isNewField(fieldDiff diff.FieldDiff) bool {
+ return fieldDiff.Old == nil && fieldDiff.New != nil
+}
+
+func resourceToDocFile(resource string, repoPath string) (string, error) {
+ baseNameOptions := []string{
+ strings.TrimPrefix(resource, "google_") + ".html.markdown",
+ resource + ".html.markdown",
+ }
+ suffix := []string{"_policy", "_binding", "_member"}
+ for _, s := range suffix {
+ if strings.HasSuffix(resource, "_iam"+s) {
+ iamName := strings.TrimSuffix(resource, s)
+ baseNameOptions = append(baseNameOptions, iamName+".html.markdown")
+ baseNameOptions = append(baseNameOptions, strings.TrimPrefix(iamName, "google_")+".html.markdown")
+ }
+ }
+ for _, baseName := range baseNameOptions {
+ fullPath := filepath.Join(repoPath, "website", "docs", "r", baseName)
+ _, err := os.ReadFile(fullPath)
+ if !os.IsNotExist(err) {
+ return fullPath, nil
+ }
+ }
+ return filepath.Join(repoPath, "website", "docs", "r", baseNameOptions[0]), fmt.Errorf("no document files found in %s for resource %q", baseNameOptions, resource)
+}
+
+func listToMap(items []string) map[string]bool {
+ m := make(map[string]bool)
+ for _, item := range items {
+ m[item] = true
+ }
+ return m
+}
diff --git a/tools/diff-processor/detector/detector_test.go b/tools/diff-processor/detector/detector_test.go
index 60ad7739bc7f..30f0dcc5813a 100644
--- a/tools/diff-processor/detector/detector_test.go
+++ b/tools/diff-processor/detector/detector_test.go
@@ -2,10 +2,12 @@ package detector
import (
"reflect"
+ "sort"
"testing"
"github.com/GoogleCloudPlatform/magic-modules/tools/diff-processor/diff"
"github.com/GoogleCloudPlatform/magic-modules/tools/test-reader/reader"
+ "github.com/google/go-cmp/cmp"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)
@@ -201,3 +203,206 @@ func TestGetMissingTestsForChanges(t *testing.T) {
}
}
}
+
+func TestDetectMissingDocs(t *testing.T) {
+ // top level field_one is argument, field_two is attribute.
+ resourceSchema := map[string]*schema.Resource{
+ "a_resource": {
+ Schema: map[string]*schema.Schema{
+ "field_one": {
+ Computed: true,
+ Optional: true,
+ Elem: &schema.Resource{
+ Schema: map[string]*schema.Schema{
+ "a": {
+ Computed: true,
+ Optional: true,
+ },
+ "b": {
+ Computed: true,
+ Optional: false,
+ },
+ "c": {
+ Computed: true,
+ Optional: false,
+ },
+ },
+ },
+ },
+ "field_two": {
+ Computed: true,
+ Elem: &schema.Resource{
+ Schema: map[string]*schema.Schema{
+ "a": {
+ Computed: true,
+ Optional: false,
+ },
+ "b": {
+ Computed: true,
+ Optional: false,
+ },
+ "c": {
+ Computed: true,
+ Optional: false,
+ },
+ },
+ },
+ },
+ "field_three": {
+ Computed: true,
+ Optional: true,
+ },
+ "field_four": {
+ Computed: true,
+ },
+ },
+ },
+ }
+
+ // If repo is not temp dir, then the doc file points to tools/diff-processor/testdata/website/docs/r/a_resource.html.markdown.
+ for _, test := range []struct {
+ name string
+ schemaDiff diff.SchemaDiff
+ repo string
+ want map[string]MissingDocDetails
+ }{
+ {
+ name: "doc file not exist",
+ schemaDiff: diff.SchemaDiff{
+ "a_resource": diff.ResourceDiff{
+ Fields: map[string]diff.FieldDiff{
+ "field_one": {
+ New: &schema.Schema{},
+ },
+ "field_one.a": {
+ New: &schema.Schema{},
+ },
+ "field_one.b": {
+ New: &schema.Schema{},
+ },
+ "field_two.a": {
+ New: &schema.Schema{},
+ Old: &schema.Schema{},
+ },
+ "field_two.b": {
+ New: &schema.Schema{},
+ },
+ "field_three": {
+ New: &schema.Schema{
+ Computed: true,
+ Optional: true,
+ },
+ },
+ "field_four": {
+ New: &schema.Schema{
+ Computed: true,
+ },
+ },
+ },
+ },
+ },
+ repo: t.TempDir(),
+ want: map[string]MissingDocDetails{
+ "a_resource": {
+ FilePath: "/website/docs/r/a_resource.html.markdown",
+ Fields: []string{"field_one", "field_one.a", "field_one.b", "field_two.b", "field_three", "field_four"},
+ },
+ },
+ },
+ {
+ name: "doc file exist",
+ schemaDiff: diff.SchemaDiff{
+ "a_resource": diff.ResourceDiff{
+ Fields: map[string]diff.FieldDiff{
+ "field_one": {
+ New: &schema.Schema{},
+ },
+ "field_one.a": {
+ New: &schema.Schema{},
+ },
+ "field_one.b": {
+ New: &schema.Schema{},
+ },
+ "field_two.a": {
+ New: &schema.Schema{},
+ Old: &schema.Schema{},
+ },
+ "field_two.b": {
+ New: &schema.Schema{},
+ },
+ "field_three": {
+ New: &schema.Schema{
+ Computed: true,
+ Optional: true,
+ },
+ },
+ "field_four": {
+ New: &schema.Schema{
+ Computed: true,
+ },
+ },
+ },
+ },
+ },
+ repo: "../testdata",
+ want: map[string]MissingDocDetails{
+ "a_resource": {
+ FilePath: "/website/docs/r/a_resource.html.markdown",
+ Fields: []string{"field_one.b", "field_two.b", "field_three", "field_four"},
+ },
+ },
+ },
+ {
+ name: "nested new field missing doc",
+ schemaDiff: diff.SchemaDiff{
+ "a_resource": diff.ResourceDiff{
+ Fields: map[string]diff.FieldDiff{
+ "field_one.c": {
+ New: &schema.Schema{},
+ },
+ },
+ },
+ },
+ repo: "../testdata",
+ want: map[string]MissingDocDetails{
+ "a_resource": {
+ FilePath: "/website/docs/r/a_resource.html.markdown",
+ Fields: []string{"field_one.c"},
+ },
+ },
+ },
+ {
+ name: "member and members is member/members in doc",
+ schemaDiff: diff.SchemaDiff{
+ "a_resource": diff.ResourceDiff{
+ Fields: map[string]diff.FieldDiff{
+ "member": {
+ New: &schema.Schema{},
+ },
+ "members": {
+ New: &schema.Schema{},
+ },
+ },
+ },
+ },
+ repo: "../testdata",
+ want: map[string]MissingDocDetails{},
+ },
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ got, err := DetectMissingDocs(test.schemaDiff, test.repo, resourceSchema)
+ if err != nil {
+ t.Fatalf("DetectMissingDocs = %v, want = nil", err)
+ }
+ for r := range test.want {
+ sort.Strings(test.want[r].Fields)
+ }
+ for r := range got {
+ sort.Strings(got[r].Fields)
+ }
+ if diff := cmp.Diff(test.want, got); diff != "" {
+ t.Errorf("DetectMissingDocs = %v, want = %v", got, test.want)
+ }
+ })
+ }
+}
diff --git a/tools/diff-processor/documentparser/document_parser.go b/tools/diff-processor/documentparser/document_parser.go
new file mode 100644
index 000000000000..28969906c665
--- /dev/null
+++ b/tools/diff-processor/documentparser/document_parser.go
@@ -0,0 +1,202 @@
+package documentparser
+
+import (
+ "fmt"
+ "regexp"
+ "sort"
+ "strings"
+)
+
+const (
+ nestedNamePattern = `\(#(nested_[a-z0-9_]+)\)`
+
+ itemNamePattern = "\\* `([a-z0-9_\\./]+)`"
+ nestedLinkPattern = ``
+
+ sectionSeparator = "## "
+ nestedObjectSeparator = ` 0 {
+ l := len(queue)
+ for _, cur := range queue {
+ // the separator should always at the beginning of the line
+ items := strings.Split(cur.text, "\n"+listItemSeparator)
+ for _, item := range items[1:] {
+ text := listItemSeparator + item
+ itemName, err := findItemName(text)
+ if err != nil {
+ return err
+ }
+ // There is a special case in some hand written resource eg. in compute_instance, where its attributes is in a.0.b.0.c format.
+ itemName = strings.ReplaceAll(itemName, ".0.", ".")
+ nestedName, err := findNestedName(text)
+ if err != nil {
+ return err
+ }
+ newNode := &node{
+ name: itemName,
+ }
+ cur.children = append(cur.children, newNode)
+ if text, ok := nestedBlock[nestedName]; ok {
+ newNode.text = text
+ queue = append(queue, newNode)
+ }
+ }
+
+ }
+ queue = queue[l:]
+ }
+ return nil
+}
+
+func findItemName(text string) (name string, err error) {
+ name, err = findPattern(text, itemNamePattern)
+ if err != nil {
+ return "", err
+ }
+ if name == "" {
+ return "", fmt.Errorf("cannot find item name from %s", text)
+ }
+ return
+}
+
+func findPattern(text string, pattern string) (string, error) {
+ re, err := regexp.Compile(pattern)
+ if err != nil {
+ return "", err
+ }
+ match := re.FindStringSubmatch(text)
+
+ if match != nil {
+ return match[1], nil
+ }
+ return "", nil
+}
+
+func findNestedName(text string) (string, error) {
+ s := strings.ReplaceAll(text, "\n", "")
+ return findPattern(s, nestedNamePattern)
+}
diff --git a/tools/diff-processor/documentparser/document_parser_test.go b/tools/diff-processor/documentparser/document_parser_test.go
new file mode 100644
index 000000000000..d48df5f184e8
--- /dev/null
+++ b/tools/diff-processor/documentparser/document_parser_test.go
@@ -0,0 +1,116 @@
+package documentparser
+
+import (
+ "os"
+ "sort"
+ "testing"
+
+ "github.com/google/go-cmp/cmp"
+)
+
+func TestParse(t *testing.T) {
+ b, err := os.ReadFile("../testdata/resource.html.markdown")
+ if err != nil {
+ t.Fatal(err)
+ }
+ parser := NewParser()
+ if err := parser.Parse(b); err != nil {
+ t.Fatal(err)
+ }
+ wantArguments := []string{
+ "boot_disk",
+ "boot_disk.auto_delete",
+ "boot_disk.device_name",
+ "boot_disk.disk_encryption_key_raw",
+ "boot_disk.initialize_params",
+ "boot_disk.initialize_params.enable_confidential_compute",
+ "boot_disk.initialize_params.image",
+ "boot_disk.initialize_params.labels",
+ "boot_disk.initialize_params.provisioned_iops",
+ "boot_disk.initialize_params.provisioned_throughput",
+ "boot_disk.initialize_params.resource_manager_tags",
+ "boot_disk.initialize_params.size",
+ "boot_disk.initialize_params.storage_pool",
+ "boot_disk.initialize_params.type",
+ "boot_disk.kms_key_self_link",
+ "boot_disk.mode",
+ "boot_disk.source",
+ "name",
+ "network_interface",
+ "network_interface.access_config",
+ "network_interface.access_config.nat_ip",
+ "network_interface.access_config.network_tier",
+ "network_interface.access_config.public_ptr_domain_name",
+ "network_interface.alias_ip_range",
+ "network_interface.alias_ip_range.ip_cidr_range",
+ "network_interface.alias_ip_range.subnetwork_range_name",
+ "network_interface.ipv6_access_config",
+ "network_interface.ipv6_access_config.external_ipv6",
+ "network_interface.ipv6_access_config.external_ipv6_prefix_length",
+ "network_interface.ipv6_access_config.name",
+ "network_interface.ipv6_access_config.network_tier",
+ "network_interface.ipv6_access_config.public_ptr_domain_name",
+ "network_interface.network",
+ "network_interface.network_attachment",
+ "network_interface.network_ip",
+ "network_interface.nic_type",
+ "network_interface.queue_count",
+ "network_interface.security_policy",
+ "network_interface.stack_type",
+ "params",
+ // "params.resource_manager_tags", // params text does not include a nested tag
+ "zone",
+ "labels",
+ "description",
+ "traffic_port_selector",
+ "traffic_port_selector.ports",
+ "project",
+ }
+ wantAttributes := []string{
+ "id",
+ "network_interface.access_config.nat_ip",
+ "workload_identity_config",
+ "errors",
+ "workload_identity_config.identity_provider",
+ "workload_identity_config.issuer_uri",
+ "workload_identity_config.workload_pool",
+ "errors.message",
+ }
+ gotArguments := parser.Arguments()
+ gotAttributes := parser.Attributes()
+ for _, arr := range [][]string{gotArguments, wantArguments, gotAttributes, wantAttributes} {
+ sort.Strings(arr)
+ }
+ if diff := cmp.Diff(wantArguments, gotArguments); diff != "" {
+ t.Errorf("Parse returned diff in arguments(-want, +got): %s", diff)
+ }
+ if diff := cmp.Diff(wantAttributes, gotAttributes); diff != "" {
+ t.Errorf("Parse returned diff in attributes(-want, +got): %s", diff)
+ }
+}
+
+func TestTraverse(t *testing.T) {
+ n1 := &node{name: "n1"}
+ n2 := &node{name: "n2"}
+ n3 := &node{name: "n3"}
+ n4 := &node{name: "n4"}
+ root := &node{
+ children: []*node{n1, n2, n3},
+ }
+ n1.children = []*node{n4}
+ n2.children = []*node{n4}
+
+ var paths []string
+ traverse(&paths, "", root)
+
+ wantPaths := []string{
+ "n1",
+ "n1.n4",
+ "n2",
+ "n2.n4",
+ "n3",
+ }
+ if diff := cmp.Diff(wantPaths, paths); diff != "" {
+ t.Errorf("traverse returned diff(-want, +got): %s", diff)
+ }
+}
diff --git a/tools/diff-processor/testdata/resource.html.markdown b/tools/diff-processor/testdata/resource.html.markdown
new file mode 100644
index 000000000000..b06fc5b13984
--- /dev/null
+++ b/tools/diff-processor/testdata/resource.html.markdown
@@ -0,0 +1,285 @@
+---
+subcategory: "Compute Engine"
+description: |-
+ Manages abcdefg.
+---
+
+# google_test_resource
+
+This resource combines some sections in google_compute_instance, google_container_attached_cluster, network_services_endpoint_policy and irrelvant parts are trimmed.
+
+## Example Usage
+
+Lorem ipsum
+
+## Example usage - Confidential Computing
+
+Lorem ipsum
+
+
+## Argument Reference
+
+The following arguments are supported:
+
+* `boot_disk` - (Required) The boot disk for the instance.
+ Structure is [documented below](#nested_boot_disk).
+
+* `name` - (Required) A unique name for the resource, required by GCE.
+ Changing this forces a new resource to be created.
+
+* `zone` - (Optional) The zone that the machine should be created in. If it is not provided, the provider zone is used.
+
+* `network_interface` - (Required) Networks to attach to the instance. This can
+ be specified multiple times. Structure is [documented below](#nested_network_interface).
+
+* `params` - (Optional) Additional instance parameters.
+
+---
+
+The `boot_disk` block supports:
+
+* `auto_delete` - (Optional) Whether the disk will be auto-deleted when the instance
+ is deleted. Defaults to true.
+
+* `device_name` - (Optional) Name with which attached disk will be accessible.
+ On the instance, this device will be `/dev/disk/by-id/google-{{device_name}}`.
+
+* `mode` - (Optional) The mode in which to attach this disk, either `READ_WRITE`
+ or `READ_ONLY`. If not specified, the default is to attach the disk in `READ_WRITE` mode.
+
+* `disk_encryption_key_raw` - (Optional) A 256-bit [customer-supplied encryption key]
+ (https://cloud.google.com/compute/docs/disks/customer-supplied-encryption),
+ encoded in [RFC 4648 base64](https://tools.ietf.org/html/rfc4648#section-4)
+ to encrypt this disk. Only one of `kms_key_self_link` and `disk_encryption_key_raw`
+ may be set.
+
+* `kms_key_self_link` - (Optional) The self_link of the encryption key that is
+ stored in Google Cloud KMS to encrypt this disk. Only one of `kms_key_self_link`
+ and `disk_encryption_key_raw` may be set.
+
+* `initialize_params` - (Optional) Parameters for a new disk that will be created
+ alongside the new instance. Either `initialize_params` or `source` must be set.
+ Structure is [documented below](#nested_initialize_params).
+
+* `source` - (Optional) The name or self_link of the existing disk (such as those managed by
+ `google_compute_disk`) or disk image. To create an instance from a snapshot, first create a
+ `google_compute_disk` from a snapshot and reference it here.
+
+The `initialize_params` block supports:
+
+* `size` - (Optional) The size of the image in gigabytes. If not specified, it
+ will inherit the size of its base image.
+
+* `type` - (Optional) The GCE disk type. Such as pd-standard, pd-balanced or pd-ssd.
+
+* `image` - (Optional) The image from which to initialize this disk. This can be
+ one of: the image's `self_link`, `projects/{project}/global/images/{image}`,
+ `projects/{project}/global/images/family/{family}`, `global/images/{image}`,
+ `global/images/family/{family}`, `family/{family}`, `{project}/{family}`,
+ `{project}/{image}`, `{family}`, or `{image}`. If referred by family, the
+ images names must include the family name. If they don't, use the
+ [google_compute_image data source](/docs/providers/google/d/compute_image.html).
+ For instance, the image `centos-6-v20180104` includes its family name `centos-6`.
+ These images can be referred by family name here.
+
+* `labels` - (Optional) A set of key/value label pairs assigned to the disk. This
+ field is only applicable for persistent disks.
+
+* `resource_manager_tags` - (Optional) A tag is a key-value pair that can be attached to a Google Cloud resource. You can use tags to conditionally allow or deny policies based on whether a resource has a specific tag. This value is not returned by the API. In Terraform, this value cannot be updated and changing it will recreate the resource.
+
+* `provisioned_iops` - (Optional) Indicates how many IOPS to provision for the disk.
+ This sets the number of I/O operations per second that the disk can handle.
+ For more details,see the [Hyperdisk documentation](https://cloud.google.com/compute/docs/disks/hyperdisks).
+ Note: Updating currently is only supported for hyperdisk skus via disk update
+ api/gcloud without the need to delete and recreate the disk, hyperdisk allows
+ for an update of IOPS every 4 hours. To update your hyperdisk more frequently,
+ you'll need to manually delete and recreate it.
+
+* `provisioned_throughput` - (Optional) Indicates how much throughput to provision for the disk.
+ This sets the number of throughput mb per second that the disk can handle.
+ For more details,see the [Hyperdisk documentation](https://cloud.google.com/compute/docs/disks/hyperdisks).
+ Note: Updating currently is only supported for hyperdisk skus via disk update
+ api/gcloud without the need to delete and recreate the disk, hyperdisk allows
+ for an update of throughput every 4 hours. To update your hyperdisk more
+ frequently, you'll need to manually delete and recreate it.
+
+* `enable_confidential_compute` - (Optional) Whether this disk is using confidential compute mode.
+ Note: Only supported on hyperdisk skus, disk_encryption_key is required when setting to true.
+
+* `storage_pool` - (Optional) The URL of the storage pool in which the new disk is created.
+ For example:
+ * https://www.googleapis.com/compute/v1/projects/{project}/zones/{zone}/storagePools/{storagePool}
+ * /projects/{project}/zones/{zone}/storagePools/{storagePool}
+
+
+The `network_interface` block supports:
+
+* `network` - (Optional) The name or self_link of the network to attach this interface to.
+ Either `network` or `subnetwork` must be provided. If network isn't provided it will
+ be inferred from the subnetwork.
+
+* `subnetwork` - (Optional) The name or self_link of the subnetwork to attach this
+ interface to. Either `network` or `subnetwork` must be provided. If network isn't provided
+ it will be inferred from the subnetwork. The subnetwork must exist in the same region this
+ instance will be created in. If the network resource is in
+ [legacy](https://cloud.google.com/vpc/docs/legacy) mode, do not specify this field. If the
+ network is in auto subnet mode, specifying the subnetwork is optional. If the network is
+ in custom subnet mode, specifying the subnetwork is required.
+
+
+* `subnetwork_project` - (Optional) The project in which the subnetwork belongs.
+ If the `subnetwork` is a self_link, this field is ignored in favor of the project
+ defined in the subnetwork self_link. If the `subnetwork` is a name and this
+ field is not provided, the provider project is used.
+
+* `network_ip` - (Optional) The private IP address to assign to the instance. If
+ empty, the address will be automatically assigned.
+
+* `access_config` - (Optional) Access configurations, i.e. IPs via which this
+ instance can be accessed via the Internet. Omit to ensure that the instance
+ is not accessible from the Internet. If omitted, ssh provisioners will not
+ work unless Terraform can send traffic to the instance's network (e.g. via
+ tunnel or because it is running on another cloud instance on that network).
+ This block can be repeated multiple times. Structure [documented below](#nested_access_config).
+
+* `alias_ip_range` - (Optional) An
+ array of alias IP ranges for this network interface. Can only be specified for network
+ interfaces on subnet-mode networks. Structure [documented below](#nested_alias_ip_range).
+
+* `nic_type` - (Optional) The type of vNIC to be used on this interface. Possible values: GVNIC, VIRTIO_NET.
+
+* `network_attachment` - (Optional) [Beta](https://terraform.io/docs/providers/google/guides/provider_versions.html) The URL of the network attachment that this interface should connect to in the following format: `projects/{projectNumber}/regions/{region_name}/networkAttachments/{network_attachment_name}`.
+
+* `stack_type` - (Optional) The stack type for this network interface to identify whether the IPv6 feature is enabled or not. Values are IPV4_IPV6 or IPV4_ONLY. If not specified, IPV4_ONLY will be used.
+
+* `ipv6_access_config` - (Optional) An array of IPv6 access configurations for this interface.
+Currently, only one IPv6 access config, DIRECT_IPV6, is supported. If there is no ipv6AccessConfig
+specified, then this instance will have no external IPv6 Internet access. Structure [documented below](#nested_ipv6_access_config).
+
+* `queue_count` - (Optional) The networking queue count that's specified by users for the network interface. Both Rx and Tx queues will be set to this number. It will be empty if not specified.
+
+* `security_policy` - (Optional) [Beta](https://terraform.io/docs/providers/google/guides/provider_versions.html) A full or partial URL to a security policy to add to this instance. If this field is set to an empty string it will remove the associated security policy.
+
+The `access_config` block supports:
+
+* `nat_ip` - (Optional) The IP address that will be 1:1 mapped to the instance's
+ network ip. If not given, one will be generated.
+
+* `public_ptr_domain_name` - (Optional) The DNS domain name for the public PTR record.
+ To set this field on an instance, you must be verified as the owner of the domain.
+ See [the docs](https://cloud.google.com/compute/docs/instances/create-ptr-record) for how
+ to become verified as a domain owner.
+
+* `network_tier` - (Optional) The [networking tier](https://cloud.google.com/network-tiers/docs/overview) used for configuring this instance.
+ This field can take the following values: PREMIUM, FIXED_STANDARD or STANDARD. If this field is
+ not specified, it is assumed to be PREMIUM.
+
+The `ipv6_access_config` block supports:
+
+* `external_ipv6` - (Optional) The first IPv6 address of the external IPv6 range associated
+ with this instance, prefix length is stored in externalIpv6PrefixLength in ipv6AccessConfig.
+ To use a static external IP address, it must be unused and in the same region as the instance's zone.
+ If not specified, Google Cloud will automatically assign an external IPv6 address from the instance's subnetwork.
+
+* `external_ipv6_prefix_length` - (Optional) The prefix length of the external IPv6 range.
+
+* `name` - (Optional) The name of this access configuration. In ipv6AccessConfigs, the recommended name
+ is "External IPv6".
+
+* `network_tier` - (Optional) The service-level to be provided for IPv6 traffic when the
+ subnet has an external subnet. Only PREMIUM or STANDARD tier is valid for IPv6.
+
+* `public_ptr_domain_name` - (Optional) The domain name to be used when creating DNSv6
+ records for the external IPv6 ranges..
+
+The `alias_ip_range` block supports:
+
+* `ip_cidr_range` - The IP CIDR range represented by this alias IP range. This IP CIDR range
+ must belong to the specified subnetwork and cannot contain IP addresses reserved by
+ system or used by other network interfaces. This range may be a single IP address
+ (e.g. 10.2.3.4), a netmask (e.g. /24) or a CIDR format string (e.g. 10.1.2.0/24).
+
+* `subnetwork_range_name` - (Optional) The subnetwork secondary range name specifying
+ the secondary range from which to allocate the IP CIDR range for this alias IP
+ range. If left unspecified, the primary range of the subnetwork will be used.
+
+The `params` block supports:
+
+* `resource_manager_tags` (Optional) - A tag is a key-value pair that can be attached to a Google Cloud resource. You can use tags to conditionally allow or deny policies based on whether a resource has a specific tag. This value is not returned by the API. In Terraform, this value cannot be updated and changing it will recreate the resource.
+
+- - -
+
+
+* `labels` -
+ (Optional)
+ Set of label tags associated with the TcpRoute resource.
+ **Note**: This field is non-authoritative, and will only manage the labels present in your configuration.
+ Please refer to the field `effective_labels` for all of the labels present on the resource.
+
+* `description` -
+ (Optional)
+ A free-text description of the resource. Max length 1024 characters.
+
+* `traffic_port_selector` -
+ (Optional)
+ Port selector for the (matched) endpoints. If no port selector is provided, the matched config is applied to all ports.
+ Structure is [documented below](#nested_traffic_port_selector).
+
+* `project` - (Optional) The ID of the project in which the resource belongs.
+ If it is not provided, the provider project is used.
+
+
+The `traffic_port_selector` block supports:
+
+* `ports` -
+ (Required)
+ List of ports. Can be port numbers or port range (example, [80-90] specifies all ports from 80 to 90, including 80 and 90) or named ports or * to specify all ports. If the list is empty, all ports are selected.
+
+
+## Attributes Reference
+
+In addition to the arguments listed above, the following computed attributes are
+exported:
+
+* `id` - an identifier for the resource with format `projects/{{project}}/zones/{{zone}}/instances/{{name}}`
+
+* `network_interface.0.access_config.0.nat_ip` - If the instance has an access config, either the given external ip (in the `nat_ip` field) or the ephemeral (generated) ip (if you didn't provide one).
+
+* `workload_identity_config` -
+ Workload Identity settings.
+ Structure is [documented below](#nested_workload_identity_config).
+
+* `errors` -
+ A set of errors found in the cluster.
+ Structure is [documented below](#nested_errors).
+
+
+The `workload_identity_config` block contains:
+
+* `identity_provider` -
+ (Optional)
+ The ID of the OIDC Identity Provider (IdP) associated to
+ the Workload Identity Pool.
+
+* `issuer_uri` -
+ (Optional)
+ The OIDC issuer URL for this cluster.
+
+* `workload_pool` -
+ (Optional)
+ The Workload Identity Pool associated to the cluster.
+
+The `errors` block contains:
+
+* `message` -
+ (Optional)
+ Human-friendly description of the error.
+
+## Timeouts
+
+Lorem ipsum
+
+## Import
+
+Lorem ipsum
+
diff --git a/tools/diff-processor/testdata/website/docs/r/a_resource.html.markdown b/tools/diff-processor/testdata/website/docs/r/a_resource.html.markdown
new file mode 100644
index 000000000000..9d3f5a0cde76
--- /dev/null
+++ b/tools/diff-processor/testdata/website/docs/r/a_resource.html.markdown
@@ -0,0 +1,18 @@
+## Some resource description
+
+## Argument Reference
+
+* `field_one` lorem ipsum. Structure is [documented below](#nested_field_one).
+* `member/members` - (Required) lorem ipsum.
+
+The `field_one` block supports:
+
+* `a` - (Optional) lorem ipsum.
+
+## Attributes Reference
+
+* `field_two` lorem ipsum. Structure is [documented below](#nested_field_two).
+
+The `field_two` block supports:
+
+* `a` - (Optional) lorem ipsum.
\ No newline at end of file