diff --git a/pkg/tfgen/convert_cli.go b/pkg/tfgen/convert_cli.go index 41d6e014f..fee442e87 100644 --- a/pkg/tfgen/convert_cli.go +++ b/pkg/tfgen/convert_cli.go @@ -45,6 +45,10 @@ import ( "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/tfgen/internal/autofill" ) +const ( + exampleUnavailable = "Example currently unavailable in this language\n" +) + func cliConverterEnabled() bool { return cmdutil.IsTruthy(os.Getenv("PULUMI_CONVERT")) } @@ -645,14 +649,14 @@ func (cc *cliConverter) singleExampleFromHCLToPCL(path, hclCode string) (transla func (cc *cliConverter) singleExampleFromPCLToLanguage(example translatedExample, lang string) (string, error) { var err error - if example.PCL == "" { - return "", nil - } source, diags, _ := cc.convertPCL(example.PCL, lang) diags = cc.postProcessDiagnostics(diags.Extend(example.Diagnostics)) if diags.HasErrors() { - source = "Example currently unavailable in this language\n" - err = fmt.Errorf("failed to convert an example: %s", diags.Error()) + err = fmt.Errorf("conversion errors: %s", diags.Error()) + } + + if source == "" { + source = exampleUnavailable } source = "```" + lang + "\n" + source + "```" return source, err diff --git a/pkg/tfgen/installation_docs.go b/pkg/tfgen/installation_docs.go index 42d6c9773..0fe40067a 100644 --- a/pkg/tfgen/installation_docs.go +++ b/pkg/tfgen/installation_docs.go @@ -66,9 +66,15 @@ func plainDocsParser(docFile *DocFile, g *Generator) ([]byte, error) { return nil, err } + // If the code translation resulted in an empty examples section, remove it + content, err = removeEmptySection("Example Usage", []byte(contentStr)) + if err != nil { + return nil, err + } + // Apply post-code translation edit rules. This applies all default edit rules and provider-supplied edit rules in // the post-code translation phase. - contentBytes, err = g.editRules.apply(docFile.FileName, []byte(contentStr), info.PostCodeTranslation) + contentBytes, err = g.editRules.apply(docFile.FileName, content, info.PostCodeTranslation) if err != nil { return nil, err } @@ -221,9 +227,6 @@ func translateCodeBlocks(contentStr string, g *Generator) (string, error) { // This function renders the Pulumi.yaml config file for a given language if configuration is included in the example. func processConfigYaml(pulumiYAML, lang string) string { - if pulumiYAML == "" { - return pulumiYAML - } // Replace the project name from the default `/` to a more descriptive name nameRegex := regexp.MustCompile(`name: /*`) pulumiYAMLFile := nameRegex.ReplaceAllString(pulumiYAML, "name: configuration-example") @@ -253,6 +256,19 @@ func convertExample(g *Generator, code string, exampleNumber int) (string, error return "", err } + // If both PCL and PulumiYAML fields are empty, we can return. + if pclExample.PulumiYAML == "" && pclExample.PCL == "" { + return "", nil + } + + // If we have a valid provider config but no additional code, we only render a YAML configuration block + // with no choosers and an empty language runtime field + if pclExample.PulumiYAML != "" && pclExample.PCL == "" { + if pclExample.PCL == "" { + return processConfigYaml(pclExample.PulumiYAML, ""), nil + } + } + langs := genLanguageToSlice(g.language) const ( chooserStart = `{{< chooser language "typescript,python,go,csharp,java,yaml" >}}` + "\n" @@ -260,23 +276,33 @@ func convertExample(g *Generator, code string, exampleNumber int) (string, error choosableEnd = "\n{{% /choosable %}}\n" ) exampleContent := chooserStart + successfulConversion := false // Generate each language in turn and mark up the output with the correct Hugo shortcodes. for _, lang := range langs { choosableStart := fmt.Sprintf("{{%% choosable language %s %%}}\n", lang) // Generate the Pulumi.yaml config file for each language - configFile := pclExample.PulumiYAML - pulumiYAML := processConfigYaml(configFile, lang) + var pulumiYAML string + if pclExample.PulumiYAML != "" { + pulumiYAML = processConfigYaml(pclExample.PulumiYAML, lang) + } + // Generate language example convertedLang, err := converter.singleExampleFromPCLToLanguage(pclExample, lang) if err != nil { g.warn(err.Error()) } + if convertedLang != exampleUnavailable { + successfulConversion = true + } exampleContent += choosableStart + pulumiYAML + convertedLang + choosableEnd } - exampleContent += chooserEnd - return exampleContent, nil + + if successfulConversion { + return exampleContent + chooserEnd, nil + } + return "", nil } type titleRemover struct{} @@ -477,3 +503,35 @@ func getProviderDisplayName(g *Generator) string { capitalize := cases.Title(language.English) return capitalize.String(providerName) } + +func removeEmptySection(title string, contentBytes []byte) ([]byte, error) { + if !isMarkdownSectionEmpty(title, contentBytes) { + return contentBytes, nil + } + return SkipSectionByHeaderContent(contentBytes, func(headerText string) bool { + return headerText == title + }) +} + +func isMarkdownSectionEmpty(title string, contentBytes []byte) bool { + gm := goldmark.New(goldmark.WithExtensions(parse.TFRegistryExtension)) + astNode := gm.Parser().Parse(text.NewReader(contentBytes)) + + isEmpty := false + + err := ast.Walk(astNode, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if section, ok := n.(*section.Section); ok && entering { + sectionText := section.Text(contentBytes) + // A little confusingly, we check if the section text _only_ contains the title, "Example Usage". + // Non-empty sections contain the title + content, so if we see only the title, the section is empty. + if string(sectionText) == title { + isEmpty = true + return ast.WalkStop, nil + } + } + return ast.WalkContinue, nil + }) + contract.AssertNoErrorf(err, "impossible: ast.Walk should never error") + + return isEmpty +} diff --git a/pkg/tfgen/installation_docs_test.go b/pkg/tfgen/installation_docs_test.go index 9b3c8b44b..da658897f 100644 --- a/pkg/tfgen/installation_docs_test.go +++ b/pkg/tfgen/installation_docs_test.go @@ -449,32 +449,78 @@ func TestTranslateCodeBlocks(t *testing.T) { } pclsMap := make(map[string]translatedExample) - tc := testCase{ - name: "Translates HCL from examples ", - contentStr: readfile(t, "test_data/installation-docs/configuration.md"), - expected: readfile(t, "test_data/installation-docs/configuration-expected.md"), - g: &Generator{ - sink: mockSink{}, - cliConverterState: &cliConverter{ - info: p, - pcls: pclsMap, + testCases := []testCase{ + { + name: "Translates HCL from examples ", + contentStr: readfile(t, "test_data/installation-docs/configuration.md"), + expected: readfile(t, "test_data/installation-docs/configuration-expected.md"), + g: &Generator{ + sink: mockSink{}, + cliConverterState: &cliConverter{ + info: p, + pcls: pclsMap, + }, + language: RegistryDocs, + }, + }, + { + name: "Does not translate an invalid example and leaves example block blank", + contentStr: readfile(t, "test_data/installation-docs/invalid-example.md"), + expected: readfile(t, "test_data/installation-docs/invalid-example-expected.md"), + g: &Generator{ + sink: mockSink{}, + cliConverterState: &cliConverter{ + info: p, + pcls: pclsMap, + }, + language: RegistryDocs, + }, + }, + { + name: "Translates standalone provider config into Pulumi config YAML", + contentStr: readfile(t, "test_data/installation-docs/provider-config-only.md"), + expected: readfile(t, "test_data/installation-docs/provider-config-only-expected.md"), + g: &Generator{ + sink: mockSink{}, + cliConverterState: &cliConverter{ + info: p, + pcls: pclsMap, + }, + language: RegistryDocs, + }, + }, + { + name: "Translates standalone example into languages", + contentStr: readfile(t, "test_data/installation-docs/example-only.md"), + expected: readfile(t, "test_data/installation-docs/example-only-expected.md"), + g: &Generator{ + sink: mockSink{}, + cliConverterState: &cliConverter{ + info: p, + pcls: pclsMap, + }, + language: RegistryDocs, }, - language: RegistryDocs, }, } - t.Run(tc.name, func(t *testing.T) { - if runtime.GOOS == "windows" { - // Currently there is a test issue in CI/test setup: - // - // convertViaPulumiCLI: failed to clean up temp bridge-examples.json file: The - // process cannot access the file because it is being used by another process. - t.Skipf("Skipping on Windows due to a test setup issue") - } - t.Setenv("PULUMI_CONVERT", "1") - actual, err := translateCodeBlocks(tc.contentStr, tc.g) - require.NoError(t, err) - require.Equal(t, tc.expected, actual) - }) + + for _, tt := range testCases { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + if runtime.GOOS == "windows" { + // Currently there is a test issue in CI/test setup: + // + // convertViaPulumiCLI: failed to clean up temp bridge-examples.json file: The + // process cannot access the file because it is being used by another process. + t.Skipf("Skipping on Windows due to a test setup issue") + } + t.Setenv("PULUMI_CONVERT", "1") + actual, err := translateCodeBlocks(tt.contentStr, tt.g) + require.NoError(t, err) + require.Equal(t, tt.expected, actual) + }) + } } func TestSkipSectionHeadersByContent(t *testing.T) { @@ -599,6 +645,28 @@ func TestSkipDefaultSectionHeaders(t *testing.T) { } } +func TestRemoveEmptyExamples(t *testing.T) { + t.Parallel() + type testCase struct { + name string + input string + expected string + } + + tc := testCase{ + name: "An empty Example Usage section is skipped", + input: readTestFile(t, "skip-empty-examples/input.md"), + expected: readTestFile(t, "skip-empty-examples/expected.md"), + } + + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + actual, err := removeEmptySection("Example Usage", []byte(tc.input)) + require.NoError(t, err) + assertEqualHTML(t, tc.expected, string(actual)) + }) +} + // Helper func to determine if the HTML rendering is equal. // This helps in cases where the processed Markdown is slightly different from the expected Markdown // due to goldmark making some (insignificant to the final HTML) changes when parsing and rendering. diff --git a/pkg/tfgen/test_data/installation-docs/example-only-expected.md b/pkg/tfgen/test_data/installation-docs/example-only-expected.md new file mode 100644 index 000000000..d31c827a2 --- /dev/null +++ b/pkg/tfgen/test_data/installation-docs/example-only-expected.md @@ -0,0 +1,132 @@ +This example will only translate the resource code. It has no configuration file. + +## Example Usage + +{{< chooser language "typescript,python,go,csharp,java,yaml" >}} +{{% choosable language typescript %}} +```typescript +import * as pulumi from "@pulumi/pulumi"; +import * as simple from "@pulumi/simple"; + +//# Define a resource +const aResource = new simple.index.Resource("a_resource", { + renamedInput1: "hello", + inputTwo: true, +}); +export const someOutput = aResource.result; +``` +{{% /choosable %}} +{{% choosable language python %}} +```python +import pulumi +import pulumi_simple as simple + +## Define a resource +a_resource = simple.index.Resource("a_resource", + renamed_input1=hello, + input_two=True) +pulumi.export("someOutput", a_resource["result"]) +``` +{{% /choosable %}} +{{% choosable language csharp %}} +```csharp +using System.Collections.Generic; +using System.Linq; +using Pulumi; +using Simple = Pulumi.Simple; + +return await Deployment.RunAsync(() => +{ + //# Define a resource + var aResource = new Simple.Index.Resource("a_resource", new() + { + RenamedInput1 = "hello", + InputTwo = true, + }); + + return new Dictionary + { + ["someOutput"] = aResource.Result, + }; +}); + +``` +{{% /choosable %}} +{{% choosable language go %}} +```go +package main + +import ( + "github.com/pulumi/pulumi-simple/sdk/go/simple" + "github.com/pulumi/pulumi/sdk/v3/go/pulumi" +) + +func main() { + pulumi.Run(func(ctx *pulumi.Context) error { + // # Define a resource + aResource, err := simple.NewResource(ctx, "a_resource", &simple.ResourceArgs{ + RenamedInput1: "hello", + InputTwo: true, + }) + if err != nil { + return err + } + ctx.Export("someOutput", aResource.Result) + return nil + }) +} +``` +{{% /choosable %}} +{{% choosable language yaml %}} +```yaml +resources: + ## Define a resource + aResource: + type: simple:resource + name: a_resource + properties: + renamedInput1: hello + inputTwo: true +outputs: + someOutput: ${aResource.result} +``` +{{% /choosable %}} +{{% choosable language java %}} +```java +package generated_program; + +import com.pulumi.Context; +import com.pulumi.Pulumi; +import com.pulumi.core.Output; +import com.pulumi.simple.resource; +import com.pulumi.simple.ResourceArgs; +import java.util.List; +import java.util.ArrayList; +import java.util.Map; +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Paths; + +public class App { + public static void main(String[] args) { + Pulumi.run(App::stack); + } + + public static void stack(Context ctx) { + //# Define a resource + var aResource = new Resource("aResource", ResourceArgs.builder() + .renamedInput1("hello") + .inputTwo(true) + .build()); + + ctx.export("someOutput", aResource.result()); + } +} +``` +{{% /choosable %}} +{{< /chooser >}} + + +## Configuration Reference + +The following configuration inputs are supported: diff --git a/pkg/tfgen/test_data/installation-docs/example-only.md b/pkg/tfgen/test_data/installation-docs/example-only.md new file mode 100644 index 000000000..e645676aa --- /dev/null +++ b/pkg/tfgen/test_data/installation-docs/example-only.md @@ -0,0 +1,19 @@ +This example will only translate the resource code. It has no configuration file. + +## Example Usage + +```hcl +## Define a resource +resource "simple_resource" "a_resource" { + input_one = "hello" + input_two = true +} + +output "some_output" { + value = simple_resource.a_resource.result +} +``` + +## Configuration Reference + +The following configuration inputs are supported: diff --git a/pkg/tfgen/test_data/installation-docs/invalid-example-expected.md b/pkg/tfgen/test_data/installation-docs/invalid-example-expected.md new file mode 100644 index 000000000..86320c991 --- /dev/null +++ b/pkg/tfgen/test_data/installation-docs/invalid-example-expected.md @@ -0,0 +1,9 @@ +This example is invalid and should not be translated for this test to pass + +## Example Usage + + + +## Configuration Reference + +The following configuration inputs are supported: diff --git a/pkg/tfgen/test_data/installation-docs/invalid-example.md b/pkg/tfgen/test_data/installation-docs/invalid-example.md new file mode 100644 index 000000000..bdec24a2d --- /dev/null +++ b/pkg/tfgen/test_data/installation-docs/invalid-example.md @@ -0,0 +1,27 @@ +This example is invalid and should not be translated for this test to pass + +## Example Usage + +```hcl +# Configure the OpenStack Provider +misspelledprovider "simple-provider" { + user_name = "admin" + tenant_name = "admin" + password = "pwd" + auth_url = "http://myauthurl:5000/v3" + region = "RegionOne" +} +## Define a resource +resource "simple_resource" "a_resource" { + input_one = "hello" + input_two = true +} + +output "some_output" { + value = simple_resource.a_resource.result +} +``` + +## Configuration Reference + +The following configuration inputs are supported: diff --git a/pkg/tfgen/test_data/installation-docs/provider-config-only-expected.md b/pkg/tfgen/test_data/installation-docs/provider-config-only-expected.md new file mode 100644 index 000000000..2c3ad6751 --- /dev/null +++ b/pkg/tfgen/test_data/installation-docs/provider-config-only-expected.md @@ -0,0 +1,26 @@ +This example should translate at least the Pulumi config + +## Example Usage + +```yaml +# Pulumi.yaml provider configuration file +name: configuration-example +runtime: +config: + simple-provider:authUrl: + value: http://myauthurl:5000/v3 + simple-provider:password: + value: pwd + simple-provider:region: + value: RegionOne + simple-provider:tenantName: + value: admin + simple-provider:userName: + value: admin + +``` + + +## Configuration Reference + +The following configuration inputs are supported: diff --git a/pkg/tfgen/test_data/installation-docs/provider-config-only.md b/pkg/tfgen/test_data/installation-docs/provider-config-only.md new file mode 100644 index 000000000..a5c621f8c --- /dev/null +++ b/pkg/tfgen/test_data/installation-docs/provider-config-only.md @@ -0,0 +1,18 @@ +This example should translate at least the Pulumi config + +## Example Usage + +```hcl +# Configure the OpenStack Provider +provider "simple-provider" { + user_name = "admin" + tenant_name = "admin" + password = "pwd" + auth_url = "http://myauthurl:5000/v3" + region = "RegionOne" +} +``` + +## Configuration Reference + +The following configuration inputs are supported: diff --git a/pkg/tfgen/test_data/skip-empty-examples/expected.md b/pkg/tfgen/test_data/skip-empty-examples/expected.md new file mode 100644 index 000000000..612284f2f --- /dev/null +++ b/pkg/tfgen/test_data/skip-empty-examples/expected.md @@ -0,0 +1,9 @@ +# PagerDuty Provider + +[PagerDuty](https://www.pagerduty.com/) is an incident management platform that provides reliable notifications, automatic escalations, on-call scheduling, and other functionality to help teams detect and address unplanned work in real-time. + +Use the navigation to the left to read about the available resources. + +## Perfectly allowed header for a helpful section + +This section should not be skipped \ No newline at end of file diff --git a/pkg/tfgen/test_data/skip-empty-examples/input.md b/pkg/tfgen/test_data/skip-empty-examples/input.md new file mode 100644 index 000000000..5b68e0497 --- /dev/null +++ b/pkg/tfgen/test_data/skip-empty-examples/input.md @@ -0,0 +1,13 @@ +# PagerDuty Provider + +[PagerDuty](https://www.pagerduty.com/) is an incident management platform that provides reliable notifications, automatic escalations, on-call scheduling, and other functionality to help teams detect and address unplanned work in real-time. + +Use the navigation to the left to read about the available resources. + +## Example Usage + + + +## Perfectly allowed header for a helpful section + +This section should not be skipped \ No newline at end of file