Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 31 additions & 7 deletions cmd/pipelineymlgen/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,17 +130,21 @@ More technically: if the node is a key node of a mapping pair, the value node is
### `inlinerange <args...>`

A YAML-inlining version of `range`.
Iterates over a collection (array/slice or map), evaluating the child element for each item.
If the collection is empty, nothing is output.
Iterates over a collection (array/slice or map), evaluating the loop body (child element or elements) for each collection item.

Arrays and slices are iterated in order.
Maps are iterated in sorted key order for reproducibility.

Possible args are:

* `inlinerange <pipeline>`

For each element of the list, `data` is set to that element and the child element value is evaluated.
If the element is a map, its keys are available as `.key`. If the element is a scalar, `${ . }` gives
the value. Note that there is no way to access the outer value of `data` while evaluating child
elements; use `inlinerange "v" <pipeline>` when you need access to outer data.
For each element of the collection, `data` is set to that element and the loop body is evaluated.
This means `${ . }` in the body accesses the value.

Note that in this form, there is no way to access the outer value of `data` while evaluating the loop body.
If the pipeline is a map, there is also no way to access the keys.
Use the `valuename` and `keyname`+`valuename` forms when you need more functionality.

* `inlinerange "<valuename>" <pipeline>`

Expand All @@ -150,7 +154,27 @@ Possible args are:

Merges `data` with `map[string]any{keyname: <key>, valuename: <value>}` for each iteration.

When iterating over a map, the keys are visited in sorted order for reproducibility.
The loop body output type determines how results are inserted:

* A mapping body inserts key-value pairs into the parent mapping:

```yml
${ inlinerange "k" "v" (dict "a" 1 "b" 2) }:
${ .k }: ${ yml .v }
otherKey: value
```

* A sequence body inserts items into the parent sequence:

```yml
- ${ inlinerange (list 1 2 3) }:
- ${ yml . }
- other item
```

* A bare scalar body is an error.

See [inlinerange-examples.gen.yml](/internal/pipelineymlgen/testdata/TestIndividualFiles/inlinerange-examples.gen.yml) -> [inlinerange-examples.golden.yml](/internal/pipelineymlgen/testdata/TestIndividualFiles/inlinerange-examples.golden.yml) for more examples with output.

### Sprig functions

Expand Down
98 changes: 75 additions & 23 deletions internal/pipelineymlgen/eval.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,11 @@ func (e *EvalState) eval(orig *yaml.Node) (any, error) {
if err != nil {
return fail(fmt.Errorf("converting doc content result to YAML node: %w", err))
}
if resultNode == nil {
// All content dissolved (e.g. all conditions false).
// Produce an empty mapping as the document content.
resultNode = &yaml.Node{Kind: yaml.MappingNode}
}
n.Content[0] = resultNode

return n, nil
Expand Down Expand Up @@ -448,6 +453,10 @@ func (e *EvalState) mappingToYAML(m *evalMapping) (*yaml.Node, error) {
if err != nil {
return fail(fmt.Errorf("converting mapping item result to YAML node: %w", err))
}
// nil means dissolved content (e.g. empty inlinerange). Skip it.
if node == nil {
continue
}

switch node.Kind {
case yaml.ScalarNode:
Expand All @@ -460,9 +469,16 @@ func (e *EvalState) mappingToYAML(m *evalMapping) (*yaml.Node, error) {
return fail(fmt.Errorf("mapping contains multiple kinds: had %v, now also %v", kindStr(n), kindStr(node)))
}
n.Value = node.Value
case yaml.SequenceNode, yaml.MappingNode:
if n.Kind == 0 || n.Kind == node.Kind {
n.Kind = node.Kind
case yaml.MappingNode:
if n.Kind == 0 || n.Kind == yaml.MappingNode {
n.Kind = yaml.MappingNode
} else {
return fail(fmt.Errorf("mapping contains multiple kinds: had %v, now also %v", kindStr(n), kindStr(node)))
}
n.Content = append(n.Content, node.Content...)
case yaml.SequenceNode:
if n.Kind == 0 || n.Kind == yaml.SequenceNode {
n.Kind = yaml.SequenceNode
} else {
return fail(fmt.Errorf("mapping contains multiple kinds: had %v, now also %v", kindStr(n), kindStr(node)))
}
Expand All @@ -473,7 +489,14 @@ func (e *EvalState) mappingToYAML(m *evalMapping) (*yaml.Node, error) {
}
}
if n.Kind == 0 {
// If we never found anything to insert, this is still a mapping.
if len(m.content) > 0 {
// The mapping had content entries but they all dissolved
// (e.g. false conditions, empty ranges). Return nil to signal
// "nothing to insert" so callers can distinguish this from a
// genuine empty mapping like {}.
return nil, nil
}
// Genuine empty mapping (no content entries at all).
n.Kind = yaml.MappingNode
}
return n, nil
Expand Down Expand Up @@ -521,10 +544,17 @@ func (e *EvalState) sequenceToYAML(s *evalSequence) (*yaml.Node, error) {
if err != nil {
return fail(fmt.Errorf("converting sequence item result to YAML node:\n\t\t%w", err))
}
// nil means dissolved content (e.g. empty inlinerange, all-false
// conditions). Skip it without dropping legitimate empty mappings.
if r == nil {
continue
}
// Put the template result into the sequence depending on the
// result type.
switch r.Kind {
case yaml.ScalarNode, yaml.MappingNode:
case yaml.ScalarNode:
n.Content = append(n.Content, r)
case yaml.MappingNode:
n.Content = append(n.Content, r)
case yaml.SequenceNode:
// Flatten the sequence unless it was a literal nested sequence in the source.
Expand Down Expand Up @@ -561,7 +591,13 @@ func (e *EvalState) evalTemplateResult(t *evalTemplate) (*yaml.Node, error) {
}

func (e *EvalState) evalRangeResult(r *evalRange) (*yaml.Node, error) {
seq := &yaml.Node{Kind: yaml.SequenceNode}
var items []*yaml.Node

collectItem := func(node *yaml.Node) {
if node != nil {
items = append(items, node)
}
}

// Handle map[string]any directly with sorted keys for determinism.
if m, ok := r.collection.(map[string]any); ok {
Expand All @@ -574,18 +610,16 @@ func (e *EvalState) evalRangeResult(r *evalRange) (*yaml.Node, error) {
if err != nil {
return nil, fmt.Errorf("inlinerange iteration key %v: %w", key, err)
}
if node != nil {
seq.Content = append(seq.Content, node.Content...)
}
collectItem(node)
}
return seq, nil
return e.mergeRangeItems(items)
}

// Handle slices and arrays via reflection to support typed slices (e.g. []string).
rv := reflect.ValueOf(r.collection)
if rv.Kind() == reflect.Interface || rv.Kind() == reflect.Ptr {
if rv.IsNil() {
return seq, nil
return nil, nil
}
rv = rv.Elem()
}
Expand All @@ -602,15 +636,35 @@ func (e *EvalState) evalRangeResult(r *evalRange) (*yaml.Node, error) {
if err != nil {
return nil, fmt.Errorf("inlinerange iteration %d: %w", i, err)
}
if node != nil {
seq.Content = append(seq.Content, node.Content...)
}
collectItem(node)
}
default:
return nil, fmt.Errorf("inlinerange: cannot range over %T", r.collection)
}

return seq, nil
return e.mergeRangeItems(items)
}

// mergeRangeItems combines per-iteration body results into a single node.
// If every body produced a mapping, entries are merged into one MappingNode.
// If every body produced a sequence, items are flattened into one SequenceNode.
// Returns nil when there are no items (all iterations dissolved).
func (e *EvalState) mergeRangeItems(items []*yaml.Node) (*yaml.Node, error) {
if len(items) == 0 {
return nil, nil
}

kind := items[0].Kind
for _, item := range items[1:] {
if item.Kind != kind {
return nil, fmt.Errorf("inlinerange: body produced inconsistent YAML node kinds across iterations: first was %v, got %v", kindStr(items[0]), kindStr(item))
}
}
n := &yaml.Node{Kind: kind}
for _, item := range items {
n.Content = append(n.Content, item.Content...)
}
return n, nil
}

func (e *EvalState) iterationData(r *evalRange, key, value any) (any, error) {
Expand Down Expand Up @@ -644,16 +698,14 @@ func (e *EvalState) evalRangeBody(body *yaml.Node, data any) (*yaml.Node, error)
if err != nil {
return nil, err
}
// An empty mapping means all conditions dissolved. Return nil to skip.
if node.Kind == yaml.MappingNode && len(node.Content) == 0 {
// nil means all content dissolved (e.g. false conditions, empty ranges).
if node == nil {
return nil, nil
}
// Wrap in a sequence if it isn't one, so callers can uniformly flatten.
if node.Kind != yaml.SequenceNode {
node = &yaml.Node{
Kind: yaml.SequenceNode,
Content: []*yaml.Node{node},
}
// A scalar body is ambiguous: use "- value" to produce sequence items
// or "key: value" for mapping entries.
if node.Kind == yaml.ScalarNode {
return nil, fmt.Errorf("inlinerange body evaluated to scalar %q; use \"- value\" for sequence items or \"key: value\" for mapping entries", node.Value)
}
return node, nil
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@ pipelineymlgen:
---
# Include every variables entry that uses "name" and "value" keys.
- ${ inlinerange "v" .source.variables }:
${ inlineif (and (hasKey .v "name") (hasKey .v "value")) }:
- ${ inlineif (and (hasKey .v "name") (hasKey .v "value")) }:
${ yml .v }
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,5 @@ variables:

# Test inlinerange from data. Filter out variables with "group" key.
- ${ inlinerange "v" .source.variables }:
${ inlineif (not (hasKey .v "group")) }:
- ${ inlineif (not (hasKey .v "group")) }:
${ yml .v }
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,5 @@ pipelineymlgen:
---
# Iterate over a YAML-sourced map; keys are visited in sorted order.
- ${ inlinerange "name" "short" .os }:
os: ${ .name }
- os: ${ .name }
short: ${ .short }
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
title: example
${ inlinerange (list 1) }:
- ${ . }
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
failed to evaluate `testdata/TestIndividualFileErrors/inline-seq-into-map.gen.yml` content (with config):
eval "Document" at testdata/TestIndividualFileErrors/inline-seq-into-map.gen.yml:1:1 failed: converting doc content result to YAML node: during final conversion of mapping to YAML node(s): mapping contains multiple kinds: had Mapping, now also Sequence
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
title: example
${ inlinerange (list 1) }:
${ . }
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
failed to evaluate `testdata/TestIndividualFileErrors/inline-value-into-map.gen.yml` content (with config):
eval "Document" at testdata/TestIndividualFileErrors/inline-value-into-map.gen.yml:1:1 failed: converting doc content result to YAML node: during final conversion of mapping to YAML node(s): converting mapping item result to YAML node: inlinerange iteration 0: inlinerange body evaluated to scalar "1"; use "- value" for sequence items or "key: value" for mapping entries
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
- example
- ${ inlinerange (list 1) }:
${ . }
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
failed to evaluate `testdata/TestIndividualFileErrors/inline-value-into-seq.gen.yml` content (with config):
eval "Document" at testdata/TestIndividualFileErrors/inline-value-into-seq.gen.yml:1:1 failed: converting doc content result to YAML node: during final conversion of sequence to YAML node(s): converting sequence item result to YAML node:
during final conversion of mapping to YAML node(s): converting mapping item result to YAML node: inlinerange iteration 0: inlinerange body evaluated to scalar "1"; use "- value" for sequence items or "key: value" for mapping entries
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
- ${ inlinerange (list "a" "b") }:
${ inlineif (eq . "a") }:
key: value
${ inlineelse }:
- item
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
failed to evaluate `testdata/TestIndividualFileErrors/inlinerange-mixed-kinds.gen.yml` content (with config):
eval "Document" at testdata/TestIndividualFileErrors/inlinerange-mixed-kinds.gen.yml:1:1 failed: converting doc content result to YAML node: during final conversion of sequence to YAML node(s): converting sequence item result to YAML node:
during final conversion of mapping to YAML node(s): converting mapping item result to YAML node: inlinerange: body produced inconsistent YAML node kinds across iterations: first was Mapping, got Sequence
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Verify that legitimate empty mappings in sequences are preserved,
# not dropped by the dissolved-content logic.
items:
- name: first
- {}
- name: third
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Verify that legitimate empty mappings in sequences are preserved,
# not dropped by the dissolved-content logic.
items:
- name: first
- {}
- name: third
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
- ${ inlinerange (dict "x" 1 "y" 2) }:
- ${ . }
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
- "1"
- "2"
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
- ${ inlinerange (list 1 2) }:
- value: ${ . }
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# Range over a list with a value name.
- ${ inlinerange "v" (list "a" "b" "c") }:
${ .v }
- ${ .v }
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# Range over a dict with key and value names.
- ${ inlinerange "k" "v" (dict "x" 1 "y" 2) }:
${ .k }: ${ yml .v }
- ${ .k }: ${ yml .v }
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Test and demonstration of how inlinerange can be used to generate any seq/map
# structure despite always attempting to inline the result.

# Baseline: inlinerange inserts key value pairs into a map.
insertMap:
name: Geomys bursarius
${ inlinerange "k" "v" (dict "family" "Geomyidae" "status" "least concern") }:
${ .k }: ${ .v }
# inlinerange also inserts items into an otherwise-empty map.
properties:
${ inlinerange "k" "v" (dict "diet" "herbivore" "tunnel" "2059cm/wk") }:
${ .k }: ${ .v }

# inlinerange can insert items into a sequence.
insertSeq:
- 1
- ${ inlinerange (list 2 3 4) }:
- ${ yml . }

# More than one element per iteration can be inserted.
insertSeqGaps:
- 1
- ${ inlinerange (list 2 3 4) }:
- gap
- ${ yml . }

# It can produce a sequence of sequences by explicitly including an extra dash.
# The dash forms an otherwise-empty sequence for inlinerange to insert into.
insertSeqOfSeq:
- initialize
- - ${ inlinerange (list "parallel-calculate" "parallel-telemetry") }:
- ${ yml . }

# If the child element is a map and the target is a sequence, the map as a whole
# is inserted as a single element in the sequence.
insertMapElementIntoSeq:
- 1
- ${ inlinerange (list 2 3 4) }:
${ yml . }: present

# If the child element is a sequence and the target is a map, this is an error:
#
# insertSeqElementIntoMap:
# begin: 1
# next:
# ${ inlinerange (list 2 3 4) }:
# - ${ yml . }
#
# The intent was probably to include a "-" to insert elements into a sequence:
insertSeqElementIntoMap:
begin: 1
next:
- ${ inlinerange (list 2 3 4) }:
- ${ yml . }
Loading
Loading