diff --git a/CHANGELOG.md b/CHANGELOG.md index 42b193d..4aa7b79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +# v0.8.0 + +ENHANCEMENTS + +* check: Add experimental `-enable-contents-check` and `-require-schema-ordering` options + # v0.7.0 ENHANCEMENTS diff --git a/README.md b/README.md index 4bc2d0a..7519053 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,13 @@ The validity of files is checked with the following rules: The YAML frontmatter checks include some defaults (e.g. no `layout` field for Terraform Registry), but there are some useful flags that can be passed to the command to tune the behavior, especially for larger Terraform Providers. +The validity of files can also be experimentally checked (via the `-enable-contents-check` flag) with the following rules: + +- Ensures all expected headings are present. +- Verifies heading levels and text. +- Verifies schema attribute lists are ordered (if `-require-schema-ordering` is provided). Only supports section level lists (not sub-section level lists) currently. +- Verifies resource type is present in code blocks (e.g. examples and import sections). + For additional information about check flags, you can run `tfproviderdocs check -help`. ## Development and Testing diff --git a/check/contents.go b/check/contents.go new file mode 100644 index 0000000..c067fa4 --- /dev/null +++ b/check/contents.go @@ -0,0 +1,63 @@ +package check + +import ( + "fmt" + + "github.com/bflad/tfproviderdocs/check/contents" +) + +type ContentsCheck struct { + Options *ContentsOptions +} + +// ContentsOptions represents configuration options for Contents. +type ContentsOptions struct { + *FileOptions + + Enable bool + ProviderName string + RequireSchemaOrdering bool +} + +func NewContentsCheck(opts *ContentsOptions) *ContentsCheck { + check := &ContentsCheck{ + Options: opts, + } + + if check.Options == nil { + check.Options = &ContentsOptions{} + } + + if check.Options.FileOptions == nil { + check.Options.FileOptions = &FileOptions{} + } + + return check +} + +func (check *ContentsCheck) Run(path string) error { + if !check.Options.Enable { + return nil + } + + checkOpts := &contents.CheckOptions{ + ArgumentsSection: &contents.CheckArgumentsSectionOptions{ + RequireSchemaOrdering: check.Options.RequireSchemaOrdering, + }, + AttributesSection: &contents.CheckAttributesSectionOptions{ + RequireSchemaOrdering: check.Options.RequireSchemaOrdering, + }, + } + + doc := contents.NewDocument(path, check.Options.ProviderName) + + if err := doc.Parse(); err != nil { + return fmt.Errorf("error parsing file: %w", err) + } + + if err := doc.Check(checkOpts); err != nil { + return err + } + + return nil +} diff --git a/check/contents/check.go b/check/contents/check.go new file mode 100644 index 0000000..0077b64 --- /dev/null +++ b/check/contents/check.go @@ -0,0 +1,36 @@ +package contents + +type CheckOptions struct { + ArgumentsSection *CheckArgumentsSectionOptions + AttributesSection *CheckAttributesSectionOptions +} + +func (d *Document) Check(opts *CheckOptions) error { + d.CheckOptions = opts + + if err := d.checkTitleSection(); err != nil { + return err + } + + if err := d.checkExampleSection(); err != nil { + return err + } + + if err := d.checkArgumentsSection(); err != nil { + return err + } + + if err := d.checkAttributesSection(); err != nil { + return err + } + + if err := d.checkTimeoutsSection(); err != nil { + return err + } + + if err := d.checkImportSection(); err != nil { + return err + } + + return nil +} diff --git a/check/contents/check_arguments_section.go b/check/contents/check_arguments_section.go new file mode 100644 index 0000000..07695ba --- /dev/null +++ b/check/contents/check_arguments_section.go @@ -0,0 +1,47 @@ +package contents + +import ( + "fmt" + "sort" +) + +type CheckArgumentsSectionOptions struct { + RequireSchemaOrdering bool +} + +func (d *Document) checkArgumentsSection() error { + checkOpts := &CheckArgumentsSectionOptions{} + + if d.CheckOptions != nil && d.CheckOptions.ArgumentsSection != nil { + checkOpts = d.CheckOptions.ArgumentsSection + } + + section := d.Sections.Arguments + + if section == nil { + return fmt.Errorf("missing arguments section: ## Argument Reference") + } + + heading := section.Heading + + if heading.Level != 2 { + return fmt.Errorf("arguments section heading level (%d) should be: 2", heading.Level) + } + + headingText := string(heading.Text(d.source)) + expectedHeadingText := "Argument Reference" + + if headingText != expectedHeadingText { + return fmt.Errorf("arguments section heading (%s) should be: %s", headingText, expectedHeadingText) + } + + if checkOpts.RequireSchemaOrdering { + for _, list := range section.SchemaAttributeLists { + if !sort.IsSorted(SchemaAttributeListItemByName(list.Items)) { + return fmt.Errorf("arguments section is not sorted by name") + } + } + } + + return nil +} diff --git a/check/contents/check_arguments_section_test.go b/check/contents/check_arguments_section_test.go new file mode 100644 index 0000000..231ecb8 --- /dev/null +++ b/check/contents/check_arguments_section_test.go @@ -0,0 +1,77 @@ +package contents + +import ( + "testing" +) + +func TestCheckArgumentsSection(t *testing.T) { + testCases := []struct { + Name string + Path string + ProviderName string + CheckOptions *CheckOptions + ExpectError bool + }{ + { + Name: "passing", + Path: "testdata/arguments/passing.md", + ProviderName: "test", + }, + { + Name: "missing heading", + Path: "testdata/arguments/missing_heading.md", + ProviderName: "test", + ExpectError: true, + }, + { + Name: "wrong heading level", + Path: "testdata/arguments/wrong_heading_level.md", + ProviderName: "test", + ExpectError: true, + }, + { + Name: "wrong heading text", + Path: "testdata/arguments/wrong_heading_text.md", + ProviderName: "test", + ExpectError: true, + }, + { + Name: "wrong list order", + Path: "testdata/arguments/wrong_list_order.md", + ProviderName: "test", + }, + { + Name: "wrong list order", + Path: "testdata/arguments/wrong_list_order.md", + ProviderName: "test", + CheckOptions: &CheckOptions{ + ArgumentsSection: &CheckArgumentsSectionOptions{ + RequireSchemaOrdering: true, + }, + }, + ExpectError: true, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.Name, func(t *testing.T) { + doc := NewDocument(testCase.Path, testCase.ProviderName) + + if err := doc.Parse(); err != nil { + t.Fatalf("unexpected error: %s", err) + } + + doc.CheckOptions = testCase.CheckOptions + + got := doc.checkArgumentsSection() + + if got == nil && testCase.ExpectError { + t.Errorf("expected error, got no error") + } + + if got != nil && !testCase.ExpectError { + t.Errorf("expected no error, got error: %s", got) + } + }) + } +} diff --git a/check/contents/check_attributes_section.go b/check/contents/check_attributes_section.go new file mode 100644 index 0000000..231f608 --- /dev/null +++ b/check/contents/check_attributes_section.go @@ -0,0 +1,61 @@ +package contents + +import ( + "fmt" + "sort" +) + +type CheckAttributesSectionOptions struct { + RequireSchemaOrdering bool +} + +func (d *Document) checkAttributesSection() error { + checkOpts := &CheckAttributesSectionOptions{} + + if d.CheckOptions != nil && d.CheckOptions.AttributesSection != nil { + checkOpts = d.CheckOptions.AttributesSection + } + + section := d.Sections.Attributes + + if section == nil { + return fmt.Errorf("missing attributes section: ## Attributes Reference") + } + + heading := section.Heading + + if heading.Level != 2 { + return fmt.Errorf("attributes section heading level (%d) should be: 2", heading.Level) + } + + headingText := string(heading.Text(d.source)) + expectedHeadingText := "Attributes Reference" + + if headingText != expectedHeadingText { + return fmt.Errorf("attributes section heading (%s) should be: %s", headingText, expectedHeadingText) + } + + paragraphs := section.Paragraphs + expectedBylineText := "In addition to all arguments above, the following attributes are exported:" + + switch len(paragraphs) { + case 0: + return fmt.Errorf("attributes section byline should be: %s", expectedBylineText) + case 1: + paragraphText := string(paragraphs[0].Text(d.source)) + + if paragraphText != expectedBylineText { + return fmt.Errorf("attributes section byline (%s) should be: %s", paragraphText, expectedBylineText) + } + } + + if checkOpts.RequireSchemaOrdering { + for _, list := range section.SchemaAttributeLists { + if !sort.IsSorted(SchemaAttributeListItemByName(list.Items)) { + return fmt.Errorf("attributes section is not sorted by name") + } + } + } + + return nil +} diff --git a/check/contents/check_attributes_section_test.go b/check/contents/check_attributes_section_test.go new file mode 100644 index 0000000..33c2afd --- /dev/null +++ b/check/contents/check_attributes_section_test.go @@ -0,0 +1,89 @@ +package contents + +import ( + "testing" +) + +func TestCheckAttributesSection(t *testing.T) { + testCases := []struct { + Name string + Path string + ProviderName string + CheckOptions *CheckOptions + ExpectError bool + }{ + { + Name: "passing", + Path: "testdata/attributes/passing.md", + ProviderName: "test", + }, + { + Name: "missing byline", + Path: "testdata/attributes/missing_byline.md", + ProviderName: "test", + ExpectError: true, + }, + { + Name: "missing heading", + Path: "testdata/attributes/missing_heading.md", + ProviderName: "test", + ExpectError: true, + }, + { + Name: "wrong byline", + Path: "testdata/attributes/wrong_byline.md", + ProviderName: "test", + ExpectError: true, + }, + { + Name: "wrong heading level", + Path: "testdata/attributes/wrong_heading_level.md", + ProviderName: "test", + ExpectError: true, + }, + { + Name: "wrong heading text", + Path: "testdata/attributes/wrong_heading_text.md", + ProviderName: "test", + ExpectError: true, + }, + { + Name: "wrong list order", + Path: "testdata/attributes/wrong_list_order.md", + ProviderName: "test", + }, + { + Name: "wrong list order", + Path: "testdata/attributes/wrong_list_order.md", + ProviderName: "test", + CheckOptions: &CheckOptions{ + AttributesSection: &CheckAttributesSectionOptions{ + RequireSchemaOrdering: true, + }, + }, + ExpectError: true, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.Name, func(t *testing.T) { + doc := NewDocument(testCase.Path, testCase.ProviderName) + + if err := doc.Parse(); err != nil { + t.Fatalf("unexpected error: %s", err) + } + + doc.CheckOptions = testCase.CheckOptions + + got := doc.checkAttributesSection() + + if got == nil && testCase.ExpectError { + t.Errorf("expected error, got no error") + } + + if got != nil && !testCase.ExpectError { + t.Errorf("expected no error, got error: %s", got) + } + }) + } +} diff --git a/check/contents/check_example_section.go b/check/contents/check_example_section.go new file mode 100644 index 0000000..793d2b2 --- /dev/null +++ b/check/contents/check_example_section.go @@ -0,0 +1,45 @@ +package contents + +import ( + "fmt" + "strings" + + "github.com/bflad/tfproviderdocs/markdown" +) + +func (d *Document) checkExampleSection() error { + section := d.Sections.Example + + if section == nil { + return fmt.Errorf("missing example section: ## Example Usage") + } + + heading := section.Heading + + if heading.Level != 2 { + return fmt.Errorf("example section heading level (%d) should be: 2", heading.Level) + } + + headingText := string(heading.Text(d.source)) + expectedHeadingText := "Example Usage" + + if headingText != expectedHeadingText { + return fmt.Errorf("example section heading (%s) should be: %s", headingText, expectedHeadingText) + } + + for _, fencedCodeBlock := range section.FencedCodeBlocks { + language := markdown.FencedCodeBlockLanguage(fencedCodeBlock, d.source) + + if language != markdown.FencedCodeBlockLanguageHcl && language != markdown.FencedCodeBlockLanguageTerraform { + return fmt.Errorf("example section code block language (%s) should be: ```%s or ```%s", language, markdown.FencedCodeBlockLanguageHcl, markdown.FencedCodeBlockLanguageTerraform) + } + + text := markdown.FencedCodeBlockText(fencedCodeBlock, d.source) + + if !strings.Contains(text, d.ResourceName) { + return fmt.Errorf("example section code block text should contain resource name: %s", d.ResourceName) + } + } + + return nil +} diff --git a/check/contents/check_example_section_test.go b/check/contents/check_example_section_test.go new file mode 100644 index 0000000..484a64a --- /dev/null +++ b/check/contents/check_example_section_test.go @@ -0,0 +1,70 @@ +package contents + +import ( + "testing" +) + +func TestCheckExampleSection(t *testing.T) { + testCases := []struct { + Name string + Path string + ProviderName string + ExpectError bool + }{ + { + Name: "passing", + Path: "testdata/example/passing.md", + ProviderName: "test", + }, + { + Name: "missing code block language", + Path: "testdata/example/missing_code_block_language.md", + ProviderName: "test", + ExpectError: true, + }, + { + Name: "missing heading", + Path: "testdata/example/missing_heading.md", + ProviderName: "test", + ExpectError: true, + }, + { + Name: "wrong heading level", + Path: "testdata/example/wrong_heading_level.md", + ProviderName: "test", + ExpectError: true, + }, + { + Name: "wrong heading text", + Path: "testdata/example/wrong_heading_text.md", + ProviderName: "test", + ExpectError: true, + }, + { + Name: "wrong code block language", + Path: "testdata/example/wrong_code_block_language.md", + ProviderName: "test", + ExpectError: true, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.Name, func(t *testing.T) { + doc := NewDocument(testCase.Path, testCase.ProviderName) + + if err := doc.Parse(); err != nil { + t.Fatalf("unexpected error: %s", err) + } + + got := doc.checkExampleSection() + + if got == nil && testCase.ExpectError { + t.Errorf("expected error, got no error") + } + + if got != nil && !testCase.ExpectError { + t.Errorf("expected no error, got error: %s", got) + } + }) + } +} diff --git a/check/contents/check_import_section.go b/check/contents/check_import_section.go new file mode 100644 index 0000000..b8f753e --- /dev/null +++ b/check/contents/check_import_section.go @@ -0,0 +1,39 @@ +package contents + +import ( + "fmt" + "strings" + + "github.com/bflad/tfproviderdocs/markdown" +) + +func (d *Document) checkImportSection() error { + section := d.Sections.Import + + if section == nil { + return nil + } + + heading := section.Heading + + if heading.Level != 2 { + return fmt.Errorf("import section heading level (%d) should be: 2", heading.Level) + } + + headingText := string(heading.Text(d.source)) + expectedHeadingText := "Import" + + if headingText != expectedHeadingText { + return fmt.Errorf("import section heading (%s) should be: %s", headingText, expectedHeadingText) + } + + for _, fencedCodeBlock := range section.FencedCodeBlocks { + text := markdown.FencedCodeBlockText(fencedCodeBlock, d.source) + + if !strings.Contains(text, d.ResourceName) { + return fmt.Errorf("import section code block text should contain resource name: %s", d.ResourceName) + } + } + + return nil +} diff --git a/check/contents/check_import_section_test.go b/check/contents/check_import_section_test.go new file mode 100644 index 0000000..3fcb200 --- /dev/null +++ b/check/contents/check_import_section_test.go @@ -0,0 +1,58 @@ +package contents + +import ( + "testing" +) + +func TestCheckImportSection(t *testing.T) { + testCases := []struct { + Name string + Path string + ProviderName string + ExpectError bool + }{ + { + Name: "passing", + Path: "testdata/import/passing.md", + ProviderName: "test", + }, + { + Name: "wrong code block resource type", + Path: "testdata/import/wrong_code_block_resource_type.md", + ProviderName: "test", + ExpectError: true, + }, + { + Name: "wrong heading level", + Path: "testdata/import/wrong_heading_level.md", + ProviderName: "test", + ExpectError: true, + }, + { + Name: "wrong heading text", + Path: "testdata/import/wrong_heading_text.md", + ProviderName: "test", + ExpectError: true, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.Name, func(t *testing.T) { + doc := NewDocument(testCase.Path, testCase.ProviderName) + + if err := doc.Parse(); err != nil { + t.Fatalf("unexpected error: %s", err) + } + + got := doc.checkImportSection() + + if got == nil && testCase.ExpectError { + t.Errorf("expected error, got no error") + } + + if got != nil && !testCase.ExpectError { + t.Errorf("expected no error, got error: %s", got) + } + }) + } +} diff --git a/check/contents/check_test.go b/check/contents/check_test.go new file mode 100644 index 0000000..7811824 --- /dev/null +++ b/check/contents/check_test.go @@ -0,0 +1,40 @@ +package contents + +import ( + "testing" +) + +func TestCheck(t *testing.T) { + testCases := []struct { + Name string + Path string + ProviderName string + ExpectError bool + }{ + { + Name: "passing", + Path: "testdata/full.md", + ProviderName: "test", + }, + } + + for _, testCase := range testCases { + t.Run(testCase.Name, func(t *testing.T) { + doc := NewDocument(testCase.Path, testCase.ProviderName) + + if err := doc.Parse(); err != nil { + t.Fatalf("unexpected error: %s", err) + } + + got := doc.Check(nil) + + if got == nil && testCase.ExpectError { + t.Errorf("expected error, got no error") + } + + if got != nil && !testCase.ExpectError { + t.Errorf("expected no error, got error: %s", got) + } + }) + } +} diff --git a/check/contents/check_timeouts_section.go b/check/contents/check_timeouts_section.go new file mode 100644 index 0000000..5118837 --- /dev/null +++ b/check/contents/check_timeouts_section.go @@ -0,0 +1,11 @@ +package contents + +func (d *Document) checkTimeoutsSection() error { + section := d.Sections.Timeouts + + if section == nil { + return nil + } + + return nil +} diff --git a/check/contents/check_timeouts_section_test.go b/check/contents/check_timeouts_section_test.go new file mode 100644 index 0000000..a8a383c --- /dev/null +++ b/check/contents/check_timeouts_section_test.go @@ -0,0 +1,40 @@ +package contents + +import ( + "testing" +) + +func TestCheckTimeoutsSection(t *testing.T) { + testCases := []struct { + Name string + Path string + ProviderName string + ExpectError bool + }{ + { + Name: "passing", + Path: "testdata/timeouts/passing.md", + ProviderName: "test", + }, + } + + for _, testCase := range testCases { + t.Run(testCase.Name, func(t *testing.T) { + doc := NewDocument(testCase.Path, testCase.ProviderName) + + if err := doc.Parse(); err != nil { + t.Fatalf("unexpected error: %s", err) + } + + got := doc.checkTimeoutsSection() + + if got == nil && testCase.ExpectError { + t.Errorf("expected error, got no error") + } + + if got != nil && !testCase.ExpectError { + t.Errorf("expected no error, got error: %s", got) + } + }) + } +} diff --git a/check/contents/check_title_section.go b/check/contents/check_title_section.go new file mode 100644 index 0000000..60066cf --- /dev/null +++ b/check/contents/check_title_section.go @@ -0,0 +1,32 @@ +package contents + +import ( + "fmt" + "strings" +) + +func (d *Document) checkTitleSection() error { + section := d.Sections.Title + + if section == nil { + return fmt.Errorf("missing title section: # Resource: %s", d.ResourceName) + } + + heading := section.Heading + + if heading.Level != 1 { + return fmt.Errorf("title section heading level (%d) should be: 1", heading.Level) + } + + headingText := string(heading.Text(d.source)) + + if !strings.HasPrefix(headingText, "Data Source: ") && !strings.HasPrefix(headingText, "Resource: ") { + return fmt.Errorf("title section heading (%s) should have prefix: \"Data Source: \" or \"Resource: \"", headingText) + } + + if len(section.FencedCodeBlocks) > 0 { + return fmt.Errorf("title section code examples should be in Example Usage section") + } + + return nil +} diff --git a/check/contents/check_title_section_test.go b/check/contents/check_title_section_test.go new file mode 100644 index 0000000..39c082b --- /dev/null +++ b/check/contents/check_title_section_test.go @@ -0,0 +1,70 @@ +package contents + +import ( + "testing" +) + +func TestCheckTitleSection(t *testing.T) { + testCases := []struct { + Name string + Path string + ProviderName string + ExpectError bool + }{ + { + Name: "passing", + Path: "testdata/title/passing.md", + ProviderName: "test", + }, + { + Name: "missing heading", + Path: "testdata/title/missing_heading.md", + ProviderName: "test", + ExpectError: true, + }, + { + Name: "missing heading resource type", + Path: "testdata/title/missing_heading_resource_type.md", + ProviderName: "test", + ExpectError: true, + }, + { + Name: "wrong heading level", + Path: "testdata/title/wrong_heading_level.md", + ProviderName: "test", + ExpectError: true, + }, + { + Name: "wrong resource in heading", + Path: "testdata/title/wrong_resource_in_heading.md", + ProviderName: "test", + ExpectError: true, + }, + { + Name: "wrong code block section", + Path: "testdata/title/wrong_code_block_section.md", + ProviderName: "test", + ExpectError: true, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.Name, func(t *testing.T) { + doc := NewDocument(testCase.Path, testCase.ProviderName) + + if err := doc.Parse(); err != nil { + t.Fatalf("unexpected error: %s", err) + } + + got := doc.checkTitleSection() + + if got == nil && testCase.ExpectError { + t.Errorf("expected error, got no error") + } + + if got != nil && !testCase.ExpectError { + t.Errorf("expected no error, got error: %s", got) + } + }) + } +} diff --git a/check/contents/document.go b/check/contents/document.go new file mode 100644 index 0000000..6604602 --- /dev/null +++ b/check/contents/document.go @@ -0,0 +1,60 @@ +package contents + +import ( + "fmt" + "io/ioutil" + "path/filepath" + "strings" + + "github.com/bflad/tfproviderdocs/markdown" + "github.com/yuin/goldmark/ast" +) + +type Document struct { + CheckOptions *CheckOptions + ProviderName string + ResourceName string + Sections *Sections + + document ast.Node + metadata map[string]interface{} + path string + source []byte +} + +func NewDocument(path string, providerName string) *Document { + return &Document{ + ProviderName: providerName, + ResourceName: resourceName(providerName, filepath.Base(path)), + path: path, + } +} + +func (d *Document) Parse() error { + var err error + + d.source, err = ioutil.ReadFile(d.path) + + if err != nil { + return fmt.Errorf("error reading file (%s): %w", d.path, err) + } + + d.document, d.metadata = markdown.Parse(d.source) + + // d.document.Dump(d.source, 1) + + // fmt.Println(d.metadata["page_title"]) + // fmt.Println(d.metadata["description"]) + + d.Sections, err = sectionsWalker(d.document, d.source, d.ResourceName) + + if err != nil { + return fmt.Errorf("error parsing file (%s) sections: %w", d.path, err) + } + + return nil +} + +func resourceName(providerName string, fileName string) string { + return providerName + "_" + fileName[:strings.IndexByte(fileName, '.')] +} diff --git a/check/contents/document_test.go b/check/contents/document_test.go new file mode 100644 index 0000000..d271296 --- /dev/null +++ b/check/contents/document_test.go @@ -0,0 +1,73 @@ +package contents + +import ( + "reflect" + "testing" +) + +func TestNewDocument(t *testing.T) { + testCases := []struct { + Name string + Path string + ProviderName string + ExpectDocument *Document + }{ + { + Name: "basic", + Path: "docs/r/thing.md", + ProviderName: "test", + ExpectDocument: &Document{ + ProviderName: "test", + ResourceName: "test_thing", + path: "docs/r/thing.md", + }, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.Name, func(t *testing.T) { + got := NewDocument(testCase.Path, testCase.ProviderName) + want := testCase.ExpectDocument + + if !reflect.DeepEqual(got, want) { + t.Errorf("expected %#v, got %#v", want, got) + } + }) + } +} + +func TestDocumentParse(t *testing.T) { + testCases := []struct { + Name string + Path string + ProviderName string + ExpectError bool + }{ + { + Name: "empty", + Path: "testdata/empty.md", + ProviderName: "test", + }, + { + Name: "full", + Path: "testdata/full.md", + ProviderName: "test", + }, + } + + for _, testCase := range testCases { + t.Run(testCase.Name, func(t *testing.T) { + doc := NewDocument(testCase.Path, testCase.ProviderName) + + got := doc.Parse() + + if got == nil && testCase.ExpectError { + t.Errorf("expected error, got no error") + } + + if got != nil && !testCase.ExpectError { + t.Errorf("expected no error, got error: %s", got) + } + }) + } +} diff --git a/check/contents/schema_attribute_list.go b/check/contents/schema_attribute_list.go new file mode 100644 index 0000000..abd0f50 --- /dev/null +++ b/check/contents/schema_attribute_list.go @@ -0,0 +1,115 @@ +package contents + +import ( + "strings" + + "github.com/yuin/goldmark/ast" +) + +// SchemaAttributeList represents a schema attribute list +// +// This may represent root or nested lists of arguments or attributes +type SchemaAttributeList struct { + Items []*SchemaAttributeListItem +} + +// SchemaAttributeListItem represents a schema attribute list item +// +// This may represent root or nested lists of arguments or attributes +type SchemaAttributeListItem struct { + Description string + ForceNew bool + Name string + Optional bool + Required bool + Type string +} + +type SchemaAttributeListItemByName []*SchemaAttributeListItem + +func (item SchemaAttributeListItemByName) Len() int { return len(item) } +func (item SchemaAttributeListItemByName) Swap(i, j int) { item[i], item[j] = item[j], item[i] } +func (item SchemaAttributeListItemByName) Less(i, j int) bool { return item[i].Name < item[j].Name } + +func schemaAttributeListWalker(list *ast.List, source []byte) (*SchemaAttributeList, error) { + result := &SchemaAttributeList{} + + err := ast.Walk(list, func(node ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + + switch node := node.(type) { + case *ast.ListItem: + item, err := schemaAttributeListItemWalker(node, source) + + if err != nil { + return ast.WalkStop, err + } + + result.Items = append(result.Items, item) + + return ast.WalkContinue, nil + } + + return ast.WalkContinue, nil + }) + + return result, err +} + +func schemaAttributeListItemWalker(listItem *ast.ListItem, source []byte) (*SchemaAttributeListItem, error) { + result := &SchemaAttributeListItem{} + + // Expected format: `Name` - (Required/Optional[, ForceNew]) Description + + err := ast.Walk(listItem, func(node ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + + switch node := node.(type) { + case *ast.TextBlock: + text := string(node.Text(source)) + itemParts := strings.SplitN(text, " - ", 2) + + if len(itemParts) != 2 { + return ast.WalkContinue, nil + } + + result.Name = itemParts[0] + fullDescription := itemParts[1] + + if !strings.HasPrefix(fullDescription, "(") { + result.Description = fullDescription + + return ast.WalkStop, nil + } + + traitsEndIndex := strings.IndexByte(fullDescription, ')') + + result.Description = fullDescription[traitsEndIndex+1:] + + traits := fullDescription[1:traitsEndIndex] + + for _, trait := range strings.Split(traits, ", ") { + switch trait { + case "Boolean", "Number", "String": + result.Type = trait + case "Forces new", "Forces new resource": + result.ForceNew = true + case "Optional": + result.Optional = true + case "Required": + result.Required = true + } + } + + return ast.WalkStop, nil + } + + return ast.WalkContinue, nil + }) + + return result, err +} diff --git a/check/contents/sections.go b/check/contents/sections.go new file mode 100644 index 0000000..8d1eacc --- /dev/null +++ b/check/contents/sections.go @@ -0,0 +1,253 @@ +package contents + +import ( + "strings" + + "github.com/yuin/goldmark/ast" +) + +const ( + walkerSectionUnknown = iota + walkerSectionTitle + walkerSectionExample + walkerSectionArguments + walkerSectionAttributes + walkerSectionTimeouts + walkerSectionImport +) + +// Sections represents all expected sections of a resource documentation page +type Sections struct { + Attributes *AttributesSection + Arguments *ArgumentsSection + Example *ExampleSection + Import *ImportSection + Timeouts *TimeoutsSection + Title *TitleSection +} + +// AttributesSection represents a resource attributes section. +type AttributesSection SchemaAttributeSection + +// ArgumentsSection represents a resource arguments section. +type ArgumentsSection SchemaAttributeSection + +// ExampleSection represents a resource example code section. +type ExampleSection struct { + // Children contains further nested sections below this section + Children []*ExampleSection + + FencedCodeBlocks []*ast.FencedCodeBlock + Heading *ast.Heading + Paragraphs []*ast.Paragraph +} + +// ImportSection represents a resource import section. +type ImportSection struct { + FencedCodeBlocks []*ast.FencedCodeBlock + Heading *ast.Heading + Paragraphs []*ast.Paragraph +} + +// SchemaAttributeSection represents a schema attribute section +// +// This may represent root or nested lists of arguments or attributes +type SchemaAttributeSection struct { + // Children contains further nested sections below this section + Children []*SchemaAttributeSection + + // FencedCodeBlocks contains any found code blocks + FencedCodeBlocks []*ast.FencedCodeBlock + + // Heading is the root/nested heading for the section + Heading *ast.Heading + + // Lists is the groupings of per-attribute documentation + // + // Some sections may be split these based on Optional versus Required + Lists []*ast.List + + // SchemaAttributeLists is the groupings of per-attribute documentation + // + // Some sections may be split these based on Optional versus Required + SchemaAttributeLists []*SchemaAttributeList + + // Paragraphs is typically the byline(s) of per-attribute documentation + // + // Some sections may be split these based on Optional versus Required + Paragraphs []*ast.Paragraph +} + +// TimeoutsSection represents a resource timeouts section. +type TimeoutsSection struct { + FencedCodeBlocks []*ast.FencedCodeBlock + Heading *ast.Heading + Lists []*ast.List + Paragraphs []*ast.Paragraph +} + +// TitleSection represents the top documentation section +type TitleSection struct { + FencedCodeBlocks []*ast.FencedCodeBlock + Heading *ast.Heading + Paragraphs []*ast.Paragraph +} + +func sectionsWalker(document ast.Node, source []byte, resourceName string) (*Sections, error) { + result := &Sections{} + + var walkerSectionStartingLevel, walkerSection int + + err := ast.Walk(document, func(node ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + + switch node := node.(type) { + case *ast.FencedCodeBlock: + switch walkerSection { + case walkerSectionTitle: + result.Title.FencedCodeBlocks = append(result.Title.FencedCodeBlocks, node) + case walkerSectionExample: + result.Example.FencedCodeBlocks = append(result.Example.FencedCodeBlocks, node) + case walkerSectionArguments: + result.Arguments.FencedCodeBlocks = append(result.Arguments.FencedCodeBlocks, node) + case walkerSectionAttributes: + result.Attributes.FencedCodeBlocks = append(result.Attributes.FencedCodeBlocks, node) + case walkerSectionTimeouts: + result.Timeouts.FencedCodeBlocks = append(result.Timeouts.FencedCodeBlocks, node) + case walkerSectionImport: + result.Import.FencedCodeBlocks = append(result.Import.FencedCodeBlocks, node) + } + + return ast.WalkSkipChildren, nil + case *ast.Heading: + headingText := string(node.Text(source)) + //fmt.Printf("(walker section level: %d) found heading level %d: %s\n", walkerSectionStartingLevel, node.Level, headingText) + + // Always reset section handling when reaching starting level again + if node.Level == walkerSectionStartingLevel { + walkerSection = walkerSectionUnknown + } + + if result.Title == nil && strings.Contains(headingText, resourceName) { + result.Title = &TitleSection{ + Heading: node, + } + + walkerSection = walkerSectionTitle + walkerSectionStartingLevel = node.Level + + return ast.WalkContinue, nil + } + + if result.Example == nil && strings.HasPrefix(headingText, "Example") { + result.Example = &ExampleSection{ + Heading: node, + } + + walkerSection = walkerSectionExample + walkerSectionStartingLevel = node.Level + + return ast.WalkContinue, nil + } + + if result.Arguments == nil && strings.HasPrefix(headingText, "Argument") { + result.Arguments = &ArgumentsSection{ + Heading: node, + } + + walkerSection = walkerSectionArguments + walkerSectionStartingLevel = node.Level + + return ast.WalkContinue, nil + } + + if result.Attributes == nil && strings.HasPrefix(headingText, "Attribute") { + result.Attributes = &AttributesSection{ + Heading: node, + } + + walkerSection = walkerSectionAttributes + walkerSectionStartingLevel = node.Level + + return ast.WalkContinue, nil + } + + if result.Timeouts == nil && strings.HasPrefix(headingText, "Timeout") { + result.Timeouts = &TimeoutsSection{ + Heading: node, + } + + walkerSection = walkerSectionTimeouts + walkerSectionStartingLevel = node.Level + + return ast.WalkContinue, nil + } + + if result.Import == nil && strings.HasPrefix(headingText, "Import") { + result.Import = &ImportSection{ + Heading: node, + } + + walkerSection = walkerSectionImport + walkerSectionStartingLevel = node.Level + + return ast.WalkContinue, nil + } + + //fmt.Printf("(walker section level: %d) unknown heading level %d: %s\n", walkerSectionStartingLevel, node.Level, headingText) + walkerSection = walkerSectionUnknown + + return ast.WalkSkipChildren, nil + case *ast.List: + switch walkerSection { + case walkerSectionArguments: + result.Arguments.Lists = append(result.Arguments.Lists, node) + + schemaAttributeList, err := schemaAttributeListWalker(node, source) + + if err != nil { + return ast.WalkStop, err + } + + result.Arguments.SchemaAttributeLists = append(result.Arguments.SchemaAttributeLists, schemaAttributeList) + case walkerSectionAttributes: + result.Attributes.Lists = append(result.Attributes.Lists, node) + + schemaAttributeList, err := schemaAttributeListWalker(node, source) + + if err != nil { + return ast.WalkStop, err + } + + result.Attributes.SchemaAttributeLists = append(result.Attributes.SchemaAttributeLists, schemaAttributeList) + case walkerSectionTimeouts: + result.Timeouts.Lists = append(result.Timeouts.Lists, node) + } + + return ast.WalkSkipChildren, nil + case *ast.Paragraph: + switch walkerSection { + case walkerSectionTitle: + result.Title.Paragraphs = append(result.Title.Paragraphs, node) + case walkerSectionExample: + result.Example.Paragraphs = append(result.Example.Paragraphs, node) + case walkerSectionArguments: + result.Arguments.Paragraphs = append(result.Arguments.Paragraphs, node) + case walkerSectionAttributes: + result.Attributes.Paragraphs = append(result.Attributes.Paragraphs, node) + case walkerSectionTimeouts: + result.Timeouts.Paragraphs = append(result.Timeouts.Paragraphs, node) + case walkerSectionImport: + result.Import.Paragraphs = append(result.Import.Paragraphs, node) + } + + return ast.WalkSkipChildren, nil + } + + return ast.WalkContinue, nil + }) + + return result, err +} diff --git a/check/contents/testdata/arguments/missing_heading.md b/check/contents/testdata/arguments/missing_heading.md new file mode 100644 index 0000000..877aca5 --- /dev/null +++ b/check/contents/testdata/arguments/missing_heading.md @@ -0,0 +1,5 @@ +The following arguments are supported: + +* `aaa` - (Required) Aaa. +* `bbb` - (Optional) Bbb. +* `ccc` - (Optional, Forces new resource) Ccc. diff --git a/check/contents/testdata/arguments/passing.md b/check/contents/testdata/arguments/passing.md new file mode 100644 index 0000000..a3e4b03 --- /dev/null +++ b/check/contents/testdata/arguments/passing.md @@ -0,0 +1,7 @@ +## Argument Reference + +The following arguments are supported: + +* `aaa` - (Required) Aaa. +* `bbb` - (Optional) Bbb. +* `ccc` - (Optional, Forces new resource) Ccc. diff --git a/check/contents/testdata/arguments/wrong_heading_level.md b/check/contents/testdata/arguments/wrong_heading_level.md new file mode 100644 index 0000000..bb947b5 --- /dev/null +++ b/check/contents/testdata/arguments/wrong_heading_level.md @@ -0,0 +1,7 @@ +# Argument Reference + +The following arguments are supported: + +* `aaa` - (Required) Aaa. +* `bbb` - (Optional) Bbb. +* `ccc` - (Optional, Forces new resource) Ccc. diff --git a/check/contents/testdata/arguments/wrong_heading_text.md b/check/contents/testdata/arguments/wrong_heading_text.md new file mode 100644 index 0000000..17bf83c --- /dev/null +++ b/check/contents/testdata/arguments/wrong_heading_text.md @@ -0,0 +1,7 @@ +## Arguments + +The following arguments are supported: + +* `aaa` - (Required) Aaa. +* `bbb` - (Optional) Bbb. +* `ccc` - (Optional, Forces new resource) Ccc. diff --git a/check/contents/testdata/arguments/wrong_list_order.md b/check/contents/testdata/arguments/wrong_list_order.md new file mode 100644 index 0000000..f70fc5d --- /dev/null +++ b/check/contents/testdata/arguments/wrong_list_order.md @@ -0,0 +1,7 @@ +## Argument Reference + +The following arguments are supported: + +* `bbb` - (Optional) Bbb. +* `aaa` - (Required) Aaa. +* `ccc` - (Optional, Forces new resource) Ccc. diff --git a/check/contents/testdata/attributes/missing_byline.md b/check/contents/testdata/attributes/missing_byline.md new file mode 100644 index 0000000..df93f37 --- /dev/null +++ b/check/contents/testdata/attributes/missing_byline.md @@ -0,0 +1,5 @@ +## Attributes Reference + +* `aaa` - Aaa. +* `bbb` - Bbb. +* `ccc` - Ccc. diff --git a/check/contents/testdata/attributes/missing_heading.md b/check/contents/testdata/attributes/missing_heading.md new file mode 100644 index 0000000..9fdc1d2 --- /dev/null +++ b/check/contents/testdata/attributes/missing_heading.md @@ -0,0 +1,5 @@ +In addition to all arguments above, the following attributes are exported: + +* `aaa` - Aaa. +* `bbb` - Bbb. +* `ccc` - Ccc. diff --git a/check/contents/testdata/attributes/passing.md b/check/contents/testdata/attributes/passing.md new file mode 100644 index 0000000..c5096a2 --- /dev/null +++ b/check/contents/testdata/attributes/passing.md @@ -0,0 +1,7 @@ +## Attributes Reference + +In addition to all arguments above, the following attributes are exported: + +* `aaa` - Aaa. +* `bbb` - Bbb. +* `ccc` - Ccc. diff --git a/check/contents/testdata/attributes/wrong_byline.md b/check/contents/testdata/attributes/wrong_byline.md new file mode 100644 index 0000000..f843f01 --- /dev/null +++ b/check/contents/testdata/attributes/wrong_byline.md @@ -0,0 +1,7 @@ +## Attributes Reference + +The following attributes are exported: + +* `aaa` - Aaa. +* `bbb` - Bbb. +* `ccc` - Ccc. diff --git a/check/contents/testdata/attributes/wrong_heading_level.md b/check/contents/testdata/attributes/wrong_heading_level.md new file mode 100644 index 0000000..40985ce --- /dev/null +++ b/check/contents/testdata/attributes/wrong_heading_level.md @@ -0,0 +1,7 @@ +# Attributes Reference + +In addition to all arguments above, the following attributes are exported: + +* `aaa` - Aaa. +* `bbb` - Bbb. +* `ccc` - Ccc. diff --git a/check/contents/testdata/attributes/wrong_heading_text.md b/check/contents/testdata/attributes/wrong_heading_text.md new file mode 100644 index 0000000..7008c37 --- /dev/null +++ b/check/contents/testdata/attributes/wrong_heading_text.md @@ -0,0 +1,7 @@ +## Attributes + +In addition to all arguments above, the following attributes are exported: + +* `aaa` - Aaa. +* `bbb` - Bbb. +* `ccc` - Ccc. diff --git a/check/contents/testdata/attributes/wrong_list_order.md b/check/contents/testdata/attributes/wrong_list_order.md new file mode 100644 index 0000000..a30147d --- /dev/null +++ b/check/contents/testdata/attributes/wrong_list_order.md @@ -0,0 +1,7 @@ +## Attributes Reference + +In addition to all arguments above, the following attributes are exported: + +* `bbb` - Bbb. +* `aaa` - Aaa. +* `ccc` - Ccc. diff --git a/check/contents/testdata/empty.md b/check/contents/testdata/empty.md new file mode 100644 index 0000000..e69de29 diff --git a/check/contents/testdata/example/missing_code_block_language.md b/check/contents/testdata/example/missing_code_block_language.md new file mode 100644 index 0000000..e8969c8 --- /dev/null +++ b/check/contents/testdata/example/missing_code_block_language.md @@ -0,0 +1,7 @@ +## Example Usage + +``` +resource "test_missing_code_block_language" "example" { + name = "example" +} +``` diff --git a/check/contents/testdata/example/missing_heading.md b/check/contents/testdata/example/missing_heading.md new file mode 100644 index 0000000..f5b1bc1 --- /dev/null +++ b/check/contents/testdata/example/missing_heading.md @@ -0,0 +1,7 @@ +Manages an Example Thing. + +```hcl +resource "test_missing_heading" "example" { + name = "example" +} +``` diff --git a/check/contents/testdata/example/passing.md b/check/contents/testdata/example/passing.md new file mode 100644 index 0000000..449199c --- /dev/null +++ b/check/contents/testdata/example/passing.md @@ -0,0 +1,7 @@ +## Example Usage + +```hcl +resource "test_passing" "example" { + name = "example" +} +``` diff --git a/check/contents/testdata/example/wrong_code_block_language.md b/check/contents/testdata/example/wrong_code_block_language.md new file mode 100644 index 0000000..a306f60 --- /dev/null +++ b/check/contents/testdata/example/wrong_code_block_language.md @@ -0,0 +1,7 @@ +## Example Usage + +```tf +resource "test_wrong_code_block_language" "example" { + name = "example" +} +``` diff --git a/check/contents/testdata/example/wrong_heading_level.md b/check/contents/testdata/example/wrong_heading_level.md new file mode 100644 index 0000000..7300b4d --- /dev/null +++ b/check/contents/testdata/example/wrong_heading_level.md @@ -0,0 +1,7 @@ +# Example Usage + +```hcl +resource "test_wrong_heading_level" "example" { + name = "example" +} +``` diff --git a/check/contents/testdata/example/wrong_heading_text.md b/check/contents/testdata/example/wrong_heading_text.md new file mode 100644 index 0000000..9759150 --- /dev/null +++ b/check/contents/testdata/example/wrong_heading_text.md @@ -0,0 +1,7 @@ +## Examples + +```hcl +resource "test_wrong_heading_text" "example" { + name = "example" +} +``` diff --git a/check/contents/testdata/full.md b/check/contents/testdata/full.md new file mode 100644 index 0000000..f71e670 --- /dev/null +++ b/check/contents/testdata/full.md @@ -0,0 +1,48 @@ +--- +subcategory: "Test Full" +layout: "test" +page_title: "Test: test_full" +description: |- + Manages a Test Full +--- + +# Resource: test_full + +Manages a Test Full. + +## Example Usage + +```hcl +resource "test_full" "example" { + name = "example" +} +``` + +## Argument Reference + +The following arguments are supported: + +* `name` - (Required) Name of thing. +* `tags` - (Optional) Key-value map of resource tags. +* `type` - (Optional) Type of thing. + +## Attributes Reference + +In addition to all arguments above, the following attributes are exported: + +* `id` - Name of thing. + +## Timeouts + +`test_full` provides the following [Timeouts](/docs/configuration/resources.html#timeouts) +configuration options: + +* `create` - (Default `10m`) How long to wait for the thing to be created. + +## Import + +Test Fulls can be imported using the `name`, e.g. + +``` +$ terraform import test_full.example example +``` diff --git a/check/contents/testdata/import/passing.md b/check/contents/testdata/import/passing.md new file mode 100644 index 0000000..bfec434 --- /dev/null +++ b/check/contents/testdata/import/passing.md @@ -0,0 +1,7 @@ +## Import + +Test Passings can be imported using the `name`, e.g. + +``` +$ terraform import test_passing.example example +``` diff --git a/check/contents/testdata/import/wrong_code_block_resource_type.md b/check/contents/testdata/import/wrong_code_block_resource_type.md new file mode 100644 index 0000000..d8f66ea --- /dev/null +++ b/check/contents/testdata/import/wrong_code_block_resource_type.md @@ -0,0 +1,7 @@ +## Import + +Test Wrong Code Block Resource Types can be imported using the `name`, e.g. + +``` +$ terraform import test_passing.example example +``` diff --git a/check/contents/testdata/import/wrong_heading_level.md b/check/contents/testdata/import/wrong_heading_level.md new file mode 100644 index 0000000..48bcda5 --- /dev/null +++ b/check/contents/testdata/import/wrong_heading_level.md @@ -0,0 +1,7 @@ +# Import + +Test Wrong Heading Levels can be imported using the `name`, e.g. + +``` +$ terraform import test_wrong_heading_level.example example +``` diff --git a/check/contents/testdata/import/wrong_heading_text.md b/check/contents/testdata/import/wrong_heading_text.md new file mode 100644 index 0000000..4cfd87c --- /dev/null +++ b/check/contents/testdata/import/wrong_heading_text.md @@ -0,0 +1,7 @@ +## Importing + +Test Wrong Heading Texts can be imported using the `name`, e.g. + +``` +$ terraform import test_wrong_heading_text.example example +``` diff --git a/check/contents/testdata/timeouts/passing.md b/check/contents/testdata/timeouts/passing.md new file mode 100644 index 0000000..bf24524 --- /dev/null +++ b/check/contents/testdata/timeouts/passing.md @@ -0,0 +1,6 @@ +## Timeouts + +`example_thing` provides the following [Timeouts](/docs/configuration/resources.html#timeouts) +configuration options: + +* `create` - (Default `10m`) How long to wait for the thing to be created. diff --git a/check/contents/testdata/title/missing_heading.md b/check/contents/testdata/title/missing_heading.md new file mode 100644 index 0000000..21fd30a --- /dev/null +++ b/check/contents/testdata/title/missing_heading.md @@ -0,0 +1,9 @@ +Manages an Example Thing. + +## Example Usage + +```hcl +resource "example_thing" "example" { + name = "example" +} +``` diff --git a/check/contents/testdata/title/missing_heading_resource_type.md b/check/contents/testdata/title/missing_heading_resource_type.md new file mode 100644 index 0000000..408643a --- /dev/null +++ b/check/contents/testdata/title/missing_heading_resource_type.md @@ -0,0 +1,3 @@ +# test_missing_resource_type + +Manages an Example Thing. diff --git a/check/contents/testdata/title/passing.md b/check/contents/testdata/title/passing.md new file mode 100644 index 0000000..2d1c4f3 --- /dev/null +++ b/check/contents/testdata/title/passing.md @@ -0,0 +1,11 @@ +# Resource: test_passing + +Manages an Example Thing. + +## Example Usage + +```hcl +resource "example_thing" "example" { + name = "example" +} +``` diff --git a/check/contents/testdata/title/wrong_code_block_section.md b/check/contents/testdata/title/wrong_code_block_section.md new file mode 100644 index 0000000..0e0b5d7 --- /dev/null +++ b/check/contents/testdata/title/wrong_code_block_section.md @@ -0,0 +1,9 @@ +# Resource: test_wrong_code_block_section + +Manages an Example Thing. + +```hcl +resource "example_thing" "example" { + name = "example" +} +``` diff --git a/check/contents/testdata/title/wrong_heading_level.md b/check/contents/testdata/title/wrong_heading_level.md new file mode 100644 index 0000000..821402f --- /dev/null +++ b/check/contents/testdata/title/wrong_heading_level.md @@ -0,0 +1,3 @@ +## Resource: test_wrong_heading_level + +Manages an Example Thing. diff --git a/check/contents/testdata/title/wrong_resource_in_heading.md b/check/contents/testdata/title/wrong_resource_in_heading.md new file mode 100644 index 0000000..10a731d --- /dev/null +++ b/check/contents/testdata/title/wrong_resource_in_heading.md @@ -0,0 +1,3 @@ +# Resource: test_thing + +Manages an Example Thing. diff --git a/check/legacy_resource_file.go b/check/legacy_resource_file.go index e6dfaf6..b3bead5 100644 --- a/check/legacy_resource_file.go +++ b/check/legacy_resource_file.go @@ -11,7 +11,9 @@ import ( type LegacyResourceFileOptions struct { *FileOptions - FrontMatter *FrontMatterOptions + Contents *ContentsOptions + FrontMatter *FrontMatterOptions + ProviderName string } type LegacyResourceFileCheck struct { @@ -29,6 +31,14 @@ func NewLegacyResourceFileCheck(opts *LegacyResourceFileOptions) *LegacyResource check.Options = &LegacyResourceFileOptions{} } + if check.Options.Contents == nil { + check.Options.Contents = &ContentsOptions{} + } + + if check.Options.Contents.ProviderName == "" { + check.Options.Contents.ProviderName = check.Options.ProviderName + } + if check.Options.FileOptions == nil { check.Options.FileOptions = &FileOptions{} } @@ -68,6 +78,10 @@ func (check *LegacyResourceFileCheck) Run(path string) error { return fmt.Errorf("%s: error checking file frontmatter: %w", path, err) } + if err := NewContentsCheck(check.Options.Contents).Run(fullpath); err != nil { + return fmt.Errorf("%s: error checking file contents: %w", path, err) + } + return nil } diff --git a/check/registry_resource_file.go b/check/registry_resource_file.go index 170ccdb..e2c0ad0 100644 --- a/check/registry_resource_file.go +++ b/check/registry_resource_file.go @@ -11,7 +11,9 @@ import ( type RegistryResourceFileOptions struct { *FileOptions - FrontMatter *FrontMatterOptions + Contents *ContentsOptions + FrontMatter *FrontMatterOptions + ProviderName string } type RegistryResourceFileCheck struct { @@ -29,6 +31,14 @@ func NewRegistryResourceFileCheck(opts *RegistryResourceFileOptions) *RegistryRe check.Options = &RegistryResourceFileOptions{} } + if check.Options.Contents == nil { + check.Options.Contents = &ContentsOptions{} + } + + if check.Options.Contents.ProviderName == "" { + check.Options.Contents.ProviderName = check.Options.ProviderName + } + if check.Options.FileOptions == nil { check.Options.FileOptions = &FileOptions{} } @@ -66,6 +76,10 @@ func (check *RegistryResourceFileCheck) Run(path string) error { return fmt.Errorf("%s: error checking file frontmatter: %w", path, err) } + if err := NewContentsCheck(check.Options.Contents).Run(fullpath); err != nil { + return fmt.Errorf("%s: error checking file contents: %w", path, err) + } + return nil } diff --git a/command/check.go b/command/check.go index c5a305b..b5031f4 100644 --- a/command/check.go +++ b/command/check.go @@ -24,6 +24,7 @@ type CheckCommandConfig struct { AllowedGuideSubcategoriesFile string AllowedResourceSubcategories string AllowedResourceSubcategoriesFile string + EnableContentsCheck bool IgnoreFileMismatchDataSources string IgnoreFileMismatchResources string IgnoreFileMissingDataSources string @@ -36,6 +37,7 @@ type CheckCommandConfig struct { ProvidersSchemaJson string RequireGuideSubcategory bool RequireResourceSubcategory bool + RequireSchemaOrdering bool RequireSideNavigation bool } @@ -52,6 +54,7 @@ func (*CheckCommand) Help() string { fmt.Fprintf(opts, CommandHelpOptionFormat, "-allowed-guide-subcategories-file", "Path to newline separated file of allowed guide frontmatter subcategories.") fmt.Fprintf(opts, CommandHelpOptionFormat, "-allowed-resource-subcategories", "Comma separated list of allowed data source and resource frontmatter subcategories.") fmt.Fprintf(opts, CommandHelpOptionFormat, "-allowed-resource-subcategories-file", "Path to newline separated file of allowed data source and resource frontmatter subcategories.") + fmt.Fprintf(opts, CommandHelpOptionFormat, "-enable-contents-check", "(Experimental) Enable contents checking.") fmt.Fprintf(opts, CommandHelpOptionFormat, "-ignore-file-mismatch-data-sources", "Comma separated list of data sources to ignore mismatched/extra files.") fmt.Fprintf(opts, CommandHelpOptionFormat, "-ignore-file-mismatch-resources", "Comma separated list of resources to ignore mismatched/extra files.") fmt.Fprintf(opts, CommandHelpOptionFormat, "-ignore-file-missing-data-sources", "Comma separated list of data sources to ignore missing files.") @@ -63,6 +66,7 @@ func (*CheckCommand) Help() string { fmt.Fprintf(opts, CommandHelpOptionFormat, "-require-guide-subcategory", "Require guide frontmatter subcategory.") fmt.Fprintf(opts, CommandHelpOptionFormat, "-require-resource-subcategory", "Require data source and resource frontmatter subcategory.") fmt.Fprintf(opts, CommandHelpOptionFormat, "-require-side-navigation", "Require side navigation (legacy terraform.io ERB file).") + fmt.Fprintf(opts, CommandHelpOptionFormat, "-require-schema-ordering", "Require schema attribute lists to be alphabetically ordered (requires -enable-contents-check).") opts.Flush() helpText := fmt.Sprintf(` @@ -90,6 +94,7 @@ func (c *CheckCommand) Run(args []string) int { flags.StringVar(&config.AllowedGuideSubcategoriesFile, "allowed-guide-subcategories-file", "", "") flags.StringVar(&config.AllowedResourceSubcategories, "allowed-resource-subcategories", "", "") flags.StringVar(&config.AllowedResourceSubcategoriesFile, "allowed-resource-subcategories-file", "", "") + flags.BoolVar(&config.EnableContentsCheck, "enable-contents-check", false, "") flags.StringVar(&config.IgnoreFileMismatchDataSources, "ignore-file-mismatch-data-sources", "", "") flags.StringVar(&config.IgnoreFileMismatchResources, "ignore-file-mismatch-resources", "", "") flags.StringVar(&config.IgnoreFileMissingDataSources, "ignore-file-missing-data-sources", "", "") @@ -100,6 +105,7 @@ func (c *CheckCommand) Run(args []string) int { flags.StringVar(&config.ProvidersSchemaJson, "providers-schema-json", "", "") flags.BoolVar(&config.RequireGuideSubcategory, "require-guide-subcategory", false, "") flags.BoolVar(&config.RequireResourceSubcategory, "require-resource-subcategory", false, "") + flags.BoolVar(&config.RequireSchemaOrdering, "require-schema-ordering", false, "") flags.BoolVar(&config.RequireSideNavigation, "require-side-navigation", false, "") if err := flags.Parse(args); err != nil { @@ -123,7 +129,7 @@ func (c *CheckCommand) Run(args []string) int { } if config.ProviderName == "" { - log.Printf("[WARN] Unable to determine provider name. Enhanced validations may fail.") + log.Printf("[WARN] Unable to determine provider name. Contents and enhanced validations may fail.") } else { log.Printf("[DEBUG] Found provider name: %s", config.ProviderName) } @@ -256,11 +262,16 @@ Check that the current working directory or provided path is prefixed with terra FileOptions: fileOpts, }, LegacyResourceFile: &check.LegacyResourceFileOptions{ + Contents: &check.ContentsOptions{ + Enable: config.EnableContentsCheck, + RequireSchemaOrdering: config.RequireSchemaOrdering, + }, FileOptions: fileOpts, FrontMatter: &check.FrontMatterOptions{ AllowedSubcategories: allowedResourceSubcategories, RequireSubcategory: config.RequireResourceSubcategory, }, + ProviderName: config.ProviderName, }, ProviderName: config.ProviderName, RegistryDataSourceFile: &check.RegistryDataSourceFileOptions{ @@ -281,11 +292,16 @@ Check that the current working directory or provided path is prefixed with terra FileOptions: fileOpts, }, RegistryResourceFile: &check.RegistryResourceFileOptions{ + Contents: &check.ContentsOptions{ + Enable: config.EnableContentsCheck, + RequireSchemaOrdering: config.RequireSchemaOrdering, + }, FileOptions: fileOpts, FrontMatter: &check.FrontMatterOptions{ AllowedSubcategories: allowedResourceSubcategories, RequireSubcategory: config.RequireResourceSubcategory, }, + ProviderName: config.ProviderName, }, ResourceFileMismatch: &check.FileMismatchOptions{ IgnoreFileMismatch: ignoreFileMismatchResources, diff --git a/go.mod b/go.mod index 94e8953..badbae2 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,8 @@ require ( github.com/hashicorp/terraform-json v0.5.0 github.com/mattn/go-colorable v0.1.4 github.com/mitchellh/cli v1.0.0 + github.com/yuin/goldmark v1.2.1 + github.com/yuin/goldmark-meta v0.0.0-20191126180153-f0638e958b60 golang.org/x/net v0.0.0-20180811021610-c39426892332 gopkg.in/yaml.v2 v2.2.7 ) diff --git a/go.sum b/go.sum index ed5ba54..fc7f76b 100644 --- a/go.sum +++ b/go.sum @@ -42,6 +42,11 @@ github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndr github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/vmihailenco/msgpack v3.3.3+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= +github.com/yuin/goldmark v1.1.7/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1 h1:ruQGxdhGHe7FWOJPT0mKs5+pD2Xs1Bm/kdGlHO04FmM= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark-meta v0.0.0-20191126180153-f0638e958b60 h1:gZucqLjL1eDzVWrXj4uiWeMbAopJlBR2mKQAsTGdPwo= +github.com/yuin/goldmark-meta v0.0.0-20191126180153-f0638e958b60/go.mod h1:i9VhcIHN2PxXMbQrKqXNueok6QNONoPjNMoj9MygVL0= github.com/zclconf/go-cty v1.2.1 h1:vGMsygfmeCl4Xb6OA5U5XVAaQZ69FvoG7X2jUtQujb8= github.com/zclconf/go-cty v1.2.1/go.mod h1:hOPWgoHbaTUnI5k4D2ld+GRpFJSCe6bCM7m1q/N4PQ8= golang.org/x/net v0.0.0-20180811021610-c39426892332 h1:efGso+ep0DjyCBJPjvoz0HI6UldX4Md2F1rZFe1ir0E= @@ -58,5 +63,6 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.7 h1:VUgggvou5XRW9mHwD/yXxIYSMtY0zoKQf/v226p2nyo= gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/markdown/fenced_code_block.go b/markdown/fenced_code_block.go new file mode 100644 index 0000000..7c0557a --- /dev/null +++ b/markdown/fenced_code_block.go @@ -0,0 +1,45 @@ +package markdown + +import ( + "strings" + + "github.com/yuin/goldmark/ast" +) + +const ( + FencedCodeBlockLanguageHcl = "hcl" + FencedCodeBlockLanguageMissing = "MISSING" + FencedCodeBlockLanguageTerraform = "terraform" +) + +// FencedCodeBlockLanguage returns the language or "MISSING" +func FencedCodeBlockLanguage(fcb *ast.FencedCodeBlock, source []byte) string { + if fcb == nil { + return FencedCodeBlockLanguageMissing + } + + language := string(fcb.Language(source)) + + if language == "" { + return FencedCodeBlockLanguageMissing + } + + return language +} + +// FencedCodeBlockText returns the text +func FencedCodeBlockText(fcb *ast.FencedCodeBlock, source []byte) string { + if fcb == nil { + return "" + } + + lines := fcb.Lines() + var builder strings.Builder + + for i := 0; i < lines.Len(); i++ { + segment := lines.At(i) + builder.WriteString(string(segment.Value(source))) + } + + return strings.TrimSpace(builder.String()) +} diff --git a/markdown/parse.go b/markdown/parse.go new file mode 100644 index 0000000..b77e356 --- /dev/null +++ b/markdown/parse.go @@ -0,0 +1,25 @@ +package markdown + +import ( + "github.com/yuin/goldmark" + meta "github.com/yuin/goldmark-meta" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/text" +) + +// Parse converts a Markdown source into AST and metadata +func Parse(source []byte) (ast.Node, map[string]interface{}) { + markdown := goldmark.New( + goldmark.WithExtensions( + meta.New(), + ), + ) + + context := parser.NewContext() + reader := text.NewReader(source) + document := markdown.Parser().Parse(reader, parser.WithContext(context)) + metadata := meta.Get(context) + + return document, metadata +}