From 2f591abdec0126d3730ebb2bad166af58efc4d1f Mon Sep 17 00:00:00 2001 From: Andrew Morozko Date: Fri, 25 Oct 2024 08:48:30 +0400 Subject: [PATCH] Dynamic blocks, conditional rendering (#251) --- .github/workflows/main_common.yml | 11 +- .golangci.yaml | 2 - .goreleaser-dev.yaml | 268 ++++++------- cmd/render.go | 25 +- engine/dynamic_test.go | 444 ++++++++++++++++++++++ engine/engine.go | 27 +- engine/engine_test.go | 53 +-- engine/engine_vars_test.go | 39 +- engine/fuzz_test.go | 8 +- engine/helpers_test.go | 60 ++- engine/is_included_test.go | 220 +++++++++++ engine/tags_test.go | 189 +++++++++ eval/content.go | 9 +- eval/document.go | 44 ++- eval/dynamic.go | 207 ++++++++++ eval/plugin_action.go | 1 + eval/plugin_content_action.go | 56 ++- eval/section.go | 78 ++-- eval/vars.go | 19 +- parser/definitions/definitions.go | 3 + parser/definitions/document.go | 1 - parser/definitions/dynamic.go | 17 + parser/definitions/global_config.go | 4 +- parser/definitions/meta.go | 22 +- parser/definitions/parsed_plugin.go | 4 + parser/definitions/section.go | 1 + parser/definitions/title.go | 48 --- parser/parse_document.go | 15 +- parser/parse_dynamic.go | 105 +++++ parser/parse_plugin.go | 23 +- parser/parse_section.go | 19 +- parser/parse_title.go | 56 +++ pkg/utils/golang.go | 9 + plugin/content.go | 18 +- plugin/dataspec/decode.go | 67 ++-- plugin/dataspec/deferred/deferred_eval.go | 132 +++---- plugin/plugindata/data.go | 21 + 37 files changed, 1835 insertions(+), 490 deletions(-) create mode 100644 engine/dynamic_test.go create mode 100644 engine/is_included_test.go create mode 100644 engine/tags_test.go create mode 100644 eval/dynamic.go create mode 100644 parser/definitions/dynamic.go delete mode 100644 parser/definitions/title.go create mode 100644 parser/parse_dynamic.go create mode 100644 parser/parse_title.go diff --git a/.github/workflows/main_common.yml b/.github/workflows/main_common.yml index 4d2adab7..cb86dafd 100644 --- a/.github/workflows/main_common.yml +++ b/.github/workflows/main_common.yml @@ -13,14 +13,23 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - uses: actions/setup-go@v5 with: go-version: "1.23" + - id: golangci-rev + name: Get the rev to compare against + run: | + git fetch origin main + echo "SHA=$(git merge-base origin/main @)" >> $GITHUB_OUTPUT + - name: golangci-lint uses: golangci/golangci-lint-action@v6 with: version: v1.60.2 - only-new-issues: true + only-new-issues: false + args: "--new-from-rev ${{ steps.golangci-rev.outputs.SHA }}" codegen-check: runs-on: ubuntu-latest diff --git a/.golangci.yaml b/.golangci.yaml index 5c0c552f..50acf2c5 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -85,7 +85,6 @@ linters-settings: linters: disable-all: true enable: - - exportloopref - gci - gocritic - gofmt @@ -95,7 +94,6 @@ linters: - gosimple - govet - ineffassign - - nolintlint - staticcheck - typecheck - unused diff --git a/.goreleaser-dev.yaml b/.goreleaser-dev.yaml index 4fc99080..cc4fc88e 100644 --- a/.goreleaser-dev.yaml +++ b/.goreleaser-dev.yaml @@ -23,137 +23,137 @@ builds: # Plugins - - id: elastic - main: ./internal/elastic/cmd - binary: "plugins/blackstork/elastic@{{ .Version }}" - ldflags: "-X main.version={{.Version}}" - gcflags: all=-N -l - no_unique_dist_dir: true - tags: - - fabricplugin - - - id: github - main: ./internal/github/cmd - binary: "plugins/blackstork/github@{{ .Version }}" - ldflags: "-X main.version={{.Version}}" - gcflags: all=-N -l - no_unique_dist_dir: true - tags: - - fabricplugin - - - id: graphql - main: ./internal/graphql/cmd - binary: "plugins/blackstork/graphql@{{ .Version }}" - ldflags: "-X main.version={{.Version}}" - gcflags: all=-N -l - no_unique_dist_dir: true - tags: - - fabricplugin - - - id: openai - main: ./internal/openai/cmd - binary: "plugins/blackstork/openai@{{ .Version }}" - ldflags: "-X main.version={{.Version}}" - gcflags: all=-N -l - no_unique_dist_dir: true - tags: - - fabricplugin - - - id: opencti - main: ./internal/opencti/cmd - binary: "plugins/blackstork/opencti@{{ .Version }}" - ldflags: "-X main.version={{.Version}}" - gcflags: all=-N -l - no_unique_dist_dir: true - tags: - - fabricplugin - - - id: nistnvd - main: ./internal/nistnvd/cmd - binary: "plugins/blackstork/nist_nvd@{{ .Version }}" - ldflags: "-X main.version={{.Version}}" - gcflags: all=-N -l - no_unique_dist_dir: true - tags: - - fabricplugin - - - id: postgresql - main: ./internal/postgresql/cmd - binary: "plugins/blackstork/postgresql@{{ .Version }}" - ldflags: "-X main.version={{.Version}}" - gcflags: all=-N -l - no_unique_dist_dir: true - tags: - - fabricplugin - - - id: sqlite - main: ./internal/sqlite/cmd - binary: "plugins/blackstork/sqlite@{{ .Version }}" - ldflags: "-X main.version={{.Version}}" - gcflags: all=-N -l - no_unique_dist_dir: true - tags: - - fabricplugin - - - id: terraform - main: ./internal/terraform/cmd - binary: "plugins/blackstork/terraform@{{ .Version }}" - ldflags: "-X main.version={{.Version}}" - gcflags: all=-N -l - no_unique_dist_dir: true - tags: - - fabricplugin - - - id: hackerone - main: ./internal/hackerone/cmd - binary: "plugins/blackstork/hackerone@{{ .Version }}" - ldflags: "-X main.version={{.Version}}" - gcflags: all=-N -l - no_unique_dist_dir: true - tags: - - fabricplugin - - - id: virustotal - main: ./internal/virustotal/cmd - binary: "plugins/blackstork/virustotal@{{ .Version }}" - ldflags: "-X main.version={{.Version}}" - gcflags: all=-N -l - no_unique_dist_dir: true - tags: - - fabricplugin - - - id: splunk - main: ./internal/splunk/cmd - binary: "plugins/blackstork/splunk@{{ .Version }}" - ldflags: "-X main.version={{.Version}}" - gcflags: all=-N -l - no_unique_dist_dir: true - tags: - - fabricplugin - - - id: stixview - main: ./internal/stixview/cmd - binary: "plugins/blackstork/stixview@{{ .Version }}" - ldflags: "-X main.version={{.Version}}" - gcflags: all=-N -l - no_unique_dist_dir: true - tags: - - fabricplugin - - - id: snyk - main: ./internal/snyk/cmd - binary: "plugins/blackstork/snyk@{{ .Version }}" - ldflags: "-X main.version={{.Version}}" - gcflags: all=-N -l - no_unique_dist_dir: true - tags: - - fabricplugin - - - id: microsoft - main: ./internal/microsoft/cmd - binary: "plugins/blackstork/microsoft@{{ .Version }}" - ldflags: "-X main.version={{.Version}}" - gcflags: all=-N -l - no_unique_dist_dir: true - tags: - - fabricplugin + # - id: elastic + # main: ./internal/elastic/cmd + # binary: "plugins/blackstork/elastic@{{ .Version }}" + # ldflags: "-X main.version={{.Version}}" + # gcflags: all=-N -l + # no_unique_dist_dir: true + # tags: + # - fabricplugin + + # - id: github + # main: ./internal/github/cmd + # binary: "plugins/blackstork/github@{{ .Version }}" + # ldflags: "-X main.version={{.Version}}" + # gcflags: all=-N -l + # no_unique_dist_dir: true + # tags: + # - fabricplugin + + # - id: graphql + # main: ./internal/graphql/cmd + # binary: "plugins/blackstork/graphql@{{ .Version }}" + # ldflags: "-X main.version={{.Version}}" + # gcflags: all=-N -l + # no_unique_dist_dir: true + # tags: + # - fabricplugin + + # - id: openai + # main: ./internal/openai/cmd + # binary: "plugins/blackstork/openai@{{ .Version }}" + # ldflags: "-X main.version={{.Version}}" + # gcflags: all=-N -l + # no_unique_dist_dir: true + # tags: + # - fabricplugin + + # - id: opencti + # main: ./internal/opencti/cmd + # binary: "plugins/blackstork/opencti@{{ .Version }}" + # ldflags: "-X main.version={{.Version}}" + # gcflags: all=-N -l + # no_unique_dist_dir: true + # tags: + # - fabricplugin + + # - id: nistnvd + # main: ./internal/nistnvd/cmd + # binary: "plugins/blackstork/nist_nvd@{{ .Version }}" + # ldflags: "-X main.version={{.Version}}" + # gcflags: all=-N -l + # no_unique_dist_dir: true + # tags: + # - fabricplugin + + # - id: postgresql + # main: ./internal/postgresql/cmd + # binary: "plugins/blackstork/postgresql@{{ .Version }}" + # ldflags: "-X main.version={{.Version}}" + # gcflags: all=-N -l + # no_unique_dist_dir: true + # tags: + # - fabricplugin + + # - id: sqlite + # main: ./internal/sqlite/cmd + # binary: "plugins/blackstork/sqlite@{{ .Version }}" + # ldflags: "-X main.version={{.Version}}" + # gcflags: all=-N -l + # no_unique_dist_dir: true + # tags: + # - fabricplugin + + # - id: terraform + # main: ./internal/terraform/cmd + # binary: "plugins/blackstork/terraform@{{ .Version }}" + # ldflags: "-X main.version={{.Version}}" + # gcflags: all=-N -l + # no_unique_dist_dir: true + # tags: + # - fabricplugin + + # - id: hackerone + # main: ./internal/hackerone/cmd + # binary: "plugins/blackstork/hackerone@{{ .Version }}" + # ldflags: "-X main.version={{.Version}}" + # gcflags: all=-N -l + # no_unique_dist_dir: true + # tags: + # - fabricplugin + + # - id: virustotal + # main: ./internal/virustotal/cmd + # binary: "plugins/blackstork/virustotal@{{ .Version }}" + # ldflags: "-X main.version={{.Version}}" + # gcflags: all=-N -l + # no_unique_dist_dir: true + # tags: + # - fabricplugin + + # - id: splunk + # main: ./internal/splunk/cmd + # binary: "plugins/blackstork/splunk@{{ .Version }}" + # ldflags: "-X main.version={{.Version}}" + # gcflags: all=-N -l + # no_unique_dist_dir: true + # tags: + # - fabricplugin + + # - id: stixview + # main: ./internal/stixview/cmd + # binary: "plugins/blackstork/stixview@{{ .Version }}" + # ldflags: "-X main.version={{.Version}}" + # gcflags: all=-N -l + # no_unique_dist_dir: true + # tags: + # - fabricplugin + + # - id: snyk + # main: ./internal/snyk/cmd + # binary: "plugins/blackstork/snyk@{{ .Version }}" + # ldflags: "-X main.version={{.Version}}" + # gcflags: all=-N -l + # no_unique_dist_dir: true + # tags: + # - fabricplugin + + # - id: microsoft + # main: ./internal/microsoft/cmd + # binary: "plugins/blackstork/microsoft@{{ .Version }}" + # ldflags: "-X main.version={{.Version}}" + # gcflags: all=-N -l + # no_unique_dist_dir: true + # tags: + # - fabricplugin diff --git a/cmd/render.go b/cmd/render.go index dfdca47d..2b74f5ea 100644 --- a/cmd/render.go +++ b/cmd/render.go @@ -4,6 +4,7 @@ import ( "fmt" "log/slog" "os" + "slices" "strings" "github.com/spf13/cobra" @@ -13,7 +14,7 @@ import ( "github.com/blackstork-io/fabric/internal/builtin" "github.com/blackstork-io/fabric/parser/definitions" "github.com/blackstork-io/fabric/pkg/diagnostics" - "github.com/blackstork-io/fabric/plugin" + "github.com/blackstork-io/fabric/pkg/utils" "github.com/blackstork-io/fabric/print" "github.com/blackstork-io/fabric/print/htmlprint" "github.com/blackstork-io/fabric/print/mdprint" @@ -22,12 +23,15 @@ import ( var ( publish bool format string + tags string ) func init() { rootCmd.AddCommand(renderCmd) renderCmd.Flags().BoolVar(&publish, "publish", false, "publish the rendered document") renderCmd.Flags().StringVar(&format, "format", "md", "default output format of the document (md, html or pdf)") + renderCmd.Flags().StringVar(&tags, "with-meta-tags", "", "comma separated list of meta tags. Only content blocks matching these tags will be rendered") + renderCmd.SetUsageTemplate(UsageTemplate( [2]string{"TARGET", "name of the document to be rendered as 'document.'"}, )) @@ -47,6 +51,13 @@ var renderCmd = &cobra.Command{ default: return fmt.Errorf("target should have the format '%s'", docPrefix) } + requiredTags := slices.DeleteFunc( + utils.FnMap( + strings.Split(tags, ","), + strings.TrimSpace, + ), + func(tag string) bool { return tag == "" }, + ) ctx := cmd.Context() logger := slog.Default() @@ -77,19 +88,15 @@ var renderCmd = &cobra.Command{ if diags.Extend(diag) { return diags } - var content plugin.Content - if publish { - content, diag = eng.RenderAndPublishContent(ctx, target) - } else { - _, content, _, diag = eng.RenderContent(ctx, target) - } + + doc, content, dataCtx, diag := eng.RenderContent(ctx, target, requiredTags) if diags.Extend(diag) { return diags } - // If publish requested, no need to print out to stdout if publish { - return nil + diags.Extend(eng.PublishContent(ctx, target, doc, content, dataCtx)) + return diags } logger.InfoContext(ctx, "Printing to stdout", "format", format) diff --git a/engine/dynamic_test.go b/engine/dynamic_test.go new file mode 100644 index 00000000..d61f114d --- /dev/null +++ b/engine/dynamic_test.go @@ -0,0 +1,444 @@ +package engine + +import ( + "testing" + + "github.com/blackstork-io/fabric/pkg/diagnostics/diagtest" +) + +func TestDynamic(t *testing.T) { + renderTest( + t, "dynamic content", + []string{` + document "test-doc" { + dynamic { + items = ["a", "b", "c"] + content text { + value = "{{.vars.dynamic_item_index}}: {{.vars.dynamic_item}}" + } + } + } + `}, + []string{ + "0: a", + "1: b", + "2: c", + }, + ) + + renderTest( + t, "dynamic items + is_included", + []string{` + document "test-doc" { + vars { + show1 = true + show2 = false + } + dynamic { + items = ["dyn_item"] + content text { + is_included = query_jq(".vars.show1") + value = "show1 block {{.vars.dynamic_item}}" + } + } + dynamic { + items = ["dyn_item"] + content text { + is_included = query_jq(".vars.show2") + value = "show2 block {{.vars.dynamic_item}}" + } + } + } + `}, + []string{ + "show1 block dyn_item", + }, + ) + renderTest( + t, "is_included with nested dynamics", + []string{` + document "test-doc" { + vars { + show1 = true + show2 = false + } + section { + is_included = query_jq(".vars.show1") + dynamic { + items = ["dyn_item"] + content text { + value = "show1 block {{.vars.dynamic_item}}" + } + } + } + section { + is_included = query_jq(".vars.show2") + dynamic { + items = ["dyn_item"] + content text { + value = "show2 block {{.vars.dynamic_item}}" + } + } + } + } + `}, + []string{ + "show1 block dyn_item", + }, + ) + + renderTest( + t, "nested dynamics", + []string{` + document "test-doc" { + dynamic { + items = ["abc", "def"] + content text { + value = "{{.vars.dynamic_item}} by letters:" + } + dynamic { + items = query_jq(".vars.dynamic_item | split(\"\")") + content text { + vars { + idx_human = query_jq(".vars.dynamic_item_index + 1") + } + value = "{{.vars.idx_human}}: {{.vars.dynamic_item}}" + } + } + } + } + `}, + []string{ + "abc by letters:", + "1: a", + "2: b", + "3: c", + "def by letters:", + "1: d", + "2: e", + "3: f", + }, + ) + renderTest( + t, "dynamics sections and nested dynamic", + []string{` + document "test-doc" { + dynamic { + items = ["A", "B"] + section { + content text { + value = query_jq("\"Section \" + .vars.dynamic_item") + } + dynamic { + items = ["x", "y"] + content text { + value = "Content {{.vars.dynamic_item}}" + } + } + } + } + } + `}, + []string{ + "Section A", + "Content x", + "Content y", + "Section B", + "Content x", + "Content y", + }, + ) + renderTest( + t, "dynamics sections with titles", + []string{` + document "test-doc" { + dynamic { + items = ["A", "B"] + section { + title = query_jq("\"Section \" + .vars.dynamic_item") + dynamic { + items = ["x", "y"] + content text { + value = "Content {{.vars.dynamic_item}}" + } + } + } + } + } + `}, + []string{ + "## Section A", + "Content x", + "Content y", + "## Section B", + "Content x", + "Content y", + }, + ) + renderTest( + t, "dynamic refs", + []string{` + content text "test" { + value = "test {{.vars.dynamic_item}}" + } + document "test-doc" { + dynamic { + items = ["A", "B"] + section { + title = query_jq("\"Section \" + .vars.dynamic_item") + content ref { + base = content.text.test + } + } + } + } + `}, + []string{ + "## Section A", + "test A", + "## Section B", + "test B", + }, + ) + renderTest( + t, "dynamic section ref", + []string{` + section "test" { + title = query_jq("\"Section \" + .vars.dynamic_item") + content text { + value = "test {{.vars.dynamic_item}}" + } + } + document "test-doc" { + dynamic { + items = ["A", "B"] + section ref { + base = section.test + } + content text { + value = "test2 {{.vars.dynamic_item}}" + } + } + } + `}, + []string{ + "## Section A", + "test A", + "test2 A", + "## Section B", + "test B", + "test2 B", + }, + ) + renderTest( + t, "dynamic with nested is_included", + []string{` + document "test-doc" { + dynamic { + items = ["A", "B"] + content text { + value = "test {{.vars.dynamic_item}}" + } + section { + title = query_jq("\"Section \" + .vars.dynamic_item") + content text { + is_included = query_jq(".vars.dynamic_item == \"B\"") + value = "only for B: {{.vars.dynamic_item_index}} {{.vars.dynamic_item}}" + } + } + } + } + `}, + []string{ + "test A", + "## Section A", + "test B", + "## Section B", + "only for B: 1 B", + }, + ) + renderTest( + t, "dynamic with immediate nested is_included", + []string{` + document "test-doc" { + dynamic { + items = ["A", "B"] + content text { + value = "test {{.vars.dynamic_item}}" + } + content text { + is_included = query_jq(".vars.dynamic_item == \"B\"") + value = "only for B: {{.vars.dynamic_item_index}} {{.vars.dynamic_item}}" + } + } + } + `}, + []string{ + "test A", + "test B", + "only for B: 1 B", + }, + ) + renderTest( + t, "redefined dynamics", + []string{` + document "test-doc" { + dynamic { + items = ["abc", "def"] + content text { + vars { + // overrides only locally, does not affect other dynamic blocks + dynamic_item = "XYZ" + } + value = "{{.vars.dynamic_item}} by letters:" + } + dynamic { + items = query_jq(".vars.dynamic_item | split(\"\")") + content text { + vars { + idx_human = query_jq(".vars.dynamic_item_index + 1") + } + value = "{{.vars.idx_human}}: {{.vars.dynamic_item}}" + } + } + } + } + `}, + []string{ + "XYZ by letters:", + "1: a", + "2: b", + "3: c", + "XYZ by letters:", + "1: d", + "2: e", + "3: f", + }, + ) + renderTest( + t, "redefined inner dynamics", + []string{` + document "test-doc" { + dynamic { + items = ["abc", "def"] + section { + vars { + dynamic_item = "XYZ" + } + content text { + value = "{{.vars.dynamic_item}} by letters:" + } + dynamic { + items = query_jq(".vars.dynamic_item | split(\"\")") + content text { + vars { + idx_human = query_jq(".vars.dynamic_item_index + 1") + } + value = "{{.vars.idx_human}}: {{.vars.dynamic_item}}" + } + } + } + } + } + `}, + []string{ + "XYZ by letters:", + "1: X", + "2: Y", + "3: Z", + "XYZ by letters:", + "1: X", + "2: Y", + "3: Z", + }, + ) + renderTest( + t, "deeply nested dynamics", + []string{` + document "test-doc" { + dynamic { + items = ["a", "b", "c"] + content text { + value = "1. {{.vars.dynamic_item}}" + } + section { + is_included = query_jq(".vars.dynamic_item != \"b\"") + content text { + value = "2. {{.vars.dynamic_item}}" + } + section { + content text { + value = "3. {{.vars.dynamic_item}}" + } + dynamic { + items = query_jq("[.vars.dynamic_item, \"XYZ\", \"foo\"]") + section { + is_included = query_jq(".vars.dynamic_item_index != 0") + content text { + value = "4. {{.vars.dynamic_item}}" + } + content text { + is_included = query_jq(".vars.dynamic_item != \"foo\"") + value = "5. {{.vars.dynamic_item}}" + } + } + } + } + } + } + } + `}, + []string{ + "1. a", + "2. a", + "3. a", + "4. XYZ", + "5. XYZ", + "4. foo", + "1. b", + "1. c", + "2. c", + "3. c", + "4. XYZ", + "5. XYZ", + "4. foo", + }, + ) + renderTest( + t, "warn on empty", + []string{` + document "test-doc" { + dynamic { + content text { + value = "hello" + } + } + } + `}, + []string{}, + diagtest.Asserts{{ + diagtest.IsError, + diagtest.SummaryEquals("Dynamic block without items"), + }}, + ) + renderTest( + t, "warn on no children empty", + []string{` + document "test-doc" { + content text { + value = "hello" + } + dynamic { + items = ["a", "b"] + } + } + `}, + []string{ + "hello", + }, + diagtest.Asserts{{ + diagtest.IsWarning, + diagtest.SummaryContains("Dynamic block without content"), + }}, + ) +} diff --git a/engine/engine.go b/engine/engine.go index ed2a3e77..ecec531e 100644 --- a/engine/engine.go +++ b/engine/engine.go @@ -427,7 +427,7 @@ func (e *Engine) initialDataCtx(ctx context.Context) (data plugindata.Map, diags return } -func (e *Engine) RenderContent(ctx context.Context, target string) (doc *eval.Document, content plugin.Content, data plugindata.Data, diags diagnostics.Diag) { +func (e *Engine) RenderContent(ctx context.Context, target string, requiredTags []string) (doc *eval.Document, content *plugin.ContentSection, data plugindata.Data, diags diagnostics.Diag) { ctx, span := e.tracer.Start(ctx, "Engine.RenderContent", trace.WithAttributes( attribute.String("target", target), )) @@ -447,18 +447,23 @@ func (e *Engine) RenderContent(ctx context.Context, target string) (doc *eval.Do if diags.Extend(diag) { return } - content, data, diag = doc.RenderContent(ctx, dataCtx) + content, data, diag = doc.RenderContent(ctx, dataCtx, requiredTags) if diags.Extend(diag) { return nil, nil, nil, diags } + + if content.IsEmpty() { + diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagWarning, + Summary: "No content to render", + Detail: "Document did not produce any content, perhaps it's empty or '--with-meta-tags' filter is too strict?", + Subject: doc.Source.Block.DefRange().Ptr(), + }) + } return doc, content, data, diags } -func (e *Engine) RenderAndPublishContent(ctx context.Context, target string) (content plugin.Content, diags diagnostics.Diag) { - doc, content, dataCtx, diag := e.RenderContent(ctx, target) - if diags.Extend(diag) { - return nil, diags - } +func (e *Engine) PublishContent(ctx context.Context, target string, doc *eval.Document, content *plugin.ContentSection, dataCtx plugindata.Data) (diags diagnostics.Diag) { ctx, span := e.tracer.Start(ctx, "Engine.Publish", trace.WithAttributes( attribute.String("target", target), )) @@ -470,11 +475,9 @@ func (e *Engine) RenderAndPublishContent(ctx context.Context, target string) (co span.End() }() e.logger.InfoContext(ctx, "Publishing the content", "target", target) - diag = doc.Publish(ctx, content, dataCtx, target) - if diags.Extend(diag) { - return nil, diags - } - return content, diags + diag := doc.Publish(ctx, content, dataCtx, target) + diags.Extend(diag) + return } func (e *Engine) loadDocument(ctx context.Context, name string) (_ *eval.Document, diags diagnostics.Diag) { diff --git a/engine/engine_test.go b/engine/engine_test.go index f20900d8..affe55e4 100644 --- a/engine/engine_test.go +++ b/engine/engine_test.go @@ -206,11 +206,9 @@ func TestEnvPrefix(t *testing.T) { } `, }, - "test-doc", []string{ "\nFABRIC_VAR\nFABRIC_TEST_VAR", }, - diagtest.Asserts{}, ) renderTest( t, "Custom", @@ -226,11 +224,9 @@ func TestEnvPrefix(t *testing.T) { } `, }, - "test-doc", []string{ "\n\nFABRIC_TEST_VAR", }, - diagtest.Asserts{}, ) renderTest( t, "Empty", @@ -246,11 +242,9 @@ func TestEnvPrefix(t *testing.T) { } `, }, - "test-doc", []string{ "\n\n", }, - diagtest.Asserts{}, ) renderTest( t, "Empty", @@ -266,7 +260,6 @@ func TestEnvPrefix(t *testing.T) { } `, }, - "test-doc", []string{ "\n\nFABRIC_TEST_VAR", }, @@ -289,7 +282,6 @@ func TestEnvPrefix(t *testing.T) { } `, }, - "test-doc", nil, diagtest.Asserts{{ diagtest.IsError, @@ -310,11 +302,9 @@ func TestEnvPrefix(t *testing.T) { } `, }, - "test-doc", []string{ "\n\n", }, - diagtest.Asserts{}, ) } @@ -342,12 +332,11 @@ func TestEngineRenderContent(t *testing.T) { } `, }, - "hello", []string{ "# Welcome", "Hello from fabric", }, - diagtest.Asserts{}, + optDocName("hello"), ) renderTest( t, "Ref", @@ -365,12 +354,10 @@ func TestEngineRenderContent(t *testing.T) { } `, }, - "test-doc", []string{ "# Welcome", "Hello from ref", }, - diagtest.Asserts{}, ) renderTest( t, "Ref across files", @@ -389,12 +376,10 @@ func TestEngineRenderContent(t *testing.T) { } `, }, - "test-doc", []string{ "# Welcome", "Hello from ref", }, - diagtest.Asserts{}, ) renderTest( t, "Ref chain", @@ -416,11 +401,9 @@ func TestEngineRenderContent(t *testing.T) { } `, }, - "test-doc", []string{ "> Hello from ref chain", }, - diagtest.Asserts{}, ) renderTest( t, "Ref loop untouched", @@ -450,11 +433,9 @@ func TestEngineRenderContent(t *testing.T) { } `, }, - "test-doc", []string{ "Near refloop", }, - diagtest.Asserts{}, ) renderTest( @@ -485,7 +466,6 @@ func TestEngineRenderContent(t *testing.T) { } `, }, - "test-doc", []string{}, diagtest.Asserts{ {diagtest.IsError, diagtest.SummaryContains("Circular reference detected")}, @@ -547,7 +527,6 @@ func TestEngineRenderContent(t *testing.T) { `, }, - "test-doc", []string{ "## sect1", "s1", @@ -561,7 +540,6 @@ func TestEngineRenderContent(t *testing.T) { "s3 extra", "final section", }, - diagtest.Asserts{}, ) renderTest( t, "Templating support", @@ -574,11 +552,9 @@ func TestEngineRenderContent(t *testing.T) { } `, }, - "test-doc", []string{ "4", }, - diagtest.Asserts{}, ) renderTest( t, "Data ref name warning missing", @@ -594,9 +570,10 @@ func TestEngineRenderContent(t *testing.T) { } `, }, - "test-doc", []string{}, - diagtest.Asserts{}, + diagtest.Asserts{ + {diagtest.IsWarning, diagtest.SummaryContains("No content")}, + }, ) renderTest( t, "Data ref name warning", @@ -615,10 +592,10 @@ func TestEngineRenderContent(t *testing.T) { } `, }, - "test-doc", []string{}, diagtest.Asserts{ {diagtest.IsWarning, diagtest.SummaryContains("Data conflict")}, + {diagtest.IsWarning, diagtest.SummaryContains("No content")}, }, ) renderTest( @@ -635,14 +612,11 @@ func TestEngineRenderContent(t *testing.T) { } `, }, - "test-doc", []string{"txt"}, - diagtest.Asserts{}, ) renderTest( t, "No fabric files", []string{}, - "test-doc", []string{}, diagtest.Asserts{ {diagtest.IsError, diagtest.SummaryContains("No valid fabric files found")}, @@ -662,9 +636,7 @@ func TestEngineRenderContent(t *testing.T) { } `, }, - "test-doc", []string{"From data block: a.json"}, - diagtest.Asserts{}, ) renderTest( t, "Data with jq interaction", @@ -683,9 +655,8 @@ func TestEngineRenderContent(t *testing.T) { } `, }, - "test", []string{"There are 3 items"}, - diagtest.Asserts{}, + optDocName("test"), ) renderTest( t, "Document meta", @@ -708,9 +679,8 @@ func TestEngineRenderContent(t *testing.T) { } `, }, - "test", []string{"authors=foo,bar,\nversion=0.1.2,\ntag=xxx"}, - diagtest.Asserts{}, + optDocName("test"), ) renderTest( t, "Document and content meta", @@ -735,9 +705,8 @@ func TestEngineRenderContent(t *testing.T) { } `, }, - "test", []string{"author = foobarbaz"}, - diagtest.Asserts{}, + optDocName("test"), ) renderTest( t, "Meta scoping and nesting", @@ -780,7 +749,6 @@ func TestEngineRenderContent(t *testing.T) { } `, }, - "test", []string{ "author = unknown", "author = unknown", @@ -788,7 +756,7 @@ func TestEngineRenderContent(t *testing.T) { "author = unknown", "author = bar", }, - diagtest.Asserts{}, + optDocName("test"), ) renderTest( t, "Reference rendered blocks", @@ -809,12 +777,11 @@ func TestEngineRenderContent(t *testing.T) { } `, }, - "test", []string{ "first result", "content[0] = first result", "content[1] = content[0] = first result", }, - diagtest.Asserts{}, + optDocName("test"), ) } diff --git a/engine/engine_vars_test.go b/engine/engine_vars_test.go index e528d826..3f5d5dce 100644 --- a/engine/engine_vars_test.go +++ b/engine/engine_vars_test.go @@ -32,7 +32,6 @@ func TestEngineVarsHandling(t *testing.T) { } } `}, - "test-doc", []string{ `base: { @@ -46,7 +45,6 @@ func TestEngineVarsHandling(t *testing.T) { "q_b": "unique to base" }`, }, - diagtest.Asserts{}, ) renderTest( t, "refs query in override", @@ -71,7 +69,6 @@ func TestEngineVarsHandling(t *testing.T) { } } `}, - "test-doc", []string{ `base: { @@ -83,7 +80,6 @@ func TestEngineVarsHandling(t *testing.T) { "b": "redefined" }`, }, - diagtest.Asserts{}, ) renderTest( t, "inheritance", @@ -111,7 +107,6 @@ func TestEngineVarsHandling(t *testing.T) { } } `}, - "test-doc", []string{ `1: { "contentVar": "contentVar", @@ -126,7 +121,6 @@ func TestEngineVarsHandling(t *testing.T) { "docVar": "docVar" }`, }, - diagtest.Asserts{}, ) renderTest( t, "combined inheritance and shadowing", @@ -155,7 +149,6 @@ func TestEngineVarsHandling(t *testing.T) { } } `}, - "test-doc", []string{ `{ "v1": 1, @@ -168,7 +161,6 @@ func TestEngineVarsHandling(t *testing.T) { "v8": 8 }`, }, - diagtest.Asserts{}, ) renderTest( t, "combined inheritance and shadowing section ref", @@ -204,7 +196,6 @@ func TestEngineVarsHandling(t *testing.T) { } } `}, - "test-doc", []string{ `{ "v1": 1, @@ -217,7 +208,6 @@ func TestEngineVarsHandling(t *testing.T) { "v8": 8 }`, }, - diagtest.Asserts{}, ) renderTest( t, "deep nesting and complex result type", @@ -243,7 +233,6 @@ func TestEngineVarsHandling(t *testing.T) { } } `}, - "test-doc", []string{ `{ "a": { @@ -264,7 +253,6 @@ func TestEngineVarsHandling(t *testing.T) { } }`, }, - diagtest.Asserts{}, ) renderTest( t, "local vars", @@ -276,13 +264,11 @@ func TestEngineVarsHandling(t *testing.T) { } } `}, - "test-doc", []string{ `{ "local": "local" }`, }, - diagtest.Asserts{}, ) renderTest( t, "local vars with var local redefinition", @@ -297,7 +283,6 @@ func TestEngineVarsHandling(t *testing.T) { } } `}, - "test-doc", nil, diagtest.Asserts{ { @@ -319,7 +304,6 @@ func TestEngineVarsHandling(t *testing.T) { } } `}, - "test-doc", []string{ `{ "local": "local", @@ -343,13 +327,11 @@ func TestEngineVarsHandling(t *testing.T) { } } `}, - "test-doc", []string{ `{ "local": "local" }`, }, - diagtest.Asserts{}, ) renderTest( t, "local vars sections", @@ -363,13 +345,11 @@ func TestEngineVarsHandling(t *testing.T) { } } `}, - "test-doc", []string{ `{ "local": "local" }`, }, - diagtest.Asserts{}, ) renderTest( t, "required vars in document", @@ -384,11 +364,10 @@ func TestEngineVarsHandling(t *testing.T) { } } `}, - "example", []string{ `Simple text`, }, - diagtest.Asserts{}, + optDocName("example"), ) renderTest( t, "required vars missing in document", @@ -400,7 +379,6 @@ func TestEngineVarsHandling(t *testing.T) { } } `}, - "example", []string{}, diagtest.Asserts{ { @@ -409,6 +387,7 @@ func TestEngineVarsHandling(t *testing.T) { diagtest.DetailEquals("block requires 'now' var which is not set."), }, }, + optDocName("example"), ) renderTest( t, "required vars in content", @@ -444,9 +423,8 @@ func TestEngineVarsHandling(t *testing.T) { } } `}, - "bar", []string{"Hello, Alice\n\nGreetings, Alice"}, - diagtest.Asserts{}, + optDocName("bar"), ) renderTest( t, "required vars missing in content", @@ -462,7 +440,6 @@ func TestEngineVarsHandling(t *testing.T) { } } `}, - "bar", []string{}, diagtest.Asserts{ { @@ -471,6 +448,7 @@ func TestEngineVarsHandling(t *testing.T) { diagtest.DetailEquals("block requires 'name' var which is not set."), }, }, + optDocName("bar"), ) renderTest( t, "required vars in section", @@ -487,12 +465,11 @@ func TestEngineVarsHandling(t *testing.T) { name = "Alice" } base = section.foo - } + } } `}, - "bar", []string{"Hello, Alice"}, - diagtest.Asserts{}, + optDocName("bar"), ) renderTest( t, "required vars missing in section", @@ -506,10 +483,9 @@ func TestEngineVarsHandling(t *testing.T) { document "bar" { section ref { base = section.foo - } + } } `}, - "bar", []string{}, diagtest.Asserts{ { @@ -518,5 +494,6 @@ func TestEngineVarsHandling(t *testing.T) { diagtest.DetailEquals("block requires 'name' var which is not set."), }, }, + optDocName("bar"), ) } diff --git a/engine/fuzz_test.go b/engine/fuzz_test.go index ad3bad56..dad920be 100644 --- a/engine/fuzz_test.go +++ b/engine/fuzz_test.go @@ -69,10 +69,12 @@ func FuzzEngine(f *testing.F) { if diags.HasErrors() { return } + doc, renderedContent, dataCtx, diags := eng.RenderContent(ctx, string(target), []string{}) + if diags.HasErrors() { + return + } if publish { - eng.RenderAndPublishContent(ctx, string(target)) - } else { - eng.RenderContent(ctx, string(target)) + eng.PublishContent(ctx, string(target), doc, renderedContent, dataCtx) } }) } diff --git a/engine/helpers_test.go b/engine/helpers_test.go index bff17728..e00f62dd 100644 --- a/engine/helpers_test.go +++ b/engine/helpers_test.go @@ -16,30 +16,51 @@ import ( "github.com/blackstork-io/fabric/print/mdprint" ) -func renderTest(t *testing.T, testName string, files []string, docName string, expectedResult []string, diagAsserts diagtest.Asserts) { +type ( + optDocName string + optRequiredTags []string +) + +func renderTest(t *testing.T, testName string, files []string, expectedResult []string, opts ...any) { t.Helper() - t.Run(testName, func(t *testing.T) { - t.Helper() - sourceDir := fstest.MapFS{} - for i, content := range files { - sourceDir[fmt.Sprintf("file_%d.fabric", i)] = &fstest.MapFile{ - Data: []byte(content), - Mode: 0o777, - } + sourceDir := fstest.MapFS{} + for i, content := range files { + sourceDir[fmt.Sprintf("file_%d.fabric", i)] = &fstest.MapFile{ + Data: []byte(content), + Mode: 0o777, } - eng := New() - defer func() { - eng.Cleanup() - }() + } + eng := New() + ctx := fabctx.New(fabctx.NoSignals) + + target := "test-doc" + var requiredTags []string + diagAsserts := diagtest.Asserts{} + + for _, opt := range opts { + switch v := opt.(type) { + case diagtest.Asserts: + diagAsserts = v + case optDocName: + target = string(v) + case optRequiredTags: + requiredTags = []string(v) + default: + t.Fatalf("unknown option type: %T", v) + } + } + + t.Run(testName, func(t *testing.T) { + defer eng.Cleanup() var res string - ctx := fabctx.New(fabctx.NoSignals) diags := eng.ParseDir(ctx, sourceDir) if !diags.HasErrors() { if !diags.Extend(eng.LoadPluginResolver(ctx, false)) && !diags.Extend(eng.LoadPluginRunner(ctx)) { - _, content, _, diag := eng.RenderContent(ctx, docName) - diags.Extend(diag) - res = mdprint.PrintString(content) + _, content, _, diag := eng.RenderContent(ctx, target, requiredTags) + if !diags.Extend(diag) { + res = mdprint.PrintString(content) + } } } @@ -47,11 +68,14 @@ func renderTest(t *testing.T, testName string, files []string, docName string, e // so nil == []string{} assert.Empty(t, res) } else { - assert.EqualValues( + ok := assert.EqualValues( t, strings.Join(expectedResult, "\n\n"), res, ) + if !ok { + t.Logf("Got:\n\n%s", res) + } } diagAsserts.AssertMatch(t, diags, eng.FileMap()) }) diff --git a/engine/is_included_test.go b/engine/is_included_test.go new file mode 100644 index 00000000..f59534b8 --- /dev/null +++ b/engine/is_included_test.go @@ -0,0 +1,220 @@ +package engine + +import "testing" + +func TestIsIncluded(t *testing.T) { + renderTest( + t, "content", + []string{` + document "test-doc" { + vars { + show1 = true + show2 = false + } + content text { + is_included = false + value = "show1 block" + } + content text { + is_included = true + value = "show2 block" + } + content text { + value = "show3 block" + } + } + `}, + []string{ + "show2 block", + "show3 block", + }, + ) + renderTest( + t, "content query", + []string{` + document "test-doc" { + vars { + show1 = true + show2 = false + } + content text { + is_included = query_jq(".vars.show1") + value = "show1 block" + } + content text { + is_included = query_jq(".vars.show2") + value = "show2 block" + } + } + `}, + []string{ + "show1 block", + }, + ) + + renderTest( + t, "section", + []string{` + document "test-doc" { + vars { + show1 = true + show2 = false + } + section { + is_included = query_jq(".vars.show1") + content text { + value = "show1 block" + } + } + section { + is_included = query_jq(".vars.show2") + content text { + value = "show2 block" + } + } + } + `}, + []string{ + "show1 block", + }, + ) + renderTest( + t, "ref", + []string{` + content text "test" { + vars { + text = "base value" + } + value = "test: {{.vars.text}}" + } + + content text "test_not_included" { + vars { + text = "base value" + } + is_included = false + value = "test_not_included: {{.vars.text}}" + } + section "test_section_not_included" { + vars { + text = "base value" + } + is_included = false + content text { + value = "test_section_not_included: {{.vars.text}}" + } + } + + document "test-doc" { + content ref { + is_included = false + vars { + text = "ref value1" + } + base = content.text.test + } + content ref { + is_included = true + vars { + text = "ref value2" + } + base = content.text.test + } + content ref { + vars { + text = "ref value1" + } + base = content.text.test_not_included + } + content ref { + vars { + text = "ref value2" + } + is_included = true + base = content.text.test_not_included + } + section ref { + vars { + text = "ref value1" + } + base = section.test_section_not_included + } + section ref { + vars { + text = "ref value2" + } + is_included = true + base = section.test_section_not_included + } + } + `}, + []string{ + "test: ref value2", + "test_not_included: ref value2", + "test_section_not_included: ref value2", + }, + ) + renderTest( + t, "truthiness", + []string{` + document "test-doc" { + content text { + is_included = [] + value = "falsy" + } + content text { + is_included = {} + value = "falsy" + } + content text { + is_included = "" + value = "falsy" + } + content text { + is_included = query_jq("[]") + value = "falsy" + } + content text { + is_included = query_jq("{}") + value = "falsy" + } + content text { + is_included = query_jq("\"\"") + value = "falsy" + } + content text { + is_included = [1] + value = "truthy" + } + content text { + is_included = { a = "b"} + value = "truthy" + } + content text { + is_included = "a" + value = "truthy" + } + content text { + is_included = query_jq("[1]") + value = "truthy" + } + content text { + is_included = query_jq("{ \"a\": \"b\" }") + value = "truthy" + } + content text { + is_included = query_jq("\"hello\"") + value = "truthy" + } + } + `}, + []string{ + "truthy", + "truthy", + "truthy", + "truthy", + "truthy", + "truthy", + }, + ) +} diff --git a/engine/tags_test.go b/engine/tags_test.go new file mode 100644 index 00000000..5dbebf6d --- /dev/null +++ b/engine/tags_test.go @@ -0,0 +1,189 @@ +package engine + +import ( + "testing" +) + +func TestTags(t *testing.T) { + renderTest( + t, "basic", + []string{` + document "test-doc" { + content text { + meta { + tags = ["tag1"] + } + value = "tag1" + } + content text { + meta { + tags = ["tag2"] + } + value = "tag2" + } + } + `}, + []string{ + "tag2", + }, + optRequiredTags{"tag2"}, + ) + renderTest( + t, "document include all", + []string{` + document "test-doc" { + meta { + tags = ["tag2"] + } + content text { + meta { + tags = ["tag1"] + } + value = "tag1" + } + content text { + meta { + tags = ["tag2"] + } + value = "tag2" + } + } + `}, + []string{ + "tag1", + "tag2", + }, + optRequiredTags{"tag2"}, + ) + renderTest( + t, "section include all", + []string{` + document "test-doc" { + content text { + value = "not included" + } + section { + meta { + tags = ["tag2"] + } + content text { + meta { + tags = ["tag1"] + } + value = "tag1" + } + content text { + meta { + tags = ["tag2"] + } + value = "tag2" + } + } + } + `}, + []string{ + "tag1", + "tag2", + }, + optRequiredTags{"tag2"}, + ) + renderTest( + t, "section children filtering", + []string{` + document "test-doc" { + content text { + meta { + tags = ["tag2"] + } + value = "included" + } + section { + content text { + meta { + tags = ["tag1"] + } + value = "tag1" + } + content text { + meta { + tags = ["tag2"] + } + value = "tag2" + } + } + } + `}, + []string{ + "included", + "tag2", + }, + optRequiredTags{"tag2"}, + ) + renderTest( + t, "multiple tags", + []string{` + document "test-doc" { + content text { + meta { + tags = ["tag1"] + } + value = "1" + } + content text { + meta { + tags = ["tag1", "tag2"] + } + value = "12" + } + content text { + meta { + tags = ["tag1", "tag2", "tag3"] + } + value = "123" + } + content text { + meta { + tags = ["tag1", "tag2", "tag3", "tag4"] + } + value = "1234" + } + } + `}, + []string{ + "123", + "1234", + }, + optRequiredTags{"tag1", "tag2", "tag3"}, + ) +} + +func TestTagsWithDynamics(t *testing.T) { + renderTest( + t, "dynamics with tags", + []string{` + document "test-doc" { + dynamic { + items = ["a", "b", "c"] + content text { + meta { + tags = ["tag1"] + } + value = "tag1 {{.vars.dynamic_item_index}}: {{.vars.dynamic_item}}" + } + content text { + meta { + tags = ["tag2"] + } + value = "tag2 {{.vars.dynamic_item_index}}: {{.vars.dynamic_item}}" + } + } + } + `}, + []string{ + "tag2 0: a", + "tag2 1: b", + "tag2 2: c", + }, + optRequiredTags{"tag2"}, + ) +} diff --git a/eval/content.go b/eval/content.go index 4831aa01..b2a9266a 100644 --- a/eval/content.go +++ b/eval/content.go @@ -14,6 +14,7 @@ import ( type Content struct { Section *Section Plugin *PluginContentAction + Dynamic *Dynamic } func (action *Content) InvocationOrder() plugin.InvocationOrder { @@ -23,14 +24,14 @@ func (action *Content) InvocationOrder() plugin.InvocationOrder { return plugin.InvocationOrderUnspecified } -func (action *Content) RenderContent(ctx context.Context, dataCtx plugindata.Map, doc, parent *plugin.ContentSection, contentID uint32) (*plugin.ContentResult, diagnostics.Diag) { +func (action *Content) RenderContent(ctx context.Context, dataCtx plugindata.Map, doc, parent *plugin.ContentSection, contentID uint32) diagnostics.Diag { if action.Section != nil { return action.Section.RenderContent(ctx, dataCtx, doc, parent, contentID) } if action.Plugin != nil { return action.Plugin.RenderContent(ctx, dataCtx, doc, parent, contentID) } - return nil, diagnostics.Diag{{ + return diagnostics.Diag{{ Severity: hcl.DiagError, Summary: "Content block not found", }} @@ -43,11 +44,13 @@ func LoadContent(ctx context.Context, providers ContentProviders, node *definiti block.Plugin, diags = LoadPluginContentAction(ctx, providers, node.Plugin) case node.Section != nil: block.Section, diags = LoadSection(ctx, providers, node.Section) + case node.Dynamic != nil: + block.Dynamic, diags = LoadDynamic(ctx, providers, node.Dynamic) default: diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, Summary: "Unsupported content block", - Detail: "Content block must be either 'content' or 'section'", + Detail: "Content block must be either 'content', 'section' or 'dynamic'", }) } if diags.HasErrors() { diff --git a/eval/document.go b/eval/document.go index 86a20377..20fa634c 100644 --- a/eval/document.go +++ b/eval/document.go @@ -54,7 +54,23 @@ func (doc *Document) FetchData(ctx context.Context) (plugindata.Data, diagnostic return result, diags } -func (doc *Document) RenderContent(ctx context.Context, docDataCtx plugindata.Map) (plugin.Content, plugindata.Data, diagnostics.Diag) { +func filterChildrenByTags(children []*Content, requiredTags []string) []*Content { + return slices.DeleteFunc(children, func(child *Content) bool { + switch { + case child.Plugin != nil: + return !child.Plugin.Meta.MatchesTags(requiredTags) + case child.Section != nil: + if child.Section.meta.MatchesTags(requiredTags) { + return false + } + child.Section.children = filterChildrenByTags(child.Section.children, requiredTags) + return len(child.Section.children) == 0 + } + return false + }) +} + +func (doc *Document) RenderContent(ctx context.Context, docDataCtx plugindata.Map, requiredTags []string) (*plugin.ContentSection, plugindata.Data, diagnostics.Diag) { logger := *slog.Default() logger.DebugContext(ctx, "Fetching data for the document template") data, diags := doc.FetchData(ctx) @@ -78,28 +94,38 @@ func (doc *Document) RenderContent(ctx context.Context, docDataCtx plugindata.Ma // verify required vars if len(doc.RequiredVars) > 0 { - diag := verifyRequiredVars(docDataCtx, doc.RequiredVars, doc.Source.Block) + diag = verifyRequiredVars(docDataCtx, doc.RequiredVars, doc.Source.Block) if diags.Extend(diag) { return nil, nil, diags } } + // evaluate/expand dynamic blocks + children, diag := UnwrapDynamicContent(ctx, doc.ContentBlocks, docDataCtx) + if diags.Extend(diag) { + return nil, nil, diags + } + // filter out content blocks that do not match tags + if !doc.Meta.MatchesTags(requiredTags) { + children = filterChildrenByTags(children, requiredTags) + } + result := plugin.NewSection(0) // create a position map for content blocks - posMap := make(map[int]uint32) - for i := range doc.ContentBlocks { + posMap := make(map[int]uint32, len(children)) + for i := range children { empty := new(plugin.ContentEmpty) result.Add(empty, nil) posMap[i] = empty.ID() } // sort content blocks by invocation order - invokeList := make([]int, 0, len(doc.ContentBlocks)) - for i := range doc.ContentBlocks { + invokeList := make([]int, 0, len(children)) + for i := range children { invokeList = append(invokeList, i) } slices.SortStableFunc(invokeList, func(a, b int) int { - ao := doc.ContentBlocks[a].InvocationOrder() - bo := doc.ContentBlocks[b].InvocationOrder() + ao := children[a].InvocationOrder() + bo := children[b].InvocationOrder() return ao.Weight() - bo.Weight() }) // execute content blocks based on the invocation order @@ -111,7 +137,7 @@ func (doc *Document) RenderContent(ctx context.Context, docDataCtx plugindata.Ma // TODO: if section, set section // execute the content block - _, diag := doc.ContentBlocks[idx].RenderContent(ctx, dataCtx, result, result, posMap[idx]) + diag := children[idx].RenderContent(ctx, dataCtx, result, result, posMap[idx]) if diags.Extend(diag) { return nil, nil, diags } diff --git a/eval/dynamic.go b/eval/dynamic.go new file mode 100644 index 00000000..a6be5f43 --- /dev/null +++ b/eval/dynamic.go @@ -0,0 +1,207 @@ +package eval + +import ( + "context" + "fmt" + "log/slog" + "maps" + + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + + "github.com/blackstork-io/fabric/cmd/fabctx" + "github.com/blackstork-io/fabric/parser" + "github.com/blackstork-io/fabric/parser/definitions" + "github.com/blackstork-io/fabric/pkg/diagnostics" + "github.com/blackstork-io/fabric/pkg/utils" + "github.com/blackstork-io/fabric/plugin/dataspec" + "github.com/blackstork-io/fabric/plugin/dataspec/constraint" + "github.com/blackstork-io/fabric/plugin/dataspec/deferred" + "github.com/blackstork-io/fabric/plugin/plugindata" +) + +type Dynamic struct { + block *hclsyntax.Block + items *dataspec.Attr + children []*Content +} + +var dynamicBlockItems = &dataspec.AttrSpec{ + Name: "items", + Type: plugindata.Encapsulated.CtyType(), + Doc: "Items to be iterated over (list or map)", + Constraints: constraint.Required, +} + +func LoadDynamic(ctx context.Context, providers ContentProviders, node *definitions.ParsedDynamic) (_ *Dynamic, diags diagnostics.Diag) { + var diag diagnostics.Diag + block := &Dynamic{ + block: node.Block, + children: make([]*Content, 0, len(node.Content)), + } + evalCtx := fabctx.GetEvalContext(deferred.WithQueryFuncs(ctx)) + block.items, diag = dataspec.DecodeAttr(evalCtx, node.Items, dynamicBlockItems) + diags.Extend(diag) + + for _, child := range node.Content { + decoded, diag := LoadContent(ctx, providers, child) + diags.Extend(diag) + block.children = append(block.children, decoded) + } + return block, diags +} + +func applyDynamicContentVars(ctx context.Context, children []*Content, dataCtx plugindata.Map, dynVarVals *definitions.ParsedVars) (res []*Content, diags diagnostics.Diag) { + res = make([]*Content, 0, len(children)) + + // unwrap dynamic content + for _, child := range children { + switch { + case child.Plugin != nil: + plugin := utils.Clone(child.Plugin) + plugin.Vars = plugin.Vars.MergeWithBaseVars(dynVarVals) + res = append(res, &Content{Plugin: plugin}) + case child.Section != nil: + section := utils.Clone(child.Section) + section.vars = section.vars.MergeWithBaseVars(dynVarVals) + res = append(res, &Content{Section: section}) + case child.Dynamic != nil: + nonDynamicContent, diag := unwrapDynamicItem(ctx, child.Dynamic, dataCtx) + diags.Extend(diag) + res = append(res, nonDynamicContent...) + default: + slog.Warn("Child has unknown type") + res = append(res, child) + } + } + return +} + +// unwrapDynamicContent unwraps dynamic content in children +func UnwrapDynamicContent(ctx context.Context, children []*Content, dataCtx plugindata.Map) (res []*Content, diags diagnostics.Diag) { + return unwrapDynamicContent(deferred.WithQueryFuncs(ctx), children, dataCtx) +} + +func unwrapDynamicContent(ctx context.Context, children []*Content, dataCtx plugindata.Map) (res []*Content, diags diagnostics.Diag) { + // Goal: expand dynamic content on the first layer of children + // (without descending into child sections) + res = make([]*Content, 0, len(children)) + + // unwrap dynamic content + for _, child := range children { + if child.Dynamic == nil { + res = append(res, child) + continue + } + nonDynamicContent, diag := unwrapDynamicItem(ctx, child.Dynamic, dataCtx) + diags.Extend(diag) + res = append(res, nonDynamicContent...) + } + return +} + +func unwrapDynamicItem(ctx context.Context, dynamic *Dynamic, dataCtx plugindata.Map) (res []*Content, diags diagnostics.Diag) { + val, diag := dataspec.EvalAttr(ctx, dynamic.items, dataCtx) + if diags.Extend(diag) || val.IsNull() { + return + } + data := plugindata.Encapsulated.MustFromCty(val) + if data == nil { + diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid dynamic block items", + Detail: "Dynamic block items must be a list or a map, got nil", + Subject: dynamic.items.ValueRange.Ptr(), + }) + return + } + + var dynamicItems [][2]plugindata.Data + switch dt := (*data).(type) { + case nil: + return + case plugindata.List: + dynamicItems = make([][2]plugindata.Data, 0, len(dt)) + for idx, item := range dt { + dynamicItems = append(dynamicItems, [2]plugindata.Data{ + plugindata.Number(idx), + item, + }) + } + case plugindata.Map: + dynamicItems = make([][2]plugindata.Data, 0, len(dt)) + for key, item := range dt { + dynamicItems = append(dynamicItems, [2]plugindata.Data{ + plugindata.String(key), + item, + }) + } + default: + diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid dynamic block items", + Detail: fmt.Sprintf("Dynamic block items must be a list or a map, got %T", dt), + Subject: dynamic.items.ValueRange.Ptr(), + }) + return + } + + newDataCtx := maps.Clone(dataCtx) + vars := getVarsCopy(newDataCtx) + for _, kv := range dynamicItems { + vars[itemIndexVarName] = kv[0] + vars[itemVarName] = kv[1] + newDynVarVals, diag := parseDynVars(ctx, kv[0], kv[1], dynamic.items.ValueRange) + if diags.Extend(diag) { + // infallible + return + } + nonDynamicContent, diag := applyDynamicContentVars(ctx, dynamic.children, newDataCtx, newDynVarVals) + if diags.Extend(diag) { + // stop dynamic block processing on error: it's likely that + // the error will be repeated for each item and only add noise + break + } + res = append(res, nonDynamicContent...) + } + return +} + +const ( + itemIndexVarName = "dynamic_item_index" + itemVarName = "dynamic_item" +) + +func parseDynVars(ctx context.Context, idx, val plugindata.Data, rng hcl.Range) (parsed *definitions.ParsedVars, diags diagnostics.Diag) { + // use existing vars parser by creating a synthetic (dynamic_)vars block + return parser.ParseVars(ctx, &hclsyntax.Block{ + Type: "dynamic_vars", + TypeRange: rng, + OpenBraceRange: rng, + CloseBraceRange: rng, + Body: &hclsyntax.Body{ + SrcRange: rng, + EndRange: rng, + Attributes: map[string]*hclsyntax.Attribute{ + itemIndexVarName: { + Name: itemIndexVarName, + Expr: &hclsyntax.LiteralValueExpr{ + Val: plugindata.Encapsulated.ValToCty(idx), + }, + SrcRange: rng, + NameRange: rng, + EqualsRange: rng, + }, + itemVarName: { + Name: itemVarName, + Expr: &hclsyntax.LiteralValueExpr{ + Val: plugindata.Encapsulated.ValToCty(val), + }, + SrcRange: rng, + NameRange: rng, + EqualsRange: rng, + }, + }, + }, + }, nil) +} diff --git a/eval/plugin_action.go b/eval/plugin_action.go index 91c9f987..49582f24 100644 --- a/eval/plugin_action.go +++ b/eval/plugin_action.go @@ -12,4 +12,5 @@ type PluginAction struct { Meta *definitions.MetaBlock Config *dataspec.Block Args *dataspec.Block + IsIncluded *dataspec.Attr } diff --git a/eval/plugin_content_action.go b/eval/plugin_content_action.go index 506c1805..75aae4ca 100644 --- a/eval/plugin_content_action.go +++ b/eval/plugin_content_action.go @@ -5,7 +5,9 @@ import ( "fmt" "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/blackstork-io/fabric/cmd/fabctx" "github.com/blackstork-io/fabric/parser/definitions" "github.com/blackstork-io/fabric/pkg/diagnostics" "github.com/blackstork-io/fabric/plugin" @@ -21,7 +23,7 @@ type PluginContentAction struct { RequiredVars []string } -func (action *PluginContentAction) RenderContent(ctx context.Context, dataCtx plugindata.Map, doc, parent *plugin.ContentSection, contentID uint32) (res *plugin.ContentResult, diags diagnostics.Diag) { +func (action *PluginContentAction) RenderContent(ctx context.Context, dataCtx plugindata.Map, doc, parent *plugin.ContentSection, contentID uint32) (diags diagnostics.Diag) { contentMap := plugindata.Map{} if action.PluginAction.Meta != nil { contentMap[definitions.BlockKindMeta] = action.PluginAction.Meta.AsPluginData() @@ -34,22 +36,29 @@ func (action *PluginContentAction) RenderContent(ctx context.Context, dataCtx pl if diags.Extend(diag) { return } + isIncluded, diag := dataspec.EvalAttr(ctx, action.IsIncluded, dataCtx) + if diags.Extend(diag) { + return + } + if isIncluded.IsNull() || !plugindata.IsTruthy(*plugindata.Encapsulated.MustFromCty(isIncluded)) { + return + } if len(action.RequiredVars) > 0 { - diag := verifyRequiredVars(dataCtx, action.RequiredVars, action.Source.Block) + diag = verifyRequiredVars(dataCtx, action.RequiredVars, action.Source.Block) if diags.Extend(diag) { return } } - diags.Extend(dataspec.EvalBlock(ctx, action.Args, dataCtx)) - if diags.HasErrors() { + evaluatedBlock, diag := dataspec.EvalBlockCopy(ctx, action.Args, dataCtx) + if diags.Extend(diag) { return } - res, diag = action.Provider.Execute(ctx, &plugin.ProvideContentParams{ + res, diag := action.Provider.Execute(ctx, &plugin.ProvideContentParams{ Config: action.Config, - Args: action.Args, + Args: evaluatedBlock, DataContext: dataCtx, ContentID: contentID, }) @@ -62,7 +71,7 @@ func (action *PluginContentAction) RenderContent(ctx context.Context, dataCtx pl } } parent.Add(res.Content, res.Location) - return res, diags + return } func LoadPluginContentAction(ctx context.Context, providers ContentProviders, node *definitions.ParsedPlugin) (_ *PluginContentAction, diags diagnostics.Diag) { @@ -100,6 +109,19 @@ func LoadPluginContentAction(ctx context.Context, providers ContentProviders, no if diags.Extend(diag) { return nil, diags } + isIncluded := node.IsIncluded + if isIncluded == nil { + isIncluded = defaultIsIncluded(node.Source.Block.DefRange()) + } + + isIncludedAttr, diag := dataspec.DecodeAttr( + fabctx.GetEvalContext(deferred.WithQueryFuncs(ctx)), + isIncluded, + isIncludedSpec, + ) + if diags.Extend(diag) { + return nil, diags + } return &PluginContentAction{ PluginAction: &PluginAction{ Source: node.Source, @@ -108,9 +130,29 @@ func LoadPluginContentAction(ctx context.Context, providers ContentProviders, no Meta: node.Meta, Config: cfg, Args: args, + IsIncluded: isIncludedAttr, }, Provider: cp, Vars: node.Vars, RequiredVars: node.RequiredVars, }, diags } + +var isIncludedSpec = &dataspec.AttrSpec{ + Name: "is_included", + Type: plugindata.Encapsulated.CtyType(), + Doc: "Condition indicating whether content should be rendered", +} + +func defaultIsIncluded(rng hcl.Range) *hclsyntax.Attribute { + return &hclsyntax.Attribute{ + Name: definitions.AttrIsIncluded, + Expr: &hclsyntax.LiteralValueExpr{ + Val: plugindata.Encapsulated.ValToCty(plugindata.Bool(true)), + SrcRange: rng, + }, + SrcRange: rng, + NameRange: rng, + EqualsRange: rng, + } +} diff --git a/eval/section.go b/eval/section.go index a28b4abd..a97bfbca 100644 --- a/eval/section.go +++ b/eval/section.go @@ -8,9 +8,12 @@ import ( "github.com/hashicorp/hcl/v2" + "github.com/blackstork-io/fabric/cmd/fabctx" "github.com/blackstork-io/fabric/parser/definitions" "github.com/blackstork-io/fabric/pkg/diagnostics" "github.com/blackstork-io/fabric/plugin" + "github.com/blackstork-io/fabric/plugin/dataspec" + "github.com/blackstork-io/fabric/plugin/dataspec/deferred" "github.com/blackstork-io/fabric/plugin/plugindata" ) @@ -20,55 +23,70 @@ type Section struct { vars *definitions.ParsedVars source *definitions.Section requiredVars []string + isIncluded *dataspec.Attr } -func (block *Section) RenderContent(ctx context.Context, dataCtx plugindata.Map, doc, parent *plugin.ContentSection, contentID uint32) (_ *plugin.ContentResult, diags diagnostics.Diag) { +func (block *Section) RenderContent(ctx context.Context, dataCtx plugindata.Map, doc, parent *plugin.ContentSection, contentID uint32) (diags diagnostics.Diag) { sectionData := plugindata.Map{} if block.meta != nil { sectionData[definitions.BlockKindMeta] = block.meta.AsPluginData() } + dataCtx[definitions.BlockKindSection] = sectionData section := new(plugin.ContentSection) if parent != nil { err := parent.Add(section, &plugin.Location{ Index: contentID, }) if err != nil { - return nil, diagnostics.Diag{{ + return diagnostics.Diag{{ Severity: hcl.DiagError, Summary: "Failed to place content", Detail: fmt.Sprintf("Failed to place content: %s", err), }} } } + + diag := ApplyVars(ctx, block.vars, dataCtx) + if diags.Extend(diag) { + return + } + + isIncluded, diag := dataspec.EvalAttr(ctx, block.isIncluded, dataCtx) + if diags.Extend(diag) { + return + } + if isIncluded.IsNull() || !plugindata.IsTruthy(*plugindata.Encapsulated.MustFromCty(isIncluded)) { + return + } + + children, diag := UnwrapDynamicContent(ctx, block.children, dataCtx) + if diags.Extend(diag) { + return + } + // create a position map for content blocks posMap := make(map[int]uint32) - for i := range block.children { + for i := range children { empty := new(plugin.ContentEmpty) section.Add(empty, nil) posMap[i] = empty.ID() } // sort content blocks by invocation order - invokeList := make([]int, 0, len(block.children)) - for i := range block.children { + invokeList := make([]int, 0, len(children)) + for i := range children { invokeList = append(invokeList, i) } slices.SortStableFunc(invokeList, func(a, b int) int { - ao := block.children[a].InvocationOrder() - bo := block.children[b].InvocationOrder() + ao := children[a].InvocationOrder() + bo := children[b].InvocationOrder() return ao.Weight() - bo.Weight() }) - dataCtx[definitions.BlockKindSection] = sectionData - - diag := ApplyVars(ctx, block.vars, dataCtx) - if diags.Extend(diag) { - return nil, diags - } // verify required vars if len(block.requiredVars) > 0 { diag := verifyRequiredVars(dataCtx, block.requiredVars, block.source.Block) if diags.Extend(diag) { - return nil, diags + return } } @@ -78,34 +96,42 @@ func (block *Section) RenderContent(ctx context.Context, dataCtx plugindata.Map, sectionData[definitions.BlockKindContent] = section.AsData() // execute the content block - _, diag := block.children[idx].RenderContent(ctx, maps.Clone(dataCtx), doc, section, posMap[idx]) + diag := children[idx].RenderContent(ctx, maps.Clone(dataCtx), doc, section, posMap[idx]) if diags.Extend(diag) { - return nil, diags + return } } // compact the content tree to remove empty content nodes section.Compact() - return &plugin.ContentResult{ - Content: section, - Location: &plugin.Location{ - Index: contentID, - }, - }, diags + return } -func LoadSection(ctx context.Context, providers ContentProviders, node *definitions.ParsedSection) (_ *Section, diag diagnostics.Diag) { - var diags diagnostics.Diag +func LoadSection(ctx context.Context, providers ContentProviders, node *definitions.ParsedSection) (_ *Section, diags diagnostics.Diag) { block := &Section{ meta: node.Meta, vars: node.Vars, source: node.Source, requiredVars: node.RequiredVars, } + var diag diagnostics.Diag + isIncluded := node.IsIncluded + if isIncluded == nil { + isIncluded = defaultIsIncluded(node.Source.Block.DefRange()) + } + + block.isIncluded, diag = dataspec.DecodeAttr( + fabctx.GetEvalContext(deferred.WithQueryFuncs(ctx)), + isIncluded, + isIncludedSpec, + ) + if diags.Extend(diag) { + return + } if node.Title != nil { title, diag := LoadContent(ctx, providers, node.Title) if diags.Extend(diag) { - return nil, diags + return } block.children = append(block.children, title) @@ -113,7 +139,7 @@ func LoadSection(ctx context.Context, providers ContentProviders, node *definiti for _, child := range node.Content { decoded, diag := LoadContent(ctx, providers, child) if diags.Extend(diag) { - return nil, diags + return } block.children = append(block.children, decoded) } diff --git a/eval/vars.go b/eval/vars.go index d825a58b..5157426d 100644 --- a/eval/vars.go +++ b/eval/vars.go @@ -13,13 +13,9 @@ import ( "github.com/blackstork-io/fabric/plugin/plugindata" ) -// Evaluates `variables` and stores the results in `dataCtx` under the key "vars". -func ApplyVars(ctx context.Context, variables *definitions.ParsedVars, dataCtx plugindata.Map) (diags diagnostics.Diag) { - if variables.Empty() { - return - } - var vars plugindata.Map - +// getVarsCopy creates a new vars key in the data context if it doesn't exist, +// or clones the existing one. +func getVarsCopy(dataCtx plugindata.Map) (vars plugindata.Map) { varsData := dataCtx["vars"] if varsData == nil { vars = plugindata.Map{} @@ -28,6 +24,15 @@ func ApplyVars(ctx context.Context, variables *definitions.ParsedVars, dataCtx p vars = maps.Clone(varsData.(plugindata.Map)) } dataCtx["vars"] = vars + return vars +} + +// Evaluates `variables` and stores the results in `dataCtx` under the key "vars". +func ApplyVars(ctx context.Context, variables *definitions.ParsedVars, dataCtx plugindata.Map) (diags diagnostics.Diag) { + if variables.Empty() { + return + } + vars := getVarsCopy(dataCtx) var diag diagnostics.Diag for _, variable := range variables.Variables { vars[variable.Name], diag = evalVar(ctx, dataCtx, variable) diff --git a/parser/definitions/definitions.go b/parser/definitions/definitions.go index ab4a7fb2..589487fc 100644 --- a/parser/definitions/definitions.go +++ b/parser/definitions/definitions.go @@ -15,12 +15,15 @@ const ( BlockKindVars = "vars" BlockKindSection = "section" BlockKindGlobalConfig = "fabric" + BlockKindDynamic = "dynamic" PluginTypeRef = "ref" AttrRefBase = "base" AttrTitle = "title" AttrLocalVar = "local_var" AttrRequiredVars = "required_vars" + AttrIsIncluded = "is_included" + AttrDynamicItems = "items" ) type FabricBlock interface { diff --git a/parser/definitions/document.go b/parser/definitions/document.go index 3fc83ec8..ba5a74e4 100644 --- a/parser/definitions/document.go +++ b/parser/definitions/document.go @@ -9,7 +9,6 @@ import ( "github.com/blackstork-io/fabric/pkg/encapsulator" ) -// Document and section are very similar conceptually. type Document struct { Block *hclsyntax.Block Name string diff --git a/parser/definitions/dynamic.go b/parser/definitions/dynamic.go new file mode 100644 index 00000000..26ddc04b --- /dev/null +++ b/parser/definitions/dynamic.go @@ -0,0 +1,17 @@ +package definitions + +import ( + "github.com/hashicorp/hcl/v2/hclsyntax" +) + +type Dynamic struct { + ParseResult *ParsedDynamic +} + +type ParsedDynamic struct { + Block *hclsyntax.Block + // Items is a list of items to be iterated over dynamically. + // Always present. + Items *hclsyntax.Attribute + Content []*ParsedContent +} diff --git a/parser/definitions/global_config.go b/parser/definitions/global_config.go index cf398b47..cbb64502 100644 --- a/parser/definitions/global_config.go +++ b/parser/definitions/global_config.go @@ -65,10 +65,10 @@ func (g *GlobalConfigDefinition) parseEnvVarPattern(ctx context.Context) (pat gl if diags.Extend(diag) { return } - if attrVal.Value.IsNull() { + if attrVal.IsNull() { return } - strVal := attrVal.Value.AsString() + strVal := attrVal.AsString() trimmedStr := strings.TrimSpace(strVal) if trimmedStr != strVal { diff --git a/parser/definitions/meta.go b/parser/definitions/meta.go index ff30985b..962a229c 100644 --- a/parser/definitions/meta.go +++ b/parser/definitions/meta.go @@ -1,6 +1,10 @@ package definitions -import "github.com/blackstork-io/fabric/plugin/plugindata" +import ( + "slices" + + "github.com/blackstork-io/fabric/plugin/plugindata" +) type MetaBlock struct { Name string `hcl:"name,optional"` @@ -15,6 +19,22 @@ type MetaBlock struct { // TODO: ?store def range defRange hcl.Range } +func (m *MetaBlock) MatchesTags(requiredTags []string) bool { + var tags []string + if m != nil { + tags = m.Tags + } + if len(tags) < len(requiredTags) { + return false + } + for _, tag := range requiredTags { + if !slices.Contains(tags, tag) { + return false + } + } + return true +} + func (m *MetaBlock) AsPluginData() plugindata.Data { tags := make(plugindata.List, len(m.Tags)) authors := make(plugindata.List, len(m.Authors)) diff --git a/parser/definitions/parsed_plugin.go b/parser/definitions/parsed_plugin.go index 48437231..11637fbd 100644 --- a/parser/definitions/parsed_plugin.go +++ b/parser/definitions/parsed_plugin.go @@ -1,6 +1,8 @@ package definitions import ( + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/blackstork-io/fabric/parser/evaluation" ) @@ -13,9 +15,11 @@ type ParsedPlugin struct { Invocation *evaluation.BlockInvocation Vars *ParsedVars RequiredVars []string + IsIncluded *hclsyntax.Attribute } type ParsedContent struct { Section *ParsedSection Plugin *ParsedPlugin + Dynamic *ParsedDynamic } diff --git a/parser/definitions/section.go b/parser/definitions/section.go index bce41835..7aecfe78 100644 --- a/parser/definitions/section.go +++ b/parser/definitions/section.go @@ -24,6 +24,7 @@ type ParsedSection struct { Content []*ParsedContent Vars *ParsedVars RequiredVars []string + IsIncluded *hclsyntax.Attribute } func (s ParsedSection) Name() string { diff --git a/parser/definitions/title.go b/parser/definitions/title.go deleted file mode 100644 index 1abc2984..00000000 --- a/parser/definitions/title.go +++ /dev/null @@ -1,48 +0,0 @@ -package definitions - -import ( - "github.com/hashicorp/hcl/v2" - "github.com/hashicorp/hcl/v2/hclsyntax" - "github.com/zclconf/go-cty/cty" - - "github.com/blackstork-io/fabric/parser/evaluation" - "github.com/blackstork-io/fabric/pkg/utils" -) - -func NewTitle(title *hclsyntax.Attribute, resolver ConfigResolver) *ParsedContent { - const pluginName = "title" - - value := *title - value.Name = "value" - - relativeSize := *title - relativeSize.Name = "relative_size" - relativeSize.Expr = &hclsyntax.LiteralValueExpr{ - Val: cty.NumberIntVal(-1), - SrcRange: title.Expr.Range(), - } - return &ParsedContent{ - Plugin: &ParsedPlugin{ - PluginName: pluginName, - Config: resolver(BlockKindContent, pluginName), - Invocation: &evaluation.BlockInvocation{ - Block: &hclsyntax.Block{ - Type: BlockKindContent, - TypeRange: title.NameRange, - Labels: []string{pluginName}, - LabelRanges: []hcl.Range{title.NameRange}, - Body: &hclsyntax.Body{ - Attributes: hclsyntax.Attributes{ - "value": &value, - "relative_size": &relativeSize, - }, - SrcRange: title.SrcRange, - EndRange: utils.RangeEnd(title.Expr.Range()), - }, - OpenBraceRange: utils.RangeStart(title.NameRange), - CloseBraceRange: utils.RangeEnd(title.Expr.Range()), - }, - }, - }, - } -} diff --git a/parser/parse_document.go b/parser/parse_document.go index fe66d94f..977c6ce3 100644 --- a/parser/parse_document.go +++ b/parser/parse_document.go @@ -17,7 +17,10 @@ func (db *DefinedBlocks) ParseDocument(ctx context.Context, d *definitions.Docum doc.Source = d if title := d.Block.Body.Attributes[definitions.AttrTitle]; title != nil { - doc.Content = append(doc.Content, definitions.NewTitle(title, db.DefaultConfig)) + titleContent, diag := db.ParseTitle(ctx, title) + if !diag.Extend(diags) { + doc.Content = append(doc.Content, titleContent) + } } var origMeta *hcl.Range @@ -93,6 +96,15 @@ func (db *DefinedBlocks) ParseDocument(ctx context.Context, d *definitions.Docum doc.Content = append(doc.Content, &definitions.ParsedContent{ Section: parsedSection, }) + case definitions.BlockKindDynamic: + dynamic, diag := db.ParseDynamic(ctx, block) + if diags.Extend(diag) { + continue + } + doc.Content = append(doc.Content, &definitions.ParsedContent{ + Dynamic: dynamic, + }) + default: diags.Append(definitions.NewNestingDiag( d.Block.Type, @@ -105,6 +117,7 @@ func (db *DefinedBlocks) ParseDocument(ctx context.Context, d *definitions.Docum definitions.BlockKindVars, definitions.BlockKindSection, definitions.BlockKindPublish, + definitions.BlockKindDynamic, }, )) continue diff --git a/parser/parse_dynamic.go b/parser/parse_dynamic.go new file mode 100644 index 00000000..15f6a970 --- /dev/null +++ b/parser/parse_dynamic.go @@ -0,0 +1,105 @@ +package parser + +import ( + "context" + "fmt" + + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + + "github.com/blackstork-io/fabric/parser/definitions" + "github.com/blackstork-io/fabric/pkg/circularRefDetector" + "github.com/blackstork-io/fabric/pkg/diagnostics" + "github.com/blackstork-io/fabric/pkg/utils" +) + +func (db *DefinedBlocks) ParseDynamic(ctx context.Context, block *hclsyntax.Block) (parsed *definitions.ParsedDynamic, diags diagnostics.Diag) { + res := definitions.ParsedDynamic{ + Block: block, + } + + res.Items, _ = utils.Pop(block.Body.Attributes, definitions.AttrDynamicItems) + + if res.Items == nil { + diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Dynamic block without items", + Detail: fmt.Sprintf("Dynamic block must have an attribute %q", definitions.AttrDynamicItems), + Subject: block.DefRange().Ptr(), + }) + } + + for k, v := range block.Body.Attributes { + diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagWarning, + Summary: "Unsupported attribute", + Detail: fmt.Sprintf("Dynamic block does not support attribute %q, it will be ignored", k), + Subject: &v.NameRange, + }) + } + + validChildren := []string{ + definitions.BlockKindContent, + definitions.BlockKindSection, + definitions.BlockKindDynamic, + } + validChildrenSet := utils.SliceToSet(validChildren) + + for _, block := range block.Body.Blocks { + if !utils.Contains(validChildrenSet, block.Type) { + diags.Append(definitions.NewNestingDiag( + block.Type, + block, + block.Body, + validChildren, + )) + continue + } + switch block.Type { + case definitions.BlockKindContent: + plugin, diag := definitions.DefinePlugin(block, false) + if diags.Extend(diag) { + continue + } + call, diag := db.ParsePlugin(ctx, plugin) + if diags.Extend(diag) { + continue + } + res.Content = append(res.Content, &definitions.ParsedContent{ + Plugin: call, + }) + case definitions.BlockKindSection: + subSection, diag := definitions.DefineSection(block, false) + if diags.Extend(diag) { + continue + } + circularRefDetector.Add(block, block.DefRange().Ptr()) + parsedSubSection, diag := db.ParseSection(ctx, subSection) + circularRefDetector.Remove(block, &diag) + if diags.Extend(diag) { + continue + } + res.Content = append(res.Content, &definitions.ParsedContent{ + Section: parsedSubSection, + }) + case definitions.BlockKindDynamic: + subDynamic, diag := db.ParseDynamic(ctx, block) + if diags.Extend(diag) { + continue + } + res.Content = append(res.Content, &definitions.ParsedContent{ + Dynamic: subDynamic, + }) + } + } + if len(res.Content) == 0 { + diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagWarning, + Summary: "Dynamic block without content", + Detail: "Dynamic block without any content can be removed, as it has no effect", + Subject: block.DefRange().Ptr(), + }) + } + parsed = &res + return +} diff --git a/parser/parse_plugin.go b/parser/parse_plugin.go index 5fa30eec..c8248ed0 100644 --- a/parser/parse_plugin.go +++ b/parser/parse_plugin.go @@ -51,6 +51,8 @@ func (db *DefinedBlocks) ParsePlugin(ctx context.Context, plugin *definitions.Pl } func (db *DefinedBlocks) parsePlugin(ctx context.Context, plugin *definitions.Plugin) (parsed *definitions.ParsedPlugin, diags diagnostics.Diag) { + var diag diagnostics.Diag + res := definitions.ParsedPlugin{ Source: plugin, PluginName: plugin.Name(), @@ -61,6 +63,10 @@ func (db *DefinedBlocks) parsePlugin(ctx context.Context, plugin *definitions.Pl // Parsing body body := plugin.Block.Body + if plugin.Kind() == definitions.BlockKindContent { + res.IsIncluded, _ = utils.Pop(body.Attributes, definitions.AttrIsIncluded) + } + configAttr, _ := utils.Pop(body.Attributes, definitions.BlockKindConfig) var configBlock, varsBlock *hclsyntax.Block @@ -125,7 +131,6 @@ func (db *DefinedBlocks) parsePlugin(ctx context.Context, plugin *definitions.Pl ) localVar, _ := utils.Pop(body.Attributes, definitions.AttrLocalVar) - var diag diagnostics.Diag res.Vars, diag = ParseVars(ctx, varsBlock, localVar) diags.Extend(diag) @@ -143,11 +148,11 @@ func (db *DefinedBlocks) parsePlugin(ctx context.Context, plugin *definitions.Pl // Parsing the ref var refBaseConfig evaluation.Configuration - refBase, refFound := utils.Pop(body.Attributes, definitions.AttrRefBase) + refBase, refBaseFound := utils.Pop(body.Attributes, definitions.AttrRefBase) pluginIsRef := plugin.IsRef() switch { - case !pluginIsRef && !refFound: // happy path, no ref - case pluginIsRef && refFound: // happy path, ref present + case !pluginIsRef && !refBaseFound: // happy path, no ref + case pluginIsRef && refBaseFound: // happy path, ref present baseEval, diag := db.parseRefBase(ctx, plugin, refBase.Expr) if diags.Extend(diag) { return @@ -163,10 +168,13 @@ func (db *DefinedBlocks) parsePlugin(ctx context.Context, plugin *definitions.Pl res.Vars = res.Vars.MergeWithBaseVars(baseEval.Vars) res.RequiredVars = append(res.RequiredVars, baseEval.RequiredVars...) + if res.IsIncluded == nil { + res.IsIncluded = baseEval.IsIncluded + } updateRefBody(invocation.Body, baseEval.Invocation.Body) - case pluginIsRef && !refFound: + case pluginIsRef && !refBaseFound: diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, Summary: "Ref block missing 'base' argument", @@ -175,7 +183,7 @@ func (db *DefinedBlocks) parsePlugin(ctx context.Context, plugin *definitions.Pl Context: &body.SrcRange, }) return - case !pluginIsRef && refFound: + case !pluginIsRef && refBaseFound: diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagWarning, Summary: "Non-ref block contains 'base' argument", @@ -187,7 +195,8 @@ func (db *DefinedBlocks) parsePlugin(ctx context.Context, plugin *definitions.Pl var dgs diagnostics.Diag res.Config, dgs = db.parsePluginConfig(plugin, configAttr, configBlock, refBaseConfig) - if diags.Extend(dgs) { + diags.Extend(dgs) + if diags.HasErrors() { return } diff --git a/parser/parse_section.go b/parser/parse_section.go index 10ad11bb..6d7ddadc 100644 --- a/parser/parse_section.go +++ b/parser/parse_section.go @@ -51,7 +51,10 @@ func (db *DefinedBlocks) parseSection(ctx context.Context, section *definitions. res := definitions.ParsedSection{} res.Source = section if title := section.Block.Body.Attributes["title"]; title != nil { - res.Title = definitions.NewTitle(title, db.DefaultConfig) + titleContent, diag := db.ParseTitle(ctx, title) + if !diag.Extend(diags) { + res.Title = titleContent + } } var origMeta *hcl.Range @@ -81,6 +84,7 @@ func (db *DefinedBlocks) parseSection(ctx context.Context, section *definitions. definitions.BlockKindMeta, definitions.BlockKindSection, definitions.BlockKindVars, + definitions.BlockKindDynamic, } } validChildrenSet := utils.SliceToSet(validChildren) @@ -158,6 +162,14 @@ func (db *DefinedBlocks) parseSection(ctx context.Context, section *definitions. res.Content = append(res.Content, &definitions.ParsedContent{ Section: parsedSubSection, }) + case definitions.BlockKindDynamic: + dynamic, diag := db.ParseDynamic(ctx, block) + if diags.Extend(diag) { + continue + } + res.Content = append(res.Content, &definitions.ParsedContent{ + Dynamic: dynamic, + }) } } @@ -174,6 +186,8 @@ func (db *DefinedBlocks) parseSection(ctx context.Context, section *definitions. diags.Extend(diag) } + res.IsIncluded = section.Block.Body.Attributes[definitions.AttrIsIncluded] + if refBase == nil { parsed = &res return @@ -207,6 +221,9 @@ func (db *DefinedBlocks) parseSection(ctx context.Context, section *definitions. if res.Meta == nil { res.Meta = baseEval.Meta } + if res.IsIncluded == nil { + res.IsIncluded = baseEval.IsIncluded + } res.Vars = res.Vars.MergeWithBaseVars(baseEval.Vars) res.RequiredVars = append(res.RequiredVars, baseEval.RequiredVars...) diff --git a/parser/parse_title.go b/parser/parse_title.go new file mode 100644 index 00000000..614bb71e --- /dev/null +++ b/parser/parse_title.go @@ -0,0 +1,56 @@ +package parser + +import ( + "context" + + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/zclconf/go-cty/cty" + + "github.com/blackstork-io/fabric/parser/definitions" + "github.com/blackstork-io/fabric/pkg/diagnostics" + "github.com/blackstork-io/fabric/pkg/utils" +) + +func (db *DefinedBlocks) ParseTitle(ctx context.Context, title *hclsyntax.Attribute) (res *definitions.ParsedContent, diags diagnostics.Diag) { + const pluginName = "title" + + value := *title + value.Name = "value" + + relativeSize := *title + relativeSize.Name = "relative_size" + relativeSize.Expr = &hclsyntax.LiteralValueExpr{ + Val: cty.NumberIntVal(-1), + SrcRange: title.Expr.Range(), + } + + block := &hclsyntax.Block{ + Type: definitions.BlockKindContent, + TypeRange: title.NameRange, + Labels: []string{pluginName}, + LabelRanges: []hcl.Range{title.NameRange}, + Body: &hclsyntax.Body{ + Attributes: hclsyntax.Attributes{ + "value": &value, + "relative_size": &relativeSize, + }, + SrcRange: title.SrcRange, + EndRange: utils.RangeEnd(title.Expr.Range()), + }, + OpenBraceRange: utils.RangeStart(title.NameRange), + CloseBraceRange: utils.RangeEnd(title.Expr.Range()), + } + def, diag := definitions.DefinePlugin(block, false) + if diags.Extend(diag) { + return + } + parsed, diag := db.ParsePlugin(ctx, def) + if diags.Extend(diag) { + return + } + res = &definitions.ParsedContent{ + Plugin: parsed, + } + return +} diff --git a/pkg/utils/golang.go b/pkg/utils/golang.go index 4222c535..8671408c 100644 --- a/pkg/utils/golang.go +++ b/pkg/utils/golang.go @@ -29,3 +29,12 @@ func Must[T any](val T, err error) T { } return val } + +// Clone performs a shallow clone operation on pointer value +func Clone[T any](val *T) *T { + if val == nil { + return nil + } + valC := *val + return &valC +} diff --git a/plugin/content.go b/plugin/content.go index 2e53e261..0a4cb7d0 100644 --- a/plugin/content.go +++ b/plugin/content.go @@ -185,14 +185,20 @@ func (c *ContentSection) AsPluginData() plugindata.Data { // Compact removes empty sections from the content tree. func (c *ContentSection) Compact() { c.Children = slices.DeleteFunc(c.Children, func(c Content) bool { - _, ok := c.(*ContentEmpty) - return ok - }) - for _, child := range c.Children { - if section, ok := child.(*ContentSection); ok { + if _, ok := c.(*ContentEmpty); ok { + return true + } + if section, ok := c.(*ContentSection); ok { section.Compact() + return section.IsEmpty() } - } + return false + }) +} + +// IsEmpty returns true if the section does not contain children +func (c *ContentSection) IsEmpty() bool { + return len(c.Children) == 0 } // AsData returns the content tree as a map. diff --git a/plugin/dataspec/decode.go b/plugin/dataspec/decode.go index a9ce90e1..f60b2f80 100644 --- a/plugin/dataspec/decode.go +++ b/plugin/dataspec/decode.go @@ -52,14 +52,14 @@ func (t *transformer) transform(val cty.Value) (_ cty.Value, diags diagnostics.D // TODO: warn in these cases switch { case val.IsNull(): - slog.Debug("Null dererred value", "path", t.path.NewErrorf("path")) + slog.Debug("Null deferred value", "path", t.path.NewErrorf("path")) val = cty.NullVal(cty.DynamicPseudoType) case !val.IsKnown(): - slog.Debug("Unknown dererred value", "path", t.path.NewErrorf("path")) + slog.Debug("Unknown deferred value", "path", t.path.NewErrorf("path")) val = cty.UnknownVal(cty.DynamicPseudoType) default: eval := deferred.Type.MustFromCty(val) - val, diags = eval.Eval(t.ctx, t.dataCtx) + val, diags = eval.DeferredEval(t.ctx, t.dataCtx) diags.Refine(diagnostics.AddPath(t.path)) } case plugindata.Encapsulated.CtyTypeEqual(ty): @@ -101,7 +101,7 @@ func (t *transformer) transform(val cty.Value) (_ cty.Value, diags diagnostics.D case ty.IsSetType() && cty.CanSetVal(vals): val = cty.SetVal(vals) default: - // Lists are replaced by tuples if no longer homogenious. + // Lists are replaced by tuples if no longer homogenous. // Sets are attempted to be converted to set type. val = cty.TupleVal(vals) if ty.IsSetType() { @@ -193,7 +193,30 @@ func DecodeAndEvalBlock(ctx context.Context, block *hclsyntax.Block, rootSpec *R return } +// EvalBlockCopy evaluates deferred values in the given block and validates the attributes. +// The original block is not modified. +func EvalBlockCopy(ctx context.Context, block *Block, dataCtx plugindata.Map) (evaluatedBlock *Block, diags diagnostics.Diag) { + if block == nil { + return + } + blockC := *block + evaluatedBlock = &blockC + + evaluatedBlock.Blocks = utils.FnMapDiags(&diags, block.Blocks, func(block *Block) (*Block, diagnostics.Diag) { + return EvalBlockCopy(ctx, block, dataCtx) + }) + evaluatedBlock.Attrs = utils.MapMapDiags(&diags, block.Attrs, func(attr *Attr) (*Attr, diagnostics.Diag) { + attrC := *attr + var diag diagnostics.Diag + attrC.Value, diag = EvalAttr(ctx, attr, dataCtx) + return &attrC, diag + }) + return +} + // EvalBlock evaluates deferred values in the given block and validates the attributes. +// WARNING: This function modifies the input block (its attributes and blocks) in place. +// Not suitable for evaluating deferred blocks func EvalBlock(ctx context.Context, block *Block, dataCtx plugindata.Map) (diags diagnostics.Diag) { if block == nil { return @@ -202,31 +225,9 @@ func EvalBlock(ctx context.Context, block *Block, dataCtx plugindata.Map) (diags diags.Extend(EvalBlock(ctx, block, dataCtx)) } for _, attr := range block.Attrs { - // deferred eval transform - var diag diagnostics.Diag - attr.Value, diag = EvaluateDeferred(ctx, dataCtx, attr) - if diags.Extend(diag) { - continue - } - if attr.spec == nil { - continue // can't convert - } - // convert - - var err error - attr.Value, err = convert.Convert(attr.Value, attr.spec.Type) - if err != nil { - diags.Extend(diagnostics.FromErr( - err, - diagnostics.DefaultSummary("Incorrect attribute value type"), - diagnostics.DefaultSubject(attr.ValueRange), - )) - continue - } - if attr.spec.Constraints.Is(constraint.TrimSpace) { - attr.Value = trimSpace(attr.Value) - } - diags.Extend(attr.spec.ValidateValue(attr.Value).Refine(diagnostics.DefaultSubject(attr.ValueRange))) + val, diag := EvalAttr(ctx, attr, dataCtx) + diags.Extend(diag) + attr.Value = val } return } @@ -454,8 +455,12 @@ func EvalAttr(ctx context.Context, attr *Attr, dataCtx plugindata.Map) (val cty. } // DecodeAndEvalAttr decodes hclsyntax.Attribute into a Attr according to the given AttrSpec and evaluates it. -func DecodeAndEvalAttr(ctx context.Context, hclAttr *hclsyntax.Attribute, spec *AttrSpec, dataCtx plugindata.Map) (attr *Attr, diags diagnostics.Diag) { +func DecodeAndEvalAttr(ctx context.Context, hclAttr *hclsyntax.Attribute, spec *AttrSpec, dataCtx plugindata.Map) (val cty.Value, diags diagnostics.Diag) { evalCtx := fabctx.GetEvalContext(ctx) - attr, diags = DecodeAttr(evalCtx, hclAttr, spec) + attr, diags := DecodeAttr(evalCtx, hclAttr, spec) + if diags.HasErrors() { + return + } + val, diags = EvalAttr(ctx, attr, dataCtx) return } diff --git a/plugin/dataspec/deferred/deferred_eval.go b/plugin/dataspec/deferred/deferred_eval.go index 31591c24..be616986 100644 --- a/plugin/dataspec/deferred/deferred_eval.go +++ b/plugin/dataspec/deferred/deferred_eval.go @@ -2,109 +2,67 @@ package deferred import ( "context" + "log/slog" "reflect" "github.com/zclconf/go-cty/cty" - "github.com/zclconf/go-cty/cty/convert" "github.com/blackstork-io/fabric/pkg/diagnostics" "github.com/blackstork-io/fabric/pkg/encapsulator" "github.com/blackstork-io/fabric/plugin/plugindata" ) -type Evaluatable interface { +type Evaluable interface { DeferredEval(ctx context.Context, dataCtx plugindata.Map) (result cty.Value, diags diagnostics.Diag) } -var deferredEvalReflectType = reflect.TypeFor[Evaluatable]() +var deferredEvalReflectType = reflect.TypeFor[Evaluable]() -var Type *encapsulator.Codec[deferredEval] = encapsulator.NewCodec("Deferred Evaluation", &encapsulator.CapsuleOps[deferredEval]{ - ConversionTo: func(dst cty.Type) func(cty.Value, cty.Path) (*deferredEval, error) { - if !reflect.PointerTo(dst.EncapsulatedType()).Implements(deferredEvalReflectType) { - return nil - } - return func(v cty.Value, p cty.Path) (*deferredEval, error) { - if v.IsNull() { - return nil, p.NewErrorf("can't convert null to DeferredEval") - } - if !v.IsKnown() { - return nil, p.NewErrorf("can't convert unknown value to DeferredEval") +// Type represents cty-type-erased deferred evaluation. This type is used to +// hide custom decoder on an inner type (i.e. JqQuery) to avoid repeated +// calls to custom decode. +var Type *encapsulator.Codec[deferredEval] + +func init() { + Type = encapsulator.NewCodec("Deferred Evaluation", &encapsulator.CapsuleOps[deferredEval]{ + ConversionTo: func(dst cty.Type) func(cty.Value, cty.Path) (*deferredEval, error) { + if !reflect.PointerTo(dst.EncapsulatedType()).Implements(deferredEvalReflectType) { + return nil } - if v.EncapsulatedValue() == nil { - return nil, p.NewErrorf("can't convert nil value to DeferredEval") + return func(v cty.Value, p cty.Path) (*deferredEval, error) { + if v.IsNull() { + return nil, p.NewErrorf("can't convert null to DeferredEval") + } + if !v.IsKnown() { + return nil, p.NewErrorf("can't convert unknown value to DeferredEval") + } + val := v.EncapsulatedValue() + if val == nil { + return nil, p.NewErrorf("can't convert nil value to DeferredEval") + } + if defEvalVal, ok := val.(*deferredEval); ok { + // Avoid double wrapping (shouldn't happen in practice) + return defEvalVal, nil + } + evaluable := val.(Evaluable) //nolint // we know it's Evaluable + return &deferredEval{ + Evaluable: evaluable, + }, nil } - return &deferredEval{ - res: v, - }, nil - } - }, - ConversionFrom: func(src cty.Type) func(*deferredEval, cty.Path) (cty.Value, error) { - return func(de *deferredEval, p cty.Path) (cty.Value, error) { - switch { - case de.status == 0: - return cty.NilVal, p.NewErrorf("DeferredEval has not been evaluated") - case de.status < 0: - return cty.NullVal(src), nil - default: - res, err := convert.Convert(de.res, src) - err = p.NewError(err) - return res, err + }, + ConversionFrom: func(src cty.Type) func(*deferredEval, cty.Path) (cty.Value, error) { + if Type.CtyTypeEqual(src) { + // Conversion to self is a no-op + return func(de *deferredEval, _ cty.Path) (cty.Value, error) { + return Type.ToCty(de), nil + } } - } - }, -}) - -type deferredEval struct { - res cty.Value - status int -} - -// Evaluates (if needed) and returns the inner value (non-type-preserving) -func (d *deferredEval) Eval(ctx context.Context, dataCtx plugindata.Map) (result cty.Value, diags diagnostics.Diag) { - switch { - case d.status > 0: - result = d.res - case d.status < 0: - diags.Append(diagnostics.RepeatedError) - default: - result, diags = d.res.EncapsulatedValue().(Evaluatable).DeferredEval(ctx, dataCtx) - } - return -} - -// Returns new Deferred eval, containing the now evaluated value -// (useful in cty.Transform-like functions, since it is type-preserving) -func (d *deferredEval) EvalAndWrap(ctx context.Context, dataCtx plugindata.Map) (result cty.Value, diags diagnostics.Diag) { - res := d - switch { - case d.status > 0: - case d.status < 0: - diags.Append(diagnostics.RepeatedError) - default: - res = &deferredEval{} - res.res, diags = d.res.EncapsulatedValue().(Evaluatable).DeferredEval(ctx, dataCtx) - if diags.HasErrors() { - res.status = -1 - } else { - res.status = +1 - } - } - result = Type.ToCty(res) - return + slog.Error("Conversion of DeferredEval type is prohibited, evaluate it instead") + return nil + }, + }) } -// Walks the (possibly deeply nested) cty.Value and applies the CustomEval if needed. -func EvaluateDeferred(ctx context.Context, dataCtx plugindata.Map, val cty.Value) (res cty.Value, diags diagnostics.Diag) { - res, _ = cty.Transform(val, func(p cty.Path, v cty.Value) (cty.Value, error) { - if v.IsNull() || !v.IsKnown() || !Type.ValCtyTypeEqual(v) { - return v, nil - } - v, marks := v.Unmark() - eval := Type.MustFromCty(v) - v, diag := eval.EvalAndWrap(ctx, dataCtx) - diags.Extend(diag.Refine(diagnostics.AddPath(p))) - v = v.WithMarks(marks) - return v, nil - }) - return +type deferredEval struct { + Evaluable } diff --git a/plugin/plugindata/data.go b/plugin/plugindata/data.go index 89110d5c..254034e7 100644 --- a/plugin/plugindata/data.go +++ b/plugin/plugindata/data.go @@ -3,6 +3,7 @@ package plugindata import ( "encoding/json" "fmt" + "log/slog" ) type Data interface { @@ -149,3 +150,23 @@ func ParseMapAny(v map[string]any) (Map, error) { type Convertible interface { AsPluginData() Data } + +func IsTruthy(d Data) bool { + switch d := d.(type) { + case Bool: + return bool(d) + case Number: + return float64(d) != 0 + case String: + return string(d) != "" + case List: + return len(d) > 0 + case Map: + return len(d) > 0 + case nil: + return false + default: + slog.Debug("unsupported data type", "dt", d) + return false + } +}