feat(template): add the YAML reader, expression splitting and the evaluator
- commit
- ee5c1ceef6340d2f065913e3f27f308b42f58706
- author
- brice · · 2026-10-12 15:31 UTC
- committer
- brice · · 2026-10-12 16:17 UTC
- parent
- 3694c60
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
9 files · +1145 -7
M go.mod +1 -1
| @@ -2,4 +2,4 @@ module bnema.dev/ermite-actions | |||
| 2 | 2 | ||
| 3 | 3 | go 1.27.1 | |
| 4 | 4 | ||
| 5 | - | require go.yaml.in/yaml/v3 v3.0.5 // indirect | |
| 5 | + | require go.yaml.in/yaml/v3 v3.0.5 | |
| @@ -2,4 +2,4 @@ module bnema.dev/ermite-actions | |||||
| 2 | 2 | ||||
| 3 | go 1.27.1 | 3 | go 1.27.1 | ||
| 4 | 4 | ||||
| 5 | - | require go.yaml.in/yaml/v3 v3.0.5 // indirect | 5 | + | require go.yaml.in/yaml/v3 v3.0.5 |
M template/context.go +14 -1
| @@ -20,12 +20,25 @@ type Error struct { | |||
| 20 | 20 | Pos Pos | |
| 21 | 21 | // Raw is the message without the "file (Line: l, Col: c): " prefix. | |
| 22 | 22 | Raw string | |
| 23 | + | ||
| 24 | + | // fileOnly makes the prefix the file name alone, for errors of the YAML | |
| 25 | + | // parser, which the reference reports without a position. | |
| 26 | + | fileOnly bool | |
| 27 | + | ||
| 28 | + | cause error | |
| 23 | 29 | } | |
| 24 | 30 | ||
| 31 | + | // Unwrap returns the error of package expr behind an evaluation error, if any. | |
| 32 | + | func (e *Error) Unwrap() error { return e.cause } | |
| 33 | + | ||
| 25 | 34 | // Error returns the message with its prefix, as the reference does: | |
| 26 | 35 | // "file.yml (Line: 3, Col: 5): message". | |
| 27 | 36 | func (e *Error) Error() string { | |
| 28 | - | if p := errorPrefix(e.File, e.Pos); p != "" { | |
| 37 | + | pos := e.Pos | |
| 38 | + | if e.fileOnly { | |
| 39 | + | pos = Pos{} | |
| 40 | + | } | |
| 41 | + | if p := errorPrefix(e.File, pos); p != "" { | |
| 29 | 42 | return p + ": " + e.Raw | |
| 30 | 43 | } | |
| 31 | 44 | return e.Raw | |
| @@ -20,12 +20,25 @@ type Error struct { | |||||
| 20 | Pos Pos | 20 | Pos Pos | ||
| 21 | // Raw is the message without the "file (Line: l, Col: c): " prefix. | 21 | // Raw is the message without the "file (Line: l, Col: c): " prefix. | ||
| 22 | Raw string | 22 | Raw string | ||
| 23 | + | ||||
| 24 | + | // fileOnly makes the prefix the file name alone, for errors of the YAML | |||
| 25 | + | // parser, which the reference reports without a position. | |||
| 26 | + | fileOnly bool | |||
| 27 | + | ||||
| 28 | + | cause error | |||
| 23 | } | 29 | } | ||
| 24 | 30 | ||||
| 31 | + | // Unwrap returns the error of package expr behind an evaluation error, if any. | |||
| 32 | + | func (e *Error) Unwrap() error { return e.cause } | |||
| 33 | + | ||||
| 25 | // Error returns the message with its prefix, as the reference does: | 34 | // Error returns the message with its prefix, as the reference does: | ||
| 26 | // "file.yml (Line: 3, Col: 5): message". | 35 | // "file.yml (Line: 3, Col: 5): message". | ||
| 27 | func (e *Error) Error() string { | 36 | func (e *Error) Error() string { | ||
| 28 | - | if p := errorPrefix(e.File, e.Pos); p != "" { | 37 | + | pos := e.Pos |
| 38 | + | if e.fileOnly { | |||
| 39 | + | pos = Pos{} | |||
| 40 | + | } | |||
| 41 | + | if p := errorPrefix(e.File, pos); p != "" { | |||
| 29 | return p + ": " + e.Raw | 42 | return p + ": " + e.Raw | ||
| 30 | } | 43 | } | ||
| 31 | return e.Raw | 44 | return e.Raw | ||
M template/definfo.go +2 -1
| @@ -39,7 +39,8 @@ func newDefinitionInfo(s *Schema, parent *DefinitionInfo, d *Definition) *Defini | |||
| 39 | 39 | } | |
| 40 | 40 | } | |
| 41 | 41 | } else if parent != nil { | |
| 42 | - | di.allowed = parent.allowed | |
| 42 | + | di.allowed, di.named, di.funcs = parent.allowed, parent.named, parent.funcs | |
| 43 | + | return di | |
| 43 | 44 | } | |
| 44 | 45 | di.named, di.funcs = splitAllowedContext(di.allowed) | |
| 45 | 46 | return di | |
| @@ -39,7 +39,8 @@ func newDefinitionInfo(s *Schema, parent *DefinitionInfo, d *Definition) *Defini | |||||
| 39 | } | 39 | } | ||
| 40 | } | 40 | } | ||
| 41 | } else if parent != nil { | 41 | } else if parent != nil { | ||
| 42 | - | di.allowed = parent.allowed | 42 | + | di.allowed, di.named, di.funcs = parent.allowed, parent.named, parent.funcs |
| 43 | + | return di | |||
| 43 | } | 44 | } | ||
| 44 | di.named, di.funcs = splitAllowedContext(di.allowed) | 45 | di.named, di.funcs = splitAllowedContext(di.allowed) | ||
| 45 | return di | 46 | return di | ||
A template/eval.go +228 -0
| @@ -0,0 +1,228 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package template | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "math" | |
| 10 | + | "strconv" | |
| 11 | + | "strings" | |
| 12 | + | ||
| 13 | + | "bnema.dev/ermite-actions/expr" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // Env is what expressions are evaluated against: the values of the named | |
| 17 | + | // contexts ("github", "needs", "matrix"...) and the implementations of the | |
| 18 | + | // extra functions ("success", "hashFiles"...). Which of them an expression may | |
| 19 | + | // use was decided when it was read, from the schema. | |
| 20 | + | type Env struct { | |
| 21 | + | Contexts map[string]expr.Value | |
| 22 | + | Funcs map[string]expr.Func | |
| 23 | + | // MaxResultBytes is the output budget of one expression, see | |
| 24 | + | // [expr.EvalContext]. Zero means the default. | |
| 25 | + | MaxResultBytes int | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func (e Env) evalContext() expr.EvalContext { | |
| 29 | + | return expr.EvalContext{Contexts: e.Contexts, Funcs: e.Funcs, MaxResultBytes: e.MaxResultBytes} | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // MaxEvalDepth bounds the nesting of tokens that [Evaluate] follows. | |
| 33 | + | const MaxEvalDepth = 100 | |
| 34 | + | ||
| 35 | + | func (t *BasicExpression) errorf(format string, a ...any) error { | |
| 36 | + | return &Error{File: t.file, Pos: t.pos, Raw: fmt.Sprintf(format, a...)} | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // EvaluateExpression computes one expression. Errors are [*Error] values | |
| 40 | + | // positioned at the expression and wrap the underlying error of package expr. | |
| 41 | + | func EvaluateExpression(ctx context.Context, t *BasicExpression, env Env) (expr.Value, error) { | |
| 42 | + | if t.parseErr != nil { | |
| 43 | + | return nil, &Error{File: t.file, Pos: t.pos, Raw: t.parseErr.Error(), cause: t.parseErr} | |
| 44 | + | } | |
| 45 | + | if t.node == nil { | |
| 46 | + | return nil, t.errorf("An expression was expected") | |
| 47 | + | } | |
| 48 | + | v, err := expr.Evaluate(ctx, t.node, env.evalContext()) | |
| 49 | + | if err != nil { | |
| 50 | + | return nil, &Error{File: t.file, Pos: t.pos, Raw: err.Error(), cause: err} | |
| 51 | + | } | |
| 52 | + | return v, nil | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // Evaluate converts a token to a value of package expr, computing the | |
| 56 | + | // expressions in it: literals become the corresponding scalars, sequences | |
| 57 | + | // arrays and mappings dictionaries. A mapping key that is ${{ insert }} must | |
| 58 | + | // have a mapping as value, whose entries are merged in place; a key that is | |
| 59 | + | // another expression is evaluated and must produce a scalar. | |
| 60 | + | func Evaluate(ctx context.Context, t Token, env Env) (expr.Value, error) { | |
| 61 | + | return evaluate(ctx, t, env, 0) | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func evaluate(ctx context.Context, t Token, env Env, depth int) (expr.Value, error) { | |
| 65 | + | if depth > MaxEvalDepth { | |
| 66 | + | return nil, &Error{File: t.File(), Pos: t.Pos(), Raw: "Maximum object depth exceeded"} | |
| 67 | + | } | |
| 68 | + | switch t := t.(type) { | |
| 69 | + | case *Null: | |
| 70 | + | return expr.Null{}, nil | |
| 71 | + | case *Boolean: | |
| 72 | + | return expr.Boolean(t.Value), nil | |
| 73 | + | case *Number: | |
| 74 | + | return expr.Number(t.Value), nil | |
| 75 | + | case *String: | |
| 76 | + | return expr.String(t.Value), nil | |
| 77 | + | case *BasicExpression: | |
| 78 | + | return EvaluateExpression(ctx, t, env) | |
| 79 | + | case *InsertExpression: | |
| 80 | + | return nil, &Error{File: t.file, Pos: t.pos, Raw: "The directive 'insert' is only allowed as a mapping key"} | |
| 81 | + | case *Sequence: | |
| 82 | + | out := expr.NewArray() | |
| 83 | + | for _, it := range t.Items { | |
| 84 | + | v, err := evaluate(ctx, it, env, depth+1) | |
| 85 | + | if err != nil { | |
| 86 | + | return nil, err | |
| 87 | + | } | |
| 88 | + | out.Append(v) | |
| 89 | + | } | |
| 90 | + | return out, nil | |
| 91 | + | case *Mapping: | |
| 92 | + | out := expr.NewDictionary() | |
| 93 | + | if err := evaluateMapping(ctx, t, env, depth, out); err != nil { | |
| 94 | + | return nil, err | |
| 95 | + | } | |
| 96 | + | return out, nil | |
| 97 | + | } | |
| 98 | + | return nil, errors.New("unknown token") | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func evaluateMapping(ctx context.Context, m *Mapping, env Env, depth int, out *expr.Dictionary) error { | |
| 102 | + | for _, p := range m.Pairs { | |
| 103 | + | if _, ok := p.Key.(*InsertExpression); ok { | |
| 104 | + | v, err := evaluate(ctx, p.Value, env, depth+1) | |
| 105 | + | if err != nil { | |
| 106 | + | return err | |
| 107 | + | } | |
| 108 | + | d, ok := v.(*expr.Dictionary) | |
| 109 | + | if !ok { | |
| 110 | + | return &Error{File: p.Key.File(), Pos: p.Key.Pos(), Raw: fmt.Sprintf("Expected a mapping for ${{ insert }} but got %s", v.Kind())} | |
| 111 | + | } | |
| 112 | + | for _, kv := range d.Pairs() { | |
| 113 | + | out.Add(kv.Key, kv.Value) | |
| 114 | + | } | |
| 115 | + | continue | |
| 116 | + | } | |
| 117 | + | kv, err := evaluate(ctx, p.Key, env, depth+1) | |
| 118 | + | if err != nil { | |
| 119 | + | return err | |
| 120 | + | } | |
| 121 | + | if k := kv.Kind(); k == expr.KindArray || k == expr.KindDictionary { | |
| 122 | + | return &Error{File: p.Key.File(), Pos: p.Key.Pos(), Raw: fmt.Sprintf("A mapping key must be a scalar but the expression produced %s", k)} | |
| 123 | + | } | |
| 124 | + | v, err := evaluate(ctx, p.Value, env, depth+1) | |
| 125 | + | if err != nil { | |
| 126 | + | return err | |
| 127 | + | } | |
| 128 | + | out.Add(expr.CoerceString(kv), v) | |
| 129 | + | } | |
| 130 | + | return nil | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | // EvaluateString computes a token that must produce a string. Numbers, | |
| 134 | + | // booleans and null are converted as GitHub does (null is the empty string); | |
| 135 | + | // a sequence or mapping is an error. | |
| 136 | + | func EvaluateString(ctx context.Context, t Token, env Env) (string, error) { | |
| 137 | + | if s, ok := t.(*String); ok { | |
| 138 | + | return s.Value, nil | |
| 139 | + | } | |
| 140 | + | v, err := Evaluate(ctx, t, env) | |
| 141 | + | if err != nil { | |
| 142 | + | return "", err | |
| 143 | + | } | |
| 144 | + | if k := v.Kind(); k == expr.KindArray || k == expr.KindDictionary { | |
| 145 | + | return "", &Error{File: t.File(), Pos: t.Pos(), Raw: fmt.Sprintf("Unexpected type '%s' encountered while reading 'string'. The type 'StringToken' was expected.", tokenKindOf(v))} | |
| 146 | + | } | |
| 147 | + | return expr.CoerceString(v), nil | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | // EvaluateBool computes a token that must produce a boolean. A string | |
| 151 | + | // "true" or "false" (any case) is accepted, as is the result of an | |
| 152 | + | // expression of that kind; anything else is an error. | |
| 153 | + | func EvaluateBool(ctx context.Context, t Token, env Env) (bool, error) { | |
| 154 | + | if b, ok := t.(*Boolean); ok { | |
| 155 | + | return b.Value, nil | |
| 156 | + | } | |
| 157 | + | v, err := Evaluate(ctx, t, env) | |
| 158 | + | if err != nil { | |
| 159 | + | return false, err | |
| 160 | + | } | |
| 161 | + | switch x := v.(type) { | |
| 162 | + | case expr.Boolean: | |
| 163 | + | return bool(x), nil | |
| 164 | + | case expr.String: | |
| 165 | + | switch lowerASCII(string(x)) { | |
| 166 | + | case "true": | |
| 167 | + | return true, nil | |
| 168 | + | case "false": | |
| 169 | + | return false, nil | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | return false, &Error{File: t.File(), Pos: t.Pos(), Raw: fmt.Sprintf("Unexpected type '%s' encountered while reading 'boolean'. The type 'BooleanToken' was expected.", tokenKindOf(v))} | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | // EvaluateNumber computes a token that must produce a number. Numeric strings | |
| 176 | + | // are accepted. | |
| 177 | + | func EvaluateNumber(ctx context.Context, t Token, env Env) (float64, error) { | |
| 178 | + | if n, ok := t.(*Number); ok { | |
| 179 | + | return n.Value, nil | |
| 180 | + | } | |
| 181 | + | v, err := Evaluate(ctx, t, env) | |
| 182 | + | if err != nil { | |
| 183 | + | return 0, err | |
| 184 | + | } | |
| 185 | + | switch x := v.(type) { | |
| 186 | + | case expr.Number: | |
| 187 | + | return float64(x), nil | |
| 188 | + | case expr.String: | |
| 189 | + | if f, ok := parseDecimal(string(x)); ok { | |
| 190 | + | return f, nil | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | return 0, &Error{File: t.File(), Pos: t.Pos(), Raw: fmt.Sprintf("Unexpected type '%s' encountered while reading 'number'. The type 'NumberToken' was expected.", tokenKindOf(v))} | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | func tokenKindOf(v expr.Value) string { | |
| 197 | + | switch v.Kind() { | |
| 198 | + | case expr.KindNull: | |
| 199 | + | return KindNull.String() | |
| 200 | + | case expr.KindBoolean: | |
| 201 | + | return KindBoolean.String() | |
| 202 | + | case expr.KindNumber: | |
| 203 | + | return KindNumber.String() | |
| 204 | + | case expr.KindString: | |
| 205 | + | return KindString.String() | |
| 206 | + | case expr.KindArray: | |
| 207 | + | return KindSequence.String() | |
| 208 | + | } | |
| 209 | + | return KindMapping.String() | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | func lowerASCII(s string) string { | |
| 213 | + | b := []byte(s) | |
| 214 | + | for i, c := range b { | |
| 215 | + | if c >= 'A' && c <= 'Z' { | |
| 216 | + | b[i] = c + 32 | |
| 217 | + | } | |
| 218 | + | } | |
| 219 | + | return string(b) | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | func parseDecimal(s string) (float64, bool) { | |
| 223 | + | f, err := strconv.ParseFloat(strings.TrimSpace(s), 64) | |
| 224 | + | if err != nil || math.IsNaN(f) || math.IsInf(f, 0) { | |
| 225 | + | return 0, false | |
| 226 | + | } | |
| 227 | + | return f, true | |
| 228 | + | } | |
| @@ -0,0 +1,228 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package template | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "errors" | |||
| 8 | + | "fmt" | |||
| 9 | + | "math" | |||
| 10 | + | "strconv" | |||
| 11 | + | "strings" | |||
| 12 | + | ||||
| 13 | + | "bnema.dev/ermite-actions/expr" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // Env is what expressions are evaluated against: the values of the named | |||
| 17 | + | // contexts ("github", "needs", "matrix"...) and the implementations of the | |||
| 18 | + | // extra functions ("success", "hashFiles"...). Which of them an expression may | |||
| 19 | + | // use was decided when it was read, from the schema. | |||
| 20 | + | type Env struct { | |||
| 21 | + | Contexts map[string]expr.Value | |||
| 22 | + | Funcs map[string]expr.Func | |||
| 23 | + | // MaxResultBytes is the output budget of one expression, see | |||
| 24 | + | // [expr.EvalContext]. Zero means the default. | |||
| 25 | + | MaxResultBytes int | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func (e Env) evalContext() expr.EvalContext { | |||
| 29 | + | return expr.EvalContext{Contexts: e.Contexts, Funcs: e.Funcs, MaxResultBytes: e.MaxResultBytes} | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | // MaxEvalDepth bounds the nesting of tokens that [Evaluate] follows. | |||
| 33 | + | const MaxEvalDepth = 100 | |||
| 34 | + | ||||
| 35 | + | func (t *BasicExpression) errorf(format string, a ...any) error { | |||
| 36 | + | return &Error{File: t.file, Pos: t.pos, Raw: fmt.Sprintf(format, a...)} | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // EvaluateExpression computes one expression. Errors are [*Error] values | |||
| 40 | + | // positioned at the expression and wrap the underlying error of package expr. | |||
| 41 | + | func EvaluateExpression(ctx context.Context, t *BasicExpression, env Env) (expr.Value, error) { | |||
| 42 | + | if t.parseErr != nil { | |||
| 43 | + | return nil, &Error{File: t.file, Pos: t.pos, Raw: t.parseErr.Error(), cause: t.parseErr} | |||
| 44 | + | } | |||
| 45 | + | if t.node == nil { | |||
| 46 | + | return nil, t.errorf("An expression was expected") | |||
| 47 | + | } | |||
| 48 | + | v, err := expr.Evaluate(ctx, t.node, env.evalContext()) | |||
| 49 | + | if err != nil { | |||
| 50 | + | return nil, &Error{File: t.file, Pos: t.pos, Raw: err.Error(), cause: err} | |||
| 51 | + | } | |||
| 52 | + | return v, nil | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | // Evaluate converts a token to a value of package expr, computing the | |||
| 56 | + | // expressions in it: literals become the corresponding scalars, sequences | |||
| 57 | + | // arrays and mappings dictionaries. A mapping key that is ${{ insert }} must | |||
| 58 | + | // have a mapping as value, whose entries are merged in place; a key that is | |||
| 59 | + | // another expression is evaluated and must produce a scalar. | |||
| 60 | + | func Evaluate(ctx context.Context, t Token, env Env) (expr.Value, error) { | |||
| 61 | + | return evaluate(ctx, t, env, 0) | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func evaluate(ctx context.Context, t Token, env Env, depth int) (expr.Value, error) { | |||
| 65 | + | if depth > MaxEvalDepth { | |||
| 66 | + | return nil, &Error{File: t.File(), Pos: t.Pos(), Raw: "Maximum object depth exceeded"} | |||
| 67 | + | } | |||
| 68 | + | switch t := t.(type) { | |||
| 69 | + | case *Null: | |||
| 70 | + | return expr.Null{}, nil | |||
| 71 | + | case *Boolean: | |||
| 72 | + | return expr.Boolean(t.Value), nil | |||
| 73 | + | case *Number: | |||
| 74 | + | return expr.Number(t.Value), nil | |||
| 75 | + | case *String: | |||
| 76 | + | return expr.String(t.Value), nil | |||
| 77 | + | case *BasicExpression: | |||
| 78 | + | return EvaluateExpression(ctx, t, env) | |||
| 79 | + | case *InsertExpression: | |||
| 80 | + | return nil, &Error{File: t.file, Pos: t.pos, Raw: "The directive 'insert' is only allowed as a mapping key"} | |||
| 81 | + | case *Sequence: | |||
| 82 | + | out := expr.NewArray() | |||
| 83 | + | for _, it := range t.Items { | |||
| 84 | + | v, err := evaluate(ctx, it, env, depth+1) | |||
| 85 | + | if err != nil { | |||
| 86 | + | return nil, err | |||
| 87 | + | } | |||
| 88 | + | out.Append(v) | |||
| 89 | + | } | |||
| 90 | + | return out, nil | |||
| 91 | + | case *Mapping: | |||
| 92 | + | out := expr.NewDictionary() | |||
| 93 | + | if err := evaluateMapping(ctx, t, env, depth, out); err != nil { | |||
| 94 | + | return nil, err | |||
| 95 | + | } | |||
| 96 | + | return out, nil | |||
| 97 | + | } | |||
| 98 | + | return nil, errors.New("unknown token") | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | func evaluateMapping(ctx context.Context, m *Mapping, env Env, depth int, out *expr.Dictionary) error { | |||
| 102 | + | for _, p := range m.Pairs { | |||
| 103 | + | if _, ok := p.Key.(*InsertExpression); ok { | |||
| 104 | + | v, err := evaluate(ctx, p.Value, env, depth+1) | |||
| 105 | + | if err != nil { | |||
| 106 | + | return err | |||
| 107 | + | } | |||
| 108 | + | d, ok := v.(*expr.Dictionary) | |||
| 109 | + | if !ok { | |||
| 110 | + | return &Error{File: p.Key.File(), Pos: p.Key.Pos(), Raw: fmt.Sprintf("Expected a mapping for ${{ insert }} but got %s", v.Kind())} | |||
| 111 | + | } | |||
| 112 | + | for _, kv := range d.Pairs() { | |||
| 113 | + | out.Add(kv.Key, kv.Value) | |||
| 114 | + | } | |||
| 115 | + | continue | |||
| 116 | + | } | |||
| 117 | + | kv, err := evaluate(ctx, p.Key, env, depth+1) | |||
| 118 | + | if err != nil { | |||
| 119 | + | return err | |||
| 120 | + | } | |||
| 121 | + | if k := kv.Kind(); k == expr.KindArray || k == expr.KindDictionary { | |||
| 122 | + | return &Error{File: p.Key.File(), Pos: p.Key.Pos(), Raw: fmt.Sprintf("A mapping key must be a scalar but the expression produced %s", k)} | |||
| 123 | + | } | |||
| 124 | + | v, err := evaluate(ctx, p.Value, env, depth+1) | |||
| 125 | + | if err != nil { | |||
| 126 | + | return err | |||
| 127 | + | } | |||
| 128 | + | out.Add(expr.CoerceString(kv), v) | |||
| 129 | + | } | |||
| 130 | + | return nil | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | // EvaluateString computes a token that must produce a string. Numbers, | |||
| 134 | + | // booleans and null are converted as GitHub does (null is the empty string); | |||
| 135 | + | // a sequence or mapping is an error. | |||
| 136 | + | func EvaluateString(ctx context.Context, t Token, env Env) (string, error) { | |||
| 137 | + | if s, ok := t.(*String); ok { | |||
| 138 | + | return s.Value, nil | |||
| 139 | + | } | |||
| 140 | + | v, err := Evaluate(ctx, t, env) | |||
| 141 | + | if err != nil { | |||
| 142 | + | return "", err | |||
| 143 | + | } | |||
| 144 | + | if k := v.Kind(); k == expr.KindArray || k == expr.KindDictionary { | |||
| 145 | + | return "", &Error{File: t.File(), Pos: t.Pos(), Raw: fmt.Sprintf("Unexpected type '%s' encountered while reading 'string'. The type 'StringToken' was expected.", tokenKindOf(v))} | |||
| 146 | + | } | |||
| 147 | + | return expr.CoerceString(v), nil | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | // EvaluateBool computes a token that must produce a boolean. A string | |||
| 151 | + | // "true" or "false" (any case) is accepted, as is the result of an | |||
| 152 | + | // expression of that kind; anything else is an error. | |||
| 153 | + | func EvaluateBool(ctx context.Context, t Token, env Env) (bool, error) { | |||
| 154 | + | if b, ok := t.(*Boolean); ok { | |||
| 155 | + | return b.Value, nil | |||
| 156 | + | } | |||
| 157 | + | v, err := Evaluate(ctx, t, env) | |||
| 158 | + | if err != nil { | |||
| 159 | + | return false, err | |||
| 160 | + | } | |||
| 161 | + | switch x := v.(type) { | |||
| 162 | + | case expr.Boolean: | |||
| 163 | + | return bool(x), nil | |||
| 164 | + | case expr.String: | |||
| 165 | + | switch lowerASCII(string(x)) { | |||
| 166 | + | case "true": | |||
| 167 | + | return true, nil | |||
| 168 | + | case "false": | |||
| 169 | + | return false, nil | |||
| 170 | + | } | |||
| 171 | + | } | |||
| 172 | + | return false, &Error{File: t.File(), Pos: t.Pos(), Raw: fmt.Sprintf("Unexpected type '%s' encountered while reading 'boolean'. The type 'BooleanToken' was expected.", tokenKindOf(v))} | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | // EvaluateNumber computes a token that must produce a number. Numeric strings | |||
| 176 | + | // are accepted. | |||
| 177 | + | func EvaluateNumber(ctx context.Context, t Token, env Env) (float64, error) { | |||
| 178 | + | if n, ok := t.(*Number); ok { | |||
| 179 | + | return n.Value, nil | |||
| 180 | + | } | |||
| 181 | + | v, err := Evaluate(ctx, t, env) | |||
| 182 | + | if err != nil { | |||
| 183 | + | return 0, err | |||
| 184 | + | } | |||
| 185 | + | switch x := v.(type) { | |||
| 186 | + | case expr.Number: | |||
| 187 | + | return float64(x), nil | |||
| 188 | + | case expr.String: | |||
| 189 | + | if f, ok := parseDecimal(string(x)); ok { | |||
| 190 | + | return f, nil | |||
| 191 | + | } | |||
| 192 | + | } | |||
| 193 | + | return 0, &Error{File: t.File(), Pos: t.Pos(), Raw: fmt.Sprintf("Unexpected type '%s' encountered while reading 'number'. The type 'NumberToken' was expected.", tokenKindOf(v))} | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | func tokenKindOf(v expr.Value) string { | |||
| 197 | + | switch v.Kind() { | |||
| 198 | + | case expr.KindNull: | |||
| 199 | + | return KindNull.String() | |||
| 200 | + | case expr.KindBoolean: | |||
| 201 | + | return KindBoolean.String() | |||
| 202 | + | case expr.KindNumber: | |||
| 203 | + | return KindNumber.String() | |||
| 204 | + | case expr.KindString: | |||
| 205 | + | return KindString.String() | |||
| 206 | + | case expr.KindArray: | |||
| 207 | + | return KindSequence.String() | |||
| 208 | + | } | |||
| 209 | + | return KindMapping.String() | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | func lowerASCII(s string) string { | |||
| 213 | + | b := []byte(s) | |||
| 214 | + | for i, c := range b { | |||
| 215 | + | if c >= 'A' && c <= 'Z' { | |||
| 216 | + | b[i] = c + 32 | |||
| 217 | + | } | |||
| 218 | + | } | |||
| 219 | + | return string(b) | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | func parseDecimal(s string) (float64, bool) { | |||
| 223 | + | f, err := strconv.ParseFloat(strings.TrimSpace(s), 64) | |||
| 224 | + | if err != nil || math.IsNaN(f) || math.IsInf(f, 0) { | |||
| 225 | + | return 0, false | |||
| 226 | + | } | |||
| 227 | + | return f, true | |||
| 228 | + | } | |||
A template/read_test.go +131 -0
| @@ -0,0 +1,131 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package template | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func readWorkflow(t testing.TB, src string) (Token, []string) { | |
| 11 | + | t.Helper() | |
| 12 | + | s, err := WorkflowSchema() | |
| 13 | + | if err != nil { | |
| 14 | + | t.Fatal(err) | |
| 15 | + | } | |
| 16 | + | ctx := NewContext(s) | |
| 17 | + | tok := ReadYAML(ctx, "workflow-root-strict", "a.yml", []byte(src)) | |
| 18 | + | return tok, ctx.Messages() | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | const head = "on: push\njobs:\n a:\n runs-on: x\n steps:\n" | |
| 22 | + | ||
| 23 | + | func TestReadErrors(t *testing.T) { | |
| 24 | + | // Expected messages come from @actions/workflow-parser 0.3.61 run on the | |
| 25 | + | // same inputs. | |
| 26 | + | tests := []struct { | |
| 27 | + | name, src string | |
| 28 | + | want []string | |
| 29 | + | }{ | |
| 30 | + | {"unknown context, block scalar", head + " - run: |\n echo hi\n echo ${{ foo.bar }}\n", | |
| 31 | + | []string{"a.yml (Line: 6, Col: 14): Unrecognized named-value: 'foo'"}}, | |
| 32 | + | {"unknown context, plain", head + " - run: echo ${{ foo.bar }} abc\n", | |
| 33 | + | []string{"a.yml (Line: 6, Col: 14): Unrecognized named-value: 'foo'"}}, | |
| 34 | + | {"unknown context, double quoted", head + " - run: \"echo ${{ foo.bar }} abc\"\n", | |
| 35 | + | []string{"a.yml (Line: 6, Col: 14): Unrecognized named-value: 'foo'"}}, | |
| 36 | + | {"bad expression and insert in a name", head + " - run: echo ${{ 1 + }}\n - name: ${{ insert }}\n run: x\n", | |
| 37 | + | []string{"a.yml (Line: 6, Col: 14): Unexpected symbol: '+'. Located at position 3 within expression: 1 +", "a.yml (Line: 7, Col: 15): Unexpected value '${{ insert }}'"}}, | |
| 38 | + | {"not closed", head + " - run: echo ${{ 1\n", | |
| 39 | + | []string{"a.yml (Line: 6, Col: 14): The expression is not closed. An unescaped ${{ sequence was found, but the closing }} sequence was not found."}}, | |
| 40 | + | {"empty document", "", []string{"a.yml: Expected a scalar value, a sequence, or a mapping"}}, | |
| 41 | + | {"comment only", "# c\n", []string{"a.yml: Expected a scalar value, a sequence, or a mapping"}}, | |
| 42 | + | {"sequence root", "[1,2]", []string{"a.yml (Line: 1, Col: 1): A sequence was not expected"}}, | |
| 43 | + | {"duplicate key", "on: push\non: pull_request\njobs: {}", []string{"a.yml (Line: 2, Col: 1): 'on' is already defined"}}, | |
| 44 | + | {"alias is resolved", "on: push\njobs:\n a: &x\n runs-on: x\n steps: [{run: a}]\n b: *x\n", nil}, | |
| 45 | + | {"valid", head + " - run: echo ${{ github.ref }}\n", nil}, | |
| 46 | + | } | |
| 47 | + | for _, tc := range tests { | |
| 48 | + | t.Run(tc.name, func(t *testing.T) { | |
| 49 | + | _, got := readWorkflow(t, tc.src) | |
| 50 | + | if strings.Join(got, "\n") != strings.Join(tc.want, "\n") { | |
| 51 | + | t.Errorf("errors:\n got %q\nwant %q", got, tc.want) | |
| 52 | + | } | |
| 53 | + | }) | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func TestReadSyntaxErrorsHaveNoTokens(t *testing.T) { | |
| 58 | + | for _, src := range []string{"on: [push\n", "a: b: c", "on: push\n---\nx: 1\n", "\t"} { | |
| 59 | + | tok, errs := readWorkflow(t, src) | |
| 60 | + | if tok != nil || len(errs) == 0 { | |
| 61 | + | t.Errorf("%q: tok=%v errs=%q", src, tok, errs) | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func TestMixedStringBecomesFormat(t *testing.T) { | |
| 67 | + | tok, errs := readWorkflow(t, head+" - run: it's ${{ github.ref }} {x} ${{ 1 }}\n") | |
| 68 | + | if len(errs) > 0 { | |
| 69 | + | t.Fatal(errs) | |
| 70 | + | } | |
| 71 | + | run := tok.(*Mapping).Find("jobs").(*Mapping).Find("a").(*Mapping).Find("steps").(*Sequence).Items[0].(*Mapping).Find("run") | |
| 72 | + | be, ok := run.(*BasicExpression) | |
| 73 | + | if !ok { | |
| 74 | + | t.Fatalf("run is %T", run) | |
| 75 | + | } | |
| 76 | + | if want := `format('it''s {0} {{x}} {1}', github.ref, 1)`; be.Expression != want { | |
| 77 | + | t.Errorf("expression = %s, want %s", be.Expression, want) | |
| 78 | + | } | |
| 79 | + | if len(be.Original) != 2 { | |
| 80 | + | t.Errorf("originals = %d", len(be.Original)) | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func TestEscapedLiteralExpression(t *testing.T) { | |
| 85 | + | tok, errs := readWorkflow(t, head+" - run: ${{ '{{ literal }}' }}\n") | |
| 86 | + | if len(errs) > 0 { | |
| 87 | + | t.Fatal(errs) | |
| 88 | + | } | |
| 89 | + | run := tok.(*Mapping).Find("jobs").(*Mapping).Find("a").(*Mapping).Find("steps").(*Sequence).Items[0].(*Mapping).Find("run") | |
| 90 | + | if s, ok := run.(*String); !ok || s.Value != "{{ literal }}" { | |
| 91 | + | t.Errorf("run = %#v", run) | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func TestScalarTypes(t *testing.T) { | |
| 96 | + | tok, errs := readWorkflow(t, "on: push\njobs:\n a:\n runs-on: x\n timeout-minutes: 0x10\n continue-on-error: yes\n steps: [{run: 1}]\n") | |
| 97 | + | // yes is a string in YAML 1.2, so continue-on-error is rejected. | |
| 98 | + | if len(errs) == 0 || !strings.Contains(errs[0], "Unexpected value 'yes'") { | |
| 99 | + | t.Errorf("errs = %q", errs) | |
| 100 | + | } | |
| 101 | + | job := tok.(*Mapping).Find("jobs").(*Mapping).Find("a").(*Mapping) | |
| 102 | + | if n, ok := job.Find("timeout-minutes").(*Number); !ok || n.Value != 16 { | |
| 103 | + | t.Errorf("timeout-minutes = %#v", job.Find("timeout-minutes")) | |
| 104 | + | } | |
| 105 | + | step := job.Find("steps").(*Sequence).Items[0].(*Mapping) | |
| 106 | + | if s, ok := step.Find("run").(*String); !ok || s.Value != "1" { | |
| 107 | + | t.Errorf("run = %#v", step.Find("run")) | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func TestAliasBombIsRejected(t *testing.T) { | |
| 112 | + | var b strings.Builder | |
| 113 | + | b.WriteString("a0: &a0 [x, x, x, x, x, x, x, x, x, x]\n") | |
| 114 | + | for i := 1; i < 8; i++ { | |
| 115 | + | b.WriteString("a") | |
| 116 | + | b.WriteString(string(rune('0' + i))) | |
| 117 | + | b.WriteString(": &a") | |
| 118 | + | b.WriteString(string(rune('0' + i))) | |
| 119 | + | b.WriteString(" [") | |
| 120 | + | for j := 0; j < 10; j++ { | |
| 121 | + | b.WriteString("*a") | |
| 122 | + | b.WriteString(string(rune('0' + i - 1))) | |
| 123 | + | b.WriteString(",") | |
| 124 | + | } | |
| 125 | + | b.WriteString("]\n") | |
| 126 | + | } | |
| 127 | + | _, errs := readWorkflow(t, b.String()) | |
| 128 | + | if len(errs) == 0 || !strings.Contains(strings.Join(errs, "\n"), "too large") { | |
| 129 | + | t.Errorf("errs = %q", errs) | |
| 130 | + | } | |
| 131 | + | } | |
| @@ -0,0 +1,131 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package template | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func readWorkflow(t testing.TB, src string) (Token, []string) { | |||
| 11 | + | t.Helper() | |||
| 12 | + | s, err := WorkflowSchema() | |||
| 13 | + | if err != nil { | |||
| 14 | + | t.Fatal(err) | |||
| 15 | + | } | |||
| 16 | + | ctx := NewContext(s) | |||
| 17 | + | tok := ReadYAML(ctx, "workflow-root-strict", "a.yml", []byte(src)) | |||
| 18 | + | return tok, ctx.Messages() | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | const head = "on: push\njobs:\n a:\n runs-on: x\n steps:\n" | |||
| 22 | + | ||||
| 23 | + | func TestReadErrors(t *testing.T) { | |||
| 24 | + | // Expected messages come from @actions/workflow-parser 0.3.61 run on the | |||
| 25 | + | // same inputs. | |||
| 26 | + | tests := []struct { | |||
| 27 | + | name, src string | |||
| 28 | + | want []string | |||
| 29 | + | }{ | |||
| 30 | + | {"unknown context, block scalar", head + " - run: |\n echo hi\n echo ${{ foo.bar }}\n", | |||
| 31 | + | []string{"a.yml (Line: 6, Col: 14): Unrecognized named-value: 'foo'"}}, | |||
| 32 | + | {"unknown context, plain", head + " - run: echo ${{ foo.bar }} abc\n", | |||
| 33 | + | []string{"a.yml (Line: 6, Col: 14): Unrecognized named-value: 'foo'"}}, | |||
| 34 | + | {"unknown context, double quoted", head + " - run: \"echo ${{ foo.bar }} abc\"\n", | |||
| 35 | + | []string{"a.yml (Line: 6, Col: 14): Unrecognized named-value: 'foo'"}}, | |||
| 36 | + | {"bad expression and insert in a name", head + " - run: echo ${{ 1 + }}\n - name: ${{ insert }}\n run: x\n", | |||
| 37 | + | []string{"a.yml (Line: 6, Col: 14): Unexpected symbol: '+'. Located at position 3 within expression: 1 +", "a.yml (Line: 7, Col: 15): Unexpected value '${{ insert }}'"}}, | |||
| 38 | + | {"not closed", head + " - run: echo ${{ 1\n", | |||
| 39 | + | []string{"a.yml (Line: 6, Col: 14): The expression is not closed. An unescaped ${{ sequence was found, but the closing }} sequence was not found."}}, | |||
| 40 | + | {"empty document", "", []string{"a.yml: Expected a scalar value, a sequence, or a mapping"}}, | |||
| 41 | + | {"comment only", "# c\n", []string{"a.yml: Expected a scalar value, a sequence, or a mapping"}}, | |||
| 42 | + | {"sequence root", "[1,2]", []string{"a.yml (Line: 1, Col: 1): A sequence was not expected"}}, | |||
| 43 | + | {"duplicate key", "on: push\non: pull_request\njobs: {}", []string{"a.yml (Line: 2, Col: 1): 'on' is already defined"}}, | |||
| 44 | + | {"alias is resolved", "on: push\njobs:\n a: &x\n runs-on: x\n steps: [{run: a}]\n b: *x\n", nil}, | |||
| 45 | + | {"valid", head + " - run: echo ${{ github.ref }}\n", nil}, | |||
| 46 | + | } | |||
| 47 | + | for _, tc := range tests { | |||
| 48 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 49 | + | _, got := readWorkflow(t, tc.src) | |||
| 50 | + | if strings.Join(got, "\n") != strings.Join(tc.want, "\n") { | |||
| 51 | + | t.Errorf("errors:\n got %q\nwant %q", got, tc.want) | |||
| 52 | + | } | |||
| 53 | + | }) | |||
| 54 | + | } | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | func TestReadSyntaxErrorsHaveNoTokens(t *testing.T) { | |||
| 58 | + | for _, src := range []string{"on: [push\n", "a: b: c", "on: push\n---\nx: 1\n", "\t"} { | |||
| 59 | + | tok, errs := readWorkflow(t, src) | |||
| 60 | + | if tok != nil || len(errs) == 0 { | |||
| 61 | + | t.Errorf("%q: tok=%v errs=%q", src, tok, errs) | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func TestMixedStringBecomesFormat(t *testing.T) { | |||
| 67 | + | tok, errs := readWorkflow(t, head+" - run: it's ${{ github.ref }} {x} ${{ 1 }}\n") | |||
| 68 | + | if len(errs) > 0 { | |||
| 69 | + | t.Fatal(errs) | |||
| 70 | + | } | |||
| 71 | + | run := tok.(*Mapping).Find("jobs").(*Mapping).Find("a").(*Mapping).Find("steps").(*Sequence).Items[0].(*Mapping).Find("run") | |||
| 72 | + | be, ok := run.(*BasicExpression) | |||
| 73 | + | if !ok { | |||
| 74 | + | t.Fatalf("run is %T", run) | |||
| 75 | + | } | |||
| 76 | + | if want := `format('it''s {0} {{x}} {1}', github.ref, 1)`; be.Expression != want { | |||
| 77 | + | t.Errorf("expression = %s, want %s", be.Expression, want) | |||
| 78 | + | } | |||
| 79 | + | if len(be.Original) != 2 { | |||
| 80 | + | t.Errorf("originals = %d", len(be.Original)) | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | func TestEscapedLiteralExpression(t *testing.T) { | |||
| 85 | + | tok, errs := readWorkflow(t, head+" - run: ${{ '{{ literal }}' }}\n") | |||
| 86 | + | if len(errs) > 0 { | |||
| 87 | + | t.Fatal(errs) | |||
| 88 | + | } | |||
| 89 | + | run := tok.(*Mapping).Find("jobs").(*Mapping).Find("a").(*Mapping).Find("steps").(*Sequence).Items[0].(*Mapping).Find("run") | |||
| 90 | + | if s, ok := run.(*String); !ok || s.Value != "{{ literal }}" { | |||
| 91 | + | t.Errorf("run = %#v", run) | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | func TestScalarTypes(t *testing.T) { | |||
| 96 | + | tok, errs := readWorkflow(t, "on: push\njobs:\n a:\n runs-on: x\n timeout-minutes: 0x10\n continue-on-error: yes\n steps: [{run: 1}]\n") | |||
| 97 | + | // yes is a string in YAML 1.2, so continue-on-error is rejected. | |||
| 98 | + | if len(errs) == 0 || !strings.Contains(errs[0], "Unexpected value 'yes'") { | |||
| 99 | + | t.Errorf("errs = %q", errs) | |||
| 100 | + | } | |||
| 101 | + | job := tok.(*Mapping).Find("jobs").(*Mapping).Find("a").(*Mapping) | |||
| 102 | + | if n, ok := job.Find("timeout-minutes").(*Number); !ok || n.Value != 16 { | |||
| 103 | + | t.Errorf("timeout-minutes = %#v", job.Find("timeout-minutes")) | |||
| 104 | + | } | |||
| 105 | + | step := job.Find("steps").(*Sequence).Items[0].(*Mapping) | |||
| 106 | + | if s, ok := step.Find("run").(*String); !ok || s.Value != "1" { | |||
| 107 | + | t.Errorf("run = %#v", step.Find("run")) | |||
| 108 | + | } | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | func TestAliasBombIsRejected(t *testing.T) { | |||
| 112 | + | var b strings.Builder | |||
| 113 | + | b.WriteString("a0: &a0 [x, x, x, x, x, x, x, x, x, x]\n") | |||
| 114 | + | for i := 1; i < 8; i++ { | |||
| 115 | + | b.WriteString("a") | |||
| 116 | + | b.WriteString(string(rune('0' + i))) | |||
| 117 | + | b.WriteString(": &a") | |||
| 118 | + | b.WriteString(string(rune('0' + i))) | |||
| 119 | + | b.WriteString(" [") | |||
| 120 | + | for j := 0; j < 10; j++ { | |||
| 121 | + | b.WriteString("*a") | |||
| 122 | + | b.WriteString(string(rune('0' + i - 1))) | |||
| 123 | + | b.WriteString(",") | |||
| 124 | + | } | |||
| 125 | + | b.WriteString("]\n") | |||
| 126 | + | } | |||
| 127 | + | _, errs := readWorkflow(t, b.String()) | |||
| 128 | + | if len(errs) == 0 || !strings.Contains(strings.Join(errs, "\n"), "too large") { | |||
| 129 | + | t.Errorf("errs = %q", errs) | |||
| 130 | + | } | |||
| 131 | + | } | |||
A template/reader.go +252 -0
| @@ -0,0 +1,252 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package template | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "fmt" | |
| 7 | + | "sort" | |
| 8 | + | "strings" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // ReadYAML parses a YAML document and reads it as a template of the schema | |
| 12 | + | // definition rootType (for workflows, "workflow-root-strict"). Problems | |
| 13 | + | // found in the document, such as syntax errors, unexpected keys or | |
| 14 | + | // misplaced expressions, are recorded in ctx; the returned token holds what | |
| 15 | + | // could be read and is nil when the document is not valid YAML. | |
| 16 | + | // | |
| 17 | + | // An empty document is an error, as in the reference. | |
| 18 | + | func ReadYAML(ctx *Context, rootType, file string, content []byte) (root Token) { | |
| 19 | + | res := readYAMLDocument(file, content) | |
| 20 | + | for _, e := range res.errs { | |
| 21 | + | ctx.add(e) | |
| 22 | + | } | |
| 23 | + | if len(res.errs) > 0 { | |
| 24 | + | return nil | |
| 25 | + | } | |
| 26 | + | defer func() { | |
| 27 | + | if r := recover(); r != nil { | |
| 28 | + | f, ok := r.(fatal) | |
| 29 | + | if !ok { | |
| 30 | + | panic(r) | |
| 31 | + | } | |
| 32 | + | ctx.ErrorInFile(file, f.msg) | |
| 33 | + | } | |
| 34 | + | }() | |
| 35 | + | rd := &reader{ctx: ctx, schema: ctx.schema, file: file} | |
| 36 | + | if res.root == nil { | |
| 37 | + | fatalf("Expected a scalar value, a sequence, or a mapping") | |
| 38 | + | } | |
| 39 | + | def := newDefinitionInfo(ctx.schema, nil, ctx.schema.get(rootType)) | |
| 40 | + | return rd.readValue(res.root, def) | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | type reader struct { | |
| 44 | + | ctx *Context | |
| 45 | + | schema *Schema | |
| 46 | + | file string | |
| 47 | + | // noExpressions is set when reading the result of an evaluation, whose | |
| 48 | + | // strings are text even if they contain ${{. | |
| 49 | + | noExpressions bool | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func (r *reader) origin(n *rnode) Origin { return Origin{File: r.file, Pos: n.pos} } | |
| 53 | + | ||
| 54 | + | func (r *reader) readValue(n *rnode, def *DefinitionInfo) Token { | |
| 55 | + | switch n.kind { | |
| 56 | + | case rLiteral: | |
| 57 | + | scalar := r.parseScalar(n, n.lit, def) | |
| 58 | + | return r.validate(scalar, def) | |
| 59 | + | case rSeq: | |
| 60 | + | seq := NewSequence(r.origin(n)) | |
| 61 | + | defs := def.schema.definitionsOfType(def.Definition, DefSequence) | |
| 62 | + | if len(defs) > 0 { | |
| 63 | + | itemDef := def.child(defs[0].ItemType) | |
| 64 | + | for _, it := range n.items { | |
| 65 | + | seq.Items = append(seq.Items, r.readValue(it, itemDef)) | |
| 66 | + | } | |
| 67 | + | } else { | |
| 68 | + | r.ctx.ErrorAt(seq, "A sequence was not expected") | |
| 69 | + | } | |
| 70 | + | seq.def = def | |
| 71 | + | return seq | |
| 72 | + | } | |
| 73 | + | m := NewMapping(r.origin(n)) | |
| 74 | + | defs := def.schema.definitionsOfType(def.Definition, DefMapping) | |
| 75 | + | if len(defs) > 0 { | |
| 76 | + | if len(defs) > 1 || len(defs[0].Properties) > 0 || defs[0].LooseKeyType == "" { | |
| 77 | + | r.mappingWithProperties(n, m, def, defs) | |
| 78 | + | } else { | |
| 79 | + | keyDef := def.child(defs[0].LooseKeyType) | |
| 80 | + | valDef := def.child(defs[0].LooseValueType) | |
| 81 | + | r.mappingAllLoose(n, m, def, keyDef, valDef, defs[0]) | |
| 82 | + | } | |
| 83 | + | } else { | |
| 84 | + | r.ctx.ErrorAt(m, "A mapping was not expected") | |
| 85 | + | } | |
| 86 | + | if m.def == nil { | |
| 87 | + | m.def = def | |
| 88 | + | } | |
| 89 | + | return m | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | // key reads the key of a mapping entry; a key that is not a scalar is an | |
| 93 | + | // error and reads as an empty string. | |
| 94 | + | func (r *reader) key(n *rnode, i int, def *DefinitionInfo) Token { | |
| 95 | + | k := n.keys[i] | |
| 96 | + | if k.badKey { | |
| 97 | + | r.ctx.ErrorAt(NewString(r.origin(k), ""), "Unexpected value ''") | |
| 98 | + | } | |
| 99 | + | return r.parseScalar(k, k.lit, def) | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func (r *reader) mappingWithProperties(n *rnode, m *Mapping, def *DefinitionInfo, defs []*Definition) { | |
| 103 | + | var looseKey, looseVal string | |
| 104 | + | if defs[0].LooseKeyType != "" { | |
| 105 | + | looseKey, looseVal = defs[0].LooseKeyType, defs[0].LooseValueType | |
| 106 | + | } | |
| 107 | + | var looseKeyDef, looseValDef *DefinitionInfo | |
| 108 | + | upperKeys := map[string]bool{} | |
| 109 | + | hasExpressionKey := false | |
| 110 | + | for i := range n.keys { | |
| 111 | + | keyScalar := r.key(n, i, def) | |
| 112 | + | val := n.vals[i] | |
| 113 | + | if IsExpression(keyScalar) { | |
| 114 | + | hasExpressionKey = true | |
| 115 | + | if len(def.allowed) > 0 { | |
| 116 | + | m.Add(keyScalar, r.readValue(val, def.child("any"))) | |
| 117 | + | } else { | |
| 118 | + | r.ctx.ErrorAt(keyScalar, "A template expression is not allowed in this context") | |
| 119 | + | } | |
| 120 | + | continue | |
| 121 | + | } | |
| 122 | + | nextKey, ok := keyScalar.(*String) | |
| 123 | + | if !ok { | |
| 124 | + | nextKey = &String{tokenBase: *keyScalar.base(), Value: keyScalar.String()} | |
| 125 | + | } | |
| 126 | + | if nextKey.Value != "" { | |
| 127 | + | up := strings.ToUpper(nextKey.Value) | |
| 128 | + | if upperKeys[up] { | |
| 129 | + | r.ctx.Errorf(nextKey, "'%s' is already defined", nextKey.Value) | |
| 130 | + | continue | |
| 131 | + | } | |
| 132 | + | upperKeys[up] = true | |
| 133 | + | } | |
| 134 | + | var prop *Property | |
| 135 | + | prop, defs = matchPropertyAndFilter(defs, nextKey.Value) | |
| 136 | + | if prop != nil { | |
| 137 | + | if backgroundStepProperties[nextKey.Value] { | |
| 138 | + | r.ctx.Errorf(nextKey, "Unexpected value '%s'", nextKey.Value) | |
| 139 | + | continue | |
| 140 | + | } | |
| 141 | + | nd := def.child(prop.Type) | |
| 142 | + | nextKey.def = nd | |
| 143 | + | m.Add(nextKey, r.readValue(val, nd)) | |
| 144 | + | continue | |
| 145 | + | } | |
| 146 | + | if looseKey != "" { | |
| 147 | + | if looseKeyDef == nil { | |
| 148 | + | looseKeyDef = def.child(looseKey) | |
| 149 | + | looseValDef = def.child(looseVal) | |
| 150 | + | } | |
| 151 | + | r.validate(nextKey, looseKeyDef) | |
| 152 | + | nextKey.def = def.child(defs[0].LooseValueType) | |
| 153 | + | m.Add(nextKey, r.readValue(val, looseValDef)) | |
| 154 | + | continue | |
| 155 | + | } | |
| 156 | + | r.ctx.Errorf(nextKey, "Unexpected value '%s'", nextKey.Value) | |
| 157 | + | } | |
| 158 | + | if len(defs) == 1 { | |
| 159 | + | m.def = newDefinitionInfo(def.schema, def, defs[0]) | |
| 160 | + | } | |
| 161 | + | switch { | |
| 162 | + | case len(defs) > 1: | |
| 163 | + | hits := map[string]int{} | |
| 164 | + | for _, d := range defs { | |
| 165 | + | for _, k := range d.PropertyNames { | |
| 166 | + | hits[k]++ | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | var props []string | |
| 170 | + | for k, c := range hits { | |
| 171 | + | if c == 1 && !backgroundStepOnlyProperties[k] { | |
| 172 | + | props = append(props, k) | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | sort.Strings(props) | |
| 176 | + | r.ctx.Errorf(m, "There's not enough info to determine what you meant. Add one of these properties: %s", strings.Join(props, ", ")) | |
| 177 | + | case len(defs) == 1 && !hasExpressionKey: | |
| 178 | + | for _, name := range defs[0].PropertyNames { | |
| 179 | + | if defs[0].Properties[name].Required && !upperKeys[strings.ToUpper(name)] { | |
| 180 | + | r.ctx.Errorf(m, "Required property is missing: %s", name) | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | } | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | var ( | |
| 187 | + | backgroundStepProperties = map[string]bool{"background": true, "wait": true, "wait-all": true, "cancel": true} | |
| 188 | + | backgroundStepOnlyProperties = map[string]bool{"wait": true, "wait-all": true, "cancel": true} | |
| 189 | + | ) | |
| 190 | + | ||
| 191 | + | func (r *reader) mappingAllLoose(n *rnode, m *Mapping, def, keyDef, valDef *DefinitionInfo, md *Definition) { | |
| 192 | + | upperKeys := map[string]bool{} | |
| 193 | + | for i := range n.keys { | |
| 194 | + | keyScalar := r.key(n, i, def) | |
| 195 | + | keyScalar.base().def = keyDef | |
| 196 | + | val := n.vals[i] | |
| 197 | + | if IsExpression(keyScalar) { | |
| 198 | + | if len(def.allowed) > 0 { | |
| 199 | + | m.Add(keyScalar, r.readValue(val, valDef)) | |
| 200 | + | } else { | |
| 201 | + | r.ctx.ErrorAt(keyScalar, "A template expression is not allowed in this context") | |
| 202 | + | } | |
| 203 | + | continue | |
| 204 | + | } | |
| 205 | + | nextKey, ok := keyScalar.(*String) | |
| 206 | + | if !ok { | |
| 207 | + | nextKey = &String{tokenBase: *keyScalar.base(), Value: keyScalar.String()} | |
| 208 | + | } | |
| 209 | + | if nextKey.Value != "" { | |
| 210 | + | up := strings.ToUpper(nextKey.Value) | |
| 211 | + | if upperKeys[up] { | |
| 212 | + | r.ctx.Errorf(nextKey, "'%s' is already defined", nextKey.Value) | |
| 213 | + | continue | |
| 214 | + | } | |
| 215 | + | upperKeys[up] = true | |
| 216 | + | } | |
| 217 | + | r.validate(nextKey, keyDef) | |
| 218 | + | nextKey.def = def.child(md.LooseValueType) | |
| 219 | + | m.Add(nextKey, r.readValue(val, valDef)) | |
| 220 | + | } | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | // validate checks a scalar against the scalar definitions of def. A literal | |
| 224 | + | // that is not a string may be converted to one. | |
| 225 | + | func (r *reader) validate(scalar Token, def *DefinitionInfo) Token { | |
| 226 | + | switch scalar.Kind() { | |
| 227 | + | case KindNull, KindBoolean, KindNumber, KindString: | |
| 228 | + | for _, d := range def.schema.scalarDefinitions(def.Definition) { | |
| 229 | + | if d.isMatch(scalar) { | |
| 230 | + | scalar.base().def = newDefinitionInfo(def.schema, def, d) | |
| 231 | + | return scalar | |
| 232 | + | } | |
| 233 | + | } | |
| 234 | + | if scalar.Kind() != KindString { | |
| 235 | + | sl := &String{tokenBase: *scalar.base(), Value: scalar.String()} | |
| 236 | + | for _, d := range def.schema.scalarDefinitions(def.Definition) { | |
| 237 | + | if d.isMatch(sl) { | |
| 238 | + | sl.def = newDefinitionInfo(def.schema, def, d) | |
| 239 | + | return sl | |
| 240 | + | } | |
| 241 | + | } | |
| 242 | + | } | |
| 243 | + | r.ctx.ErrorAt(scalar, fmt.Sprintf("Unexpected value '%s'", scalar.String())) | |
| 244 | + | case KindBasicExpression: | |
| 245 | + | if len(def.allowed) == 0 { | |
| 246 | + | r.ctx.ErrorAt(scalar, "A template expression is not allowed in this context") | |
| 247 | + | } | |
| 248 | + | default: | |
| 249 | + | r.ctx.ErrorAt(scalar, fmt.Sprintf("Unexpected value '%s'", scalar.String())) | |
| 250 | + | } | |
| 251 | + | return scalar | |
| 252 | + | } | |
| @@ -0,0 +1,252 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package template | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "fmt" | |||
| 7 | + | "sort" | |||
| 8 | + | "strings" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // ReadYAML parses a YAML document and reads it as a template of the schema | |||
| 12 | + | // definition rootType (for workflows, "workflow-root-strict"). Problems | |||
| 13 | + | // found in the document, such as syntax errors, unexpected keys or | |||
| 14 | + | // misplaced expressions, are recorded in ctx; the returned token holds what | |||
| 15 | + | // could be read and is nil when the document is not valid YAML. | |||
| 16 | + | // | |||
| 17 | + | // An empty document is an error, as in the reference. | |||
| 18 | + | func ReadYAML(ctx *Context, rootType, file string, content []byte) (root Token) { | |||
| 19 | + | res := readYAMLDocument(file, content) | |||
| 20 | + | for _, e := range res.errs { | |||
| 21 | + | ctx.add(e) | |||
| 22 | + | } | |||
| 23 | + | if len(res.errs) > 0 { | |||
| 24 | + | return nil | |||
| 25 | + | } | |||
| 26 | + | defer func() { | |||
| 27 | + | if r := recover(); r != nil { | |||
| 28 | + | f, ok := r.(fatal) | |||
| 29 | + | if !ok { | |||
| 30 | + | panic(r) | |||
| 31 | + | } | |||
| 32 | + | ctx.ErrorInFile(file, f.msg) | |||
| 33 | + | } | |||
| 34 | + | }() | |||
| 35 | + | rd := &reader{ctx: ctx, schema: ctx.schema, file: file} | |||
| 36 | + | if res.root == nil { | |||
| 37 | + | fatalf("Expected a scalar value, a sequence, or a mapping") | |||
| 38 | + | } | |||
| 39 | + | def := newDefinitionInfo(ctx.schema, nil, ctx.schema.get(rootType)) | |||
| 40 | + | return rd.readValue(res.root, def) | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | type reader struct { | |||
| 44 | + | ctx *Context | |||
| 45 | + | schema *Schema | |||
| 46 | + | file string | |||
| 47 | + | // noExpressions is set when reading the result of an evaluation, whose | |||
| 48 | + | // strings are text even if they contain ${{. | |||
| 49 | + | noExpressions bool | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func (r *reader) origin(n *rnode) Origin { return Origin{File: r.file, Pos: n.pos} } | |||
| 53 | + | ||||
| 54 | + | func (r *reader) readValue(n *rnode, def *DefinitionInfo) Token { | |||
| 55 | + | switch n.kind { | |||
| 56 | + | case rLiteral: | |||
| 57 | + | scalar := r.parseScalar(n, n.lit, def) | |||
| 58 | + | return r.validate(scalar, def) | |||
| 59 | + | case rSeq: | |||
| 60 | + | seq := NewSequence(r.origin(n)) | |||
| 61 | + | defs := def.schema.definitionsOfType(def.Definition, DefSequence) | |||
| 62 | + | if len(defs) > 0 { | |||
| 63 | + | itemDef := def.child(defs[0].ItemType) | |||
| 64 | + | for _, it := range n.items { | |||
| 65 | + | seq.Items = append(seq.Items, r.readValue(it, itemDef)) | |||
| 66 | + | } | |||
| 67 | + | } else { | |||
| 68 | + | r.ctx.ErrorAt(seq, "A sequence was not expected") | |||
| 69 | + | } | |||
| 70 | + | seq.def = def | |||
| 71 | + | return seq | |||
| 72 | + | } | |||
| 73 | + | m := NewMapping(r.origin(n)) | |||
| 74 | + | defs := def.schema.definitionsOfType(def.Definition, DefMapping) | |||
| 75 | + | if len(defs) > 0 { | |||
| 76 | + | if len(defs) > 1 || len(defs[0].Properties) > 0 || defs[0].LooseKeyType == "" { | |||
| 77 | + | r.mappingWithProperties(n, m, def, defs) | |||
| 78 | + | } else { | |||
| 79 | + | keyDef := def.child(defs[0].LooseKeyType) | |||
| 80 | + | valDef := def.child(defs[0].LooseValueType) | |||
| 81 | + | r.mappingAllLoose(n, m, def, keyDef, valDef, defs[0]) | |||
| 82 | + | } | |||
| 83 | + | } else { | |||
| 84 | + | r.ctx.ErrorAt(m, "A mapping was not expected") | |||
| 85 | + | } | |||
| 86 | + | if m.def == nil { | |||
| 87 | + | m.def = def | |||
| 88 | + | } | |||
| 89 | + | return m | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | // key reads the key of a mapping entry; a key that is not a scalar is an | |||
| 93 | + | // error and reads as an empty string. | |||
| 94 | + | func (r *reader) key(n *rnode, i int, def *DefinitionInfo) Token { | |||
| 95 | + | k := n.keys[i] | |||
| 96 | + | if k.badKey { | |||
| 97 | + | r.ctx.ErrorAt(NewString(r.origin(k), ""), "Unexpected value ''") | |||
| 98 | + | } | |||
| 99 | + | return r.parseScalar(k, k.lit, def) | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | func (r *reader) mappingWithProperties(n *rnode, m *Mapping, def *DefinitionInfo, defs []*Definition) { | |||
| 103 | + | var looseKey, looseVal string | |||
| 104 | + | if defs[0].LooseKeyType != "" { | |||
| 105 | + | looseKey, looseVal = defs[0].LooseKeyType, defs[0].LooseValueType | |||
| 106 | + | } | |||
| 107 | + | var looseKeyDef, looseValDef *DefinitionInfo | |||
| 108 | + | upperKeys := map[string]bool{} | |||
| 109 | + | hasExpressionKey := false | |||
| 110 | + | for i := range n.keys { | |||
| 111 | + | keyScalar := r.key(n, i, def) | |||
| 112 | + | val := n.vals[i] | |||
| 113 | + | if IsExpression(keyScalar) { | |||
| 114 | + | hasExpressionKey = true | |||
| 115 | + | if len(def.allowed) > 0 { | |||
| 116 | + | m.Add(keyScalar, r.readValue(val, def.child("any"))) | |||
| 117 | + | } else { | |||
| 118 | + | r.ctx.ErrorAt(keyScalar, "A template expression is not allowed in this context") | |||
| 119 | + | } | |||
| 120 | + | continue | |||
| 121 | + | } | |||
| 122 | + | nextKey, ok := keyScalar.(*String) | |||
| 123 | + | if !ok { | |||
| 124 | + | nextKey = &String{tokenBase: *keyScalar.base(), Value: keyScalar.String()} | |||
| 125 | + | } | |||
| 126 | + | if nextKey.Value != "" { | |||
| 127 | + | up := strings.ToUpper(nextKey.Value) | |||
| 128 | + | if upperKeys[up] { | |||
| 129 | + | r.ctx.Errorf(nextKey, "'%s' is already defined", nextKey.Value) | |||
| 130 | + | continue | |||
| 131 | + | } | |||
| 132 | + | upperKeys[up] = true | |||
| 133 | + | } | |||
| 134 | + | var prop *Property | |||
| 135 | + | prop, defs = matchPropertyAndFilter(defs, nextKey.Value) | |||
| 136 | + | if prop != nil { | |||
| 137 | + | if backgroundStepProperties[nextKey.Value] { | |||
| 138 | + | r.ctx.Errorf(nextKey, "Unexpected value '%s'", nextKey.Value) | |||
| 139 | + | continue | |||
| 140 | + | } | |||
| 141 | + | nd := def.child(prop.Type) | |||
| 142 | + | nextKey.def = nd | |||
| 143 | + | m.Add(nextKey, r.readValue(val, nd)) | |||
| 144 | + | continue | |||
| 145 | + | } | |||
| 146 | + | if looseKey != "" { | |||
| 147 | + | if looseKeyDef == nil { | |||
| 148 | + | looseKeyDef = def.child(looseKey) | |||
| 149 | + | looseValDef = def.child(looseVal) | |||
| 150 | + | } | |||
| 151 | + | r.validate(nextKey, looseKeyDef) | |||
| 152 | + | nextKey.def = def.child(defs[0].LooseValueType) | |||
| 153 | + | m.Add(nextKey, r.readValue(val, looseValDef)) | |||
| 154 | + | continue | |||
| 155 | + | } | |||
| 156 | + | r.ctx.Errorf(nextKey, "Unexpected value '%s'", nextKey.Value) | |||
| 157 | + | } | |||
| 158 | + | if len(defs) == 1 { | |||
| 159 | + | m.def = newDefinitionInfo(def.schema, def, defs[0]) | |||
| 160 | + | } | |||
| 161 | + | switch { | |||
| 162 | + | case len(defs) > 1: | |||
| 163 | + | hits := map[string]int{} | |||
| 164 | + | for _, d := range defs { | |||
| 165 | + | for _, k := range d.PropertyNames { | |||
| 166 | + | hits[k]++ | |||
| 167 | + | } | |||
| 168 | + | } | |||
| 169 | + | var props []string | |||
| 170 | + | for k, c := range hits { | |||
| 171 | + | if c == 1 && !backgroundStepOnlyProperties[k] { | |||
| 172 | + | props = append(props, k) | |||
| 173 | + | } | |||
| 174 | + | } | |||
| 175 | + | sort.Strings(props) | |||
| 176 | + | r.ctx.Errorf(m, "There's not enough info to determine what you meant. Add one of these properties: %s", strings.Join(props, ", ")) | |||
| 177 | + | case len(defs) == 1 && !hasExpressionKey: | |||
| 178 | + | for _, name := range defs[0].PropertyNames { | |||
| 179 | + | if defs[0].Properties[name].Required && !upperKeys[strings.ToUpper(name)] { | |||
| 180 | + | r.ctx.Errorf(m, "Required property is missing: %s", name) | |||
| 181 | + | } | |||
| 182 | + | } | |||
| 183 | + | } | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | var ( | |||
| 187 | + | backgroundStepProperties = map[string]bool{"background": true, "wait": true, "wait-all": true, "cancel": true} | |||
| 188 | + | backgroundStepOnlyProperties = map[string]bool{"wait": true, "wait-all": true, "cancel": true} | |||
| 189 | + | ) | |||
| 190 | + | ||||
| 191 | + | func (r *reader) mappingAllLoose(n *rnode, m *Mapping, def, keyDef, valDef *DefinitionInfo, md *Definition) { | |||
| 192 | + | upperKeys := map[string]bool{} | |||
| 193 | + | for i := range n.keys { | |||
| 194 | + | keyScalar := r.key(n, i, def) | |||
| 195 | + | keyScalar.base().def = keyDef | |||
| 196 | + | val := n.vals[i] | |||
| 197 | + | if IsExpression(keyScalar) { | |||
| 198 | + | if len(def.allowed) > 0 { | |||
| 199 | + | m.Add(keyScalar, r.readValue(val, valDef)) | |||
| 200 | + | } else { | |||
| 201 | + | r.ctx.ErrorAt(keyScalar, "A template expression is not allowed in this context") | |||
| 202 | + | } | |||
| 203 | + | continue | |||
| 204 | + | } | |||
| 205 | + | nextKey, ok := keyScalar.(*String) | |||
| 206 | + | if !ok { | |||
| 207 | + | nextKey = &String{tokenBase: *keyScalar.base(), Value: keyScalar.String()} | |||
| 208 | + | } | |||
| 209 | + | if nextKey.Value != "" { | |||
| 210 | + | up := strings.ToUpper(nextKey.Value) | |||
| 211 | + | if upperKeys[up] { | |||
| 212 | + | r.ctx.Errorf(nextKey, "'%s' is already defined", nextKey.Value) | |||
| 213 | + | continue | |||
| 214 | + | } | |||
| 215 | + | upperKeys[up] = true | |||
| 216 | + | } | |||
| 217 | + | r.validate(nextKey, keyDef) | |||
| 218 | + | nextKey.def = def.child(md.LooseValueType) | |||
| 219 | + | m.Add(nextKey, r.readValue(val, valDef)) | |||
| 220 | + | } | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | // validate checks a scalar against the scalar definitions of def. A literal | |||
| 224 | + | // that is not a string may be converted to one. | |||
| 225 | + | func (r *reader) validate(scalar Token, def *DefinitionInfo) Token { | |||
| 226 | + | switch scalar.Kind() { | |||
| 227 | + | case KindNull, KindBoolean, KindNumber, KindString: | |||
| 228 | + | for _, d := range def.schema.scalarDefinitions(def.Definition) { | |||
| 229 | + | if d.isMatch(scalar) { | |||
| 230 | + | scalar.base().def = newDefinitionInfo(def.schema, def, d) | |||
| 231 | + | return scalar | |||
| 232 | + | } | |||
| 233 | + | } | |||
| 234 | + | if scalar.Kind() != KindString { | |||
| 235 | + | sl := &String{tokenBase: *scalar.base(), Value: scalar.String()} | |||
| 236 | + | for _, d := range def.schema.scalarDefinitions(def.Definition) { | |||
| 237 | + | if d.isMatch(sl) { | |||
| 238 | + | sl.def = newDefinitionInfo(def.schema, def, d) | |||
| 239 | + | return sl | |||
| 240 | + | } | |||
| 241 | + | } | |||
| 242 | + | } | |||
| 243 | + | r.ctx.ErrorAt(scalar, fmt.Sprintf("Unexpected value '%s'", scalar.String())) | |||
| 244 | + | case KindBasicExpression: | |||
| 245 | + | if len(def.allowed) == 0 { | |||
| 246 | + | r.ctx.ErrorAt(scalar, "A template expression is not allowed in this context") | |||
| 247 | + | } | |||
| 248 | + | default: | |||
| 249 | + | r.ctx.ErrorAt(scalar, fmt.Sprintf("Unexpected value '%s'", scalar.String())) | |||
| 250 | + | } | |||
| 251 | + | return scalar | |||
| 252 | + | } | |||
A template/scalar.go +243 -0
| @@ -0,0 +1,243 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package template | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | "strings" | |
| 9 | + | "unicode" | |
| 10 | + | ||
| 11 | + | "bnema.dev/ermite-actions/expr" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | const ( | |
| 15 | + | openExpression = "${{" | |
| 16 | + | insertDirective = "insert" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // parseScalar splits a string that holds ${{ }} expressions into literal | |
| 20 | + | // text and expression tokens. Strings without an expression, other scalars | |
| 21 | + | // and strings with an error in an expression are returned as they are. | |
| 22 | + | // | |
| 23 | + | // The reference works on the raw source of a multi-line YAML scalar, which | |
| 24 | + | // keeps the indentation of a block scalar; this port always works on the | |
| 25 | + | // value, as the runner does. | |
| 26 | + | func (r *reader) parseScalar(_ *rnode, token Token, def *DefinitionInfo) Token { | |
| 27 | + | s, ok := token.(*String) | |
| 28 | + | if r.noExpressions || !ok || s.Value == "" { | |
| 29 | + | return token | |
| 30 | + | } | |
| 31 | + | raw := s.Value | |
| 32 | + | startExpression := strings.Index(raw, openExpression) | |
| 33 | + | if startExpression < 0 { | |
| 34 | + | return token | |
| 35 | + | } | |
| 36 | + | var segments []Token | |
| 37 | + | encounteredError := false | |
| 38 | + | i := 0 | |
| 39 | + | for i < len(raw) { | |
| 40 | + | switch { | |
| 41 | + | case i == startExpression: | |
| 42 | + | end := -1 | |
| 43 | + | inString := false | |
| 44 | + | for i += len(openExpression); i < len(raw); i++ { | |
| 45 | + | if raw[i] == '\'' { | |
| 46 | + | inString = !inString | |
| 47 | + | } else if !inString && raw[i] == '}' && raw[i-1] == '}' { | |
| 48 | + | end = i | |
| 49 | + | i++ | |
| 50 | + | break | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | if end < startExpression { | |
| 54 | + | r.ctx.ErrorAt(token, "The expression is not closed. An unescaped ${{ sequence was found, but the closing }} sequence was not found.") | |
| 55 | + | return token | |
| 56 | + | } | |
| 57 | + | rawExpression := raw[startExpression+len(openExpression) : end-1] | |
| 58 | + | e := r.parseIntoExpressionToken(token, rawExpression, def) | |
| 59 | + | if e == nil { | |
| 60 | + | encounteredError = true | |
| 61 | + | } else { | |
| 62 | + | if _, isInsert := e.(*InsertExpression); isInsert && (startExpression != 0 || i < len(raw)) { | |
| 63 | + | r.ctx.ErrorAt(token, "The directive 'insert' is not allowed in this context. Directives are not supported for expressions that are embedded within a string. Directives are only supported when the entire value is an expression.") | |
| 64 | + | return token | |
| 65 | + | } | |
| 66 | + | segments = append(segments, e) | |
| 67 | + | } | |
| 68 | + | if j := strings.Index(raw[i:], openExpression); j >= 0 { | |
| 69 | + | startExpression = i + j | |
| 70 | + | } else { | |
| 71 | + | startExpression = -1 | |
| 72 | + | } | |
| 73 | + | case i < startExpression: | |
| 74 | + | segments = r.addString(segments, token, raw[i:startExpression], s.def) | |
| 75 | + | i = startExpression | |
| 76 | + | default: | |
| 77 | + | segments = r.addString(segments, token, raw[i:], s.def) | |
| 78 | + | i = len(raw) | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | if encounteredError { | |
| 82 | + | return token | |
| 83 | + | } | |
| 84 | + | if len(segments) == 1 { | |
| 85 | + | if be, ok := segments[0].(*BasicExpression); ok { | |
| 86 | + | if str, ok := expressionString(be.Expression); ok { | |
| 87 | + | return &String{tokenBase: tokenBase{file: r.file, pos: token.Pos(), def: s.def}, Value: str} | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | return segments[0] | |
| 91 | + | } | |
| 92 | + | var format strings.Builder | |
| 93 | + | var args strings.Builder | |
| 94 | + | var originals []*BasicExpression | |
| 95 | + | argIndex := 0 | |
| 96 | + | for _, seg := range segments { | |
| 97 | + | if ss, ok := seg.(*String); ok { | |
| 98 | + | text := strings.ReplaceAll(ss.Value, "'", "''") | |
| 99 | + | text = strings.ReplaceAll(text, "{", "{{") | |
| 100 | + | text = strings.ReplaceAll(text, "}", "}}") | |
| 101 | + | format.WriteString(text) | |
| 102 | + | continue | |
| 103 | + | } | |
| 104 | + | be := seg.(*BasicExpression) | |
| 105 | + | fmt.Fprintf(&format, "{%d}", argIndex) | |
| 106 | + | argIndex++ | |
| 107 | + | args.WriteString(", ") | |
| 108 | + | args.WriteString(be.Expression) | |
| 109 | + | originals = append(originals, be) | |
| 110 | + | } | |
| 111 | + | t := &BasicExpression{ | |
| 112 | + | tokenBase: tokenBase{file: r.file, pos: token.Pos(), def: def}, | |
| 113 | + | Expression: "format('" + format.String() + "'" + args.String() + ")", | |
| 114 | + | Original: originals, | |
| 115 | + | Source: raw, | |
| 116 | + | } | |
| 117 | + | t.node, t.parseErr = parseWith(t.Expression, def) | |
| 118 | + | return t | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | func (r *reader) addString(segs []Token, at Token, value string, def *DefinitionInfo) []Token { | |
| 122 | + | if n := len(segs); n > 0 { | |
| 123 | + | if last, ok := segs[n-1].(*String); ok { | |
| 124 | + | segs[n-1] = &String{tokenBase: tokenBase{file: r.file, pos: at.Pos(), def: def}, Value: last.Value + value} | |
| 125 | + | return segs | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | return append(segs, &String{tokenBase: tokenBase{file: r.file, pos: at.Pos(), def: def}, Value: value}) | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | func (r *reader) parseIntoExpressionToken(token Token, rawExpression string, def *DefinitionInfo) Token { | |
| 132 | + | e, err := r.parseExpression(token, rawExpression, def) | |
| 133 | + | if err != nil { | |
| 134 | + | r.ctx.ErrorAt(token, err.Error()) | |
| 135 | + | return nil | |
| 136 | + | } | |
| 137 | + | return e | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | func (r *reader) parseExpression(token Token, value string, def *DefinitionInfo) (Token, error) { | |
| 141 | + | trimmed := strings.TrimSpace(value) | |
| 142 | + | if trimmed == "" { | |
| 143 | + | return nil, errors.New("An expression was expected") | |
| 144 | + | } | |
| 145 | + | isMatch, err := matchDirective(trimmed, insertDirective, 0) | |
| 146 | + | if err != nil { | |
| 147 | + | return nil, err | |
| 148 | + | } | |
| 149 | + | if isMatch { | |
| 150 | + | return &InsertExpression{tokenBase{file: r.file, pos: token.Pos(), def: def}}, nil | |
| 151 | + | } | |
| 152 | + | node, err := parseWith(trimmed, def) | |
| 153 | + | if err != nil { | |
| 154 | + | return nil, err | |
| 155 | + | } | |
| 156 | + | src, _ := token.(*String) | |
| 157 | + | t := &BasicExpression{ | |
| 158 | + | tokenBase: tokenBase{file: r.file, pos: token.Pos(), def: def}, | |
| 159 | + | Expression: trimmed, | |
| 160 | + | node: node, | |
| 161 | + | } | |
| 162 | + | if src != nil { | |
| 163 | + | t.Source = src.Value | |
| 164 | + | } | |
| 165 | + | return t, nil | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | // parseWith parses an expression against the contexts and functions allowed | |
| 169 | + | // by def. A nil def allows nothing. | |
| 170 | + | func parseWith(text string, def *DefinitionInfo) (expr.Node, error) { | |
| 171 | + | var named []string | |
| 172 | + | var funcs []expr.FuncInfo | |
| 173 | + | if def != nil { | |
| 174 | + | named, funcs = def.named, def.funcs | |
| 175 | + | } | |
| 176 | + | return expr.Parse(text, named, funcs) | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | // matchDirective reports whether trimmed is the directive followed by exactly | |
| 180 | + | // expectedParameters parameters. It is a literal port of the reference, | |
| 181 | + | // including the way it counts parameters. | |
| 182 | + | func matchDirective(trimmed, directive string, expectedParameters int) (bool, error) { | |
| 183 | + | if !strings.HasPrefix(trimmed, directive) { | |
| 184 | + | return false, nil | |
| 185 | + | } | |
| 186 | + | rest := trimmed[len(directive):] | |
| 187 | + | if rest != "" { | |
| 188 | + | c := []rune(rest)[0] | |
| 189 | + | if !unicode.IsSpace(c) && c != '\ufeff' { | |
| 190 | + | return false, nil | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | // The reference only counts the text after the last separator that is | |
| 194 | + | // outside quotes and parentheses; its loop never counts earlier ones. | |
| 195 | + | rs := []rune(trimmed) | |
| 196 | + | startIndex := len([]rune(directive)) | |
| 197 | + | inString := false | |
| 198 | + | parens := 0 | |
| 199 | + | for i := startIndex; i < len(rs); i++ { | |
| 200 | + | c := rs[i] | |
| 201 | + | switch { | |
| 202 | + | case unicode.IsSpace(c) && !inString && parens == 0: | |
| 203 | + | startIndex = i + 1 | |
| 204 | + | case c == '\'': | |
| 205 | + | inString = !inString | |
| 206 | + | case c == '(' && !inString: | |
| 207 | + | parens++ | |
| 208 | + | case c == ')' && !inString: | |
| 209 | + | parens-- | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | parameters := 0 | |
| 213 | + | if startIndex < len(rs) { | |
| 214 | + | parameters = 1 | |
| 215 | + | } | |
| 216 | + | if expectedParameters != parameters { | |
| 217 | + | return false, fmt.Errorf("Exactly %d parameter(s) were expected following the directive '%s'. Actual parameter count: %d", expectedParameters, directive, parameters) | |
| 218 | + | } | |
| 219 | + | return true, nil | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | // expressionString returns the text of an expression that is a single quoted | |
| 223 | + | // string, such as ${{ '{{ literal }}' }}, which is how GitHub lets a workflow | |
| 224 | + | // contain a literal ${{. | |
| 225 | + | func expressionString(trimmed string) (string, bool) { | |
| 226 | + | var out []byte | |
| 227 | + | inString := false | |
| 228 | + | for i := 0; i < len(trimmed); i++ { | |
| 229 | + | c := trimmed[i] | |
| 230 | + | switch { | |
| 231 | + | case c == '\'': | |
| 232 | + | inString = !inString | |
| 233 | + | if inString && i != 0 { | |
| 234 | + | out = append(out, c) | |
| 235 | + | } | |
| 236 | + | case !inString: | |
| 237 | + | return "", false | |
| 238 | + | default: | |
| 239 | + | out = append(out, c) | |
| 240 | + | } | |
| 241 | + | } | |
| 242 | + | return string(out), true | |
| 243 | + | } | |
| @@ -0,0 +1,243 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package template | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "errors" | |||
| 7 | + | "fmt" | |||
| 8 | + | "strings" | |||
| 9 | + | "unicode" | |||
| 10 | + | ||||
| 11 | + | "bnema.dev/ermite-actions/expr" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | const ( | |||
| 15 | + | openExpression = "${{" | |||
| 16 | + | insertDirective = "insert" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | // parseScalar splits a string that holds ${{ }} expressions into literal | |||
| 20 | + | // text and expression tokens. Strings without an expression, other scalars | |||
| 21 | + | // and strings with an error in an expression are returned as they are. | |||
| 22 | + | // | |||
| 23 | + | // The reference works on the raw source of a multi-line YAML scalar, which | |||
| 24 | + | // keeps the indentation of a block scalar; this port always works on the | |||
| 25 | + | // value, as the runner does. | |||
| 26 | + | func (r *reader) parseScalar(_ *rnode, token Token, def *DefinitionInfo) Token { | |||
| 27 | + | s, ok := token.(*String) | |||
| 28 | + | if r.noExpressions || !ok || s.Value == "" { | |||
| 29 | + | return token | |||
| 30 | + | } | |||
| 31 | + | raw := s.Value | |||
| 32 | + | startExpression := strings.Index(raw, openExpression) | |||
| 33 | + | if startExpression < 0 { | |||
| 34 | + | return token | |||
| 35 | + | } | |||
| 36 | + | var segments []Token | |||
| 37 | + | encounteredError := false | |||
| 38 | + | i := 0 | |||
| 39 | + | for i < len(raw) { | |||
| 40 | + | switch { | |||
| 41 | + | case i == startExpression: | |||
| 42 | + | end := -1 | |||
| 43 | + | inString := false | |||
| 44 | + | for i += len(openExpression); i < len(raw); i++ { | |||
| 45 | + | if raw[i] == '\'' { | |||
| 46 | + | inString = !inString | |||
| 47 | + | } else if !inString && raw[i] == '}' && raw[i-1] == '}' { | |||
| 48 | + | end = i | |||
| 49 | + | i++ | |||
| 50 | + | break | |||
| 51 | + | } | |||
| 52 | + | } | |||
| 53 | + | if end < startExpression { | |||
| 54 | + | r.ctx.ErrorAt(token, "The expression is not closed. An unescaped ${{ sequence was found, but the closing }} sequence was not found.") | |||
| 55 | + | return token | |||
| 56 | + | } | |||
| 57 | + | rawExpression := raw[startExpression+len(openExpression) : end-1] | |||
| 58 | + | e := r.parseIntoExpressionToken(token, rawExpression, def) | |||
| 59 | + | if e == nil { | |||
| 60 | + | encounteredError = true | |||
| 61 | + | } else { | |||
| 62 | + | if _, isInsert := e.(*InsertExpression); isInsert && (startExpression != 0 || i < len(raw)) { | |||
| 63 | + | r.ctx.ErrorAt(token, "The directive 'insert' is not allowed in this context. Directives are not supported for expressions that are embedded within a string. Directives are only supported when the entire value is an expression.") | |||
| 64 | + | return token | |||
| 65 | + | } | |||
| 66 | + | segments = append(segments, e) | |||
| 67 | + | } | |||
| 68 | + | if j := strings.Index(raw[i:], openExpression); j >= 0 { | |||
| 69 | + | startExpression = i + j | |||
| 70 | + | } else { | |||
| 71 | + | startExpression = -1 | |||
| 72 | + | } | |||
| 73 | + | case i < startExpression: | |||
| 74 | + | segments = r.addString(segments, token, raw[i:startExpression], s.def) | |||
| 75 | + | i = startExpression | |||
| 76 | + | default: | |||
| 77 | + | segments = r.addString(segments, token, raw[i:], s.def) | |||
| 78 | + | i = len(raw) | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | if encounteredError { | |||
| 82 | + | return token | |||
| 83 | + | } | |||
| 84 | + | if len(segments) == 1 { | |||
| 85 | + | if be, ok := segments[0].(*BasicExpression); ok { | |||
| 86 | + | if str, ok := expressionString(be.Expression); ok { | |||
| 87 | + | return &String{tokenBase: tokenBase{file: r.file, pos: token.Pos(), def: s.def}, Value: str} | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | return segments[0] | |||
| 91 | + | } | |||
| 92 | + | var format strings.Builder | |||
| 93 | + | var args strings.Builder | |||
| 94 | + | var originals []*BasicExpression | |||
| 95 | + | argIndex := 0 | |||
| 96 | + | for _, seg := range segments { | |||
| 97 | + | if ss, ok := seg.(*String); ok { | |||
| 98 | + | text := strings.ReplaceAll(ss.Value, "'", "''") | |||
| 99 | + | text = strings.ReplaceAll(text, "{", "{{") | |||
| 100 | + | text = strings.ReplaceAll(text, "}", "}}") | |||
| 101 | + | format.WriteString(text) | |||
| 102 | + | continue | |||
| 103 | + | } | |||
| 104 | + | be := seg.(*BasicExpression) | |||
| 105 | + | fmt.Fprintf(&format, "{%d}", argIndex) | |||
| 106 | + | argIndex++ | |||
| 107 | + | args.WriteString(", ") | |||
| 108 | + | args.WriteString(be.Expression) | |||
| 109 | + | originals = append(originals, be) | |||
| 110 | + | } | |||
| 111 | + | t := &BasicExpression{ | |||
| 112 | + | tokenBase: tokenBase{file: r.file, pos: token.Pos(), def: def}, | |||
| 113 | + | Expression: "format('" + format.String() + "'" + args.String() + ")", | |||
| 114 | + | Original: originals, | |||
| 115 | + | Source: raw, | |||
| 116 | + | } | |||
| 117 | + | t.node, t.parseErr = parseWith(t.Expression, def) | |||
| 118 | + | return t | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | func (r *reader) addString(segs []Token, at Token, value string, def *DefinitionInfo) []Token { | |||
| 122 | + | if n := len(segs); n > 0 { | |||
| 123 | + | if last, ok := segs[n-1].(*String); ok { | |||
| 124 | + | segs[n-1] = &String{tokenBase: tokenBase{file: r.file, pos: at.Pos(), def: def}, Value: last.Value + value} | |||
| 125 | + | return segs | |||
| 126 | + | } | |||
| 127 | + | } | |||
| 128 | + | return append(segs, &String{tokenBase: tokenBase{file: r.file, pos: at.Pos(), def: def}, Value: value}) | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | func (r *reader) parseIntoExpressionToken(token Token, rawExpression string, def *DefinitionInfo) Token { | |||
| 132 | + | e, err := r.parseExpression(token, rawExpression, def) | |||
| 133 | + | if err != nil { | |||
| 134 | + | r.ctx.ErrorAt(token, err.Error()) | |||
| 135 | + | return nil | |||
| 136 | + | } | |||
| 137 | + | return e | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | func (r *reader) parseExpression(token Token, value string, def *DefinitionInfo) (Token, error) { | |||
| 141 | + | trimmed := strings.TrimSpace(value) | |||
| 142 | + | if trimmed == "" { | |||
| 143 | + | return nil, errors.New("An expression was expected") | |||
| 144 | + | } | |||
| 145 | + | isMatch, err := matchDirective(trimmed, insertDirective, 0) | |||
| 146 | + | if err != nil { | |||
| 147 | + | return nil, err | |||
| 148 | + | } | |||
| 149 | + | if isMatch { | |||
| 150 | + | return &InsertExpression{tokenBase{file: r.file, pos: token.Pos(), def: def}}, nil | |||
| 151 | + | } | |||
| 152 | + | node, err := parseWith(trimmed, def) | |||
| 153 | + | if err != nil { | |||
| 154 | + | return nil, err | |||
| 155 | + | } | |||
| 156 | + | src, _ := token.(*String) | |||
| 157 | + | t := &BasicExpression{ | |||
| 158 | + | tokenBase: tokenBase{file: r.file, pos: token.Pos(), def: def}, | |||
| 159 | + | Expression: trimmed, | |||
| 160 | + | node: node, | |||
| 161 | + | } | |||
| 162 | + | if src != nil { | |||
| 163 | + | t.Source = src.Value | |||
| 164 | + | } | |||
| 165 | + | return t, nil | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | // parseWith parses an expression against the contexts and functions allowed | |||
| 169 | + | // by def. A nil def allows nothing. | |||
| 170 | + | func parseWith(text string, def *DefinitionInfo) (expr.Node, error) { | |||
| 171 | + | var named []string | |||
| 172 | + | var funcs []expr.FuncInfo | |||
| 173 | + | if def != nil { | |||
| 174 | + | named, funcs = def.named, def.funcs | |||
| 175 | + | } | |||
| 176 | + | return expr.Parse(text, named, funcs) | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | // matchDirective reports whether trimmed is the directive followed by exactly | |||
| 180 | + | // expectedParameters parameters. It is a literal port of the reference, | |||
| 181 | + | // including the way it counts parameters. | |||
| 182 | + | func matchDirective(trimmed, directive string, expectedParameters int) (bool, error) { | |||
| 183 | + | if !strings.HasPrefix(trimmed, directive) { | |||
| 184 | + | return false, nil | |||
| 185 | + | } | |||
| 186 | + | rest := trimmed[len(directive):] | |||
| 187 | + | if rest != "" { | |||
| 188 | + | c := []rune(rest)[0] | |||
| 189 | + | if !unicode.IsSpace(c) && c != '\ufeff' { | |||
| 190 | + | return false, nil | |||
| 191 | + | } | |||
| 192 | + | } | |||
| 193 | + | // The reference only counts the text after the last separator that is | |||
| 194 | + | // outside quotes and parentheses; its loop never counts earlier ones. | |||
| 195 | + | rs := []rune(trimmed) | |||
| 196 | + | startIndex := len([]rune(directive)) | |||
| 197 | + | inString := false | |||
| 198 | + | parens := 0 | |||
| 199 | + | for i := startIndex; i < len(rs); i++ { | |||
| 200 | + | c := rs[i] | |||
| 201 | + | switch { | |||
| 202 | + | case unicode.IsSpace(c) && !inString && parens == 0: | |||
| 203 | + | startIndex = i + 1 | |||
| 204 | + | case c == '\'': | |||
| 205 | + | inString = !inString | |||
| 206 | + | case c == '(' && !inString: | |||
| 207 | + | parens++ | |||
| 208 | + | case c == ')' && !inString: | |||
| 209 | + | parens-- | |||
| 210 | + | } | |||
| 211 | + | } | |||
| 212 | + | parameters := 0 | |||
| 213 | + | if startIndex < len(rs) { | |||
| 214 | + | parameters = 1 | |||
| 215 | + | } | |||
| 216 | + | if expectedParameters != parameters { | |||
| 217 | + | return false, fmt.Errorf("Exactly %d parameter(s) were expected following the directive '%s'. Actual parameter count: %d", expectedParameters, directive, parameters) | |||
| 218 | + | } | |||
| 219 | + | return true, nil | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | // expressionString returns the text of an expression that is a single quoted | |||
| 223 | + | // string, such as ${{ '{{ literal }}' }}, which is how GitHub lets a workflow | |||
| 224 | + | // contain a literal ${{. | |||
| 225 | + | func expressionString(trimmed string) (string, bool) { | |||
| 226 | + | var out []byte | |||
| 227 | + | inString := false | |||
| 228 | + | for i := 0; i < len(trimmed); i++ { | |||
| 229 | + | c := trimmed[i] | |||
| 230 | + | switch { | |||
| 231 | + | case c == '\'': | |||
| 232 | + | inString = !inString | |||
| 233 | + | if inString && i != 0 { | |||
| 234 | + | out = append(out, c) | |||
| 235 | + | } | |||
| 236 | + | case !inString: | |||
| 237 | + | return "", false | |||
| 238 | + | default: | |||
| 239 | + | out = append(out, c) | |||
| 240 | + | } | |||
| 241 | + | } | |||
| 242 | + | return string(out), true | |||
| 243 | + | } | |||
M template/tokens.go +10 -4
| @@ -140,7 +140,8 @@ type BasicExpression struct { | |||
| 140 | 140 | // Source is the unprocessed text of the value the token was read from. | |
| 141 | 141 | Source string | |
| 142 | 142 | ||
| 143 | - | node expr.Node | |
| 143 | + | node expr.Node | |
| 144 | + | parseErr error | |
| 144 | 145 | } | |
| 145 | 146 | ||
| 146 | 147 | // InsertExpression is the ${{ insert }} directive. | |
| @@ -192,15 +193,20 @@ func NewSequence(o Origin, items ...Token) *Sequence { | |||
| 192 | 193 | // NewMapping returns an empty mapping token. | |
| 193 | 194 | func NewMapping(o Origin) *Mapping { return &Mapping{tokenBase: mk(o)} } | |
| 194 | 195 | ||
| 195 | - | // NewExpression returns an expression token. It is parsed when first | |
| 196 | - | // evaluated, against the contexts and functions allowed by def, or, when def | |
| 197 | - | // is nil, against whatever the evaluation environment provides. | |
| 196 | + | // NewExpression returns an expression token. The text is parsed at once | |
| 197 | + | // against the contexts and functions allowed by def; with a nil def no | |
| 198 | + | // context and no function besides the built-in ones is allowed. A parse error | |
| 199 | + | // is returned by [Evaluate] and [BasicExpression.Err]. | |
| 198 | 200 | func NewExpression(o Origin, expression string, def *DefinitionInfo) *BasicExpression { | |
| 199 | 201 | t := &BasicExpression{tokenBase: mk(o), Expression: expression} | |
| 200 | 202 | t.def = def | |
| 203 | + | t.node, t.parseErr = parseWith(expression, def) | |
| 201 | 204 | return t | |
| 202 | 205 | } | |
| 203 | 206 | ||
| 207 | + | // Err returns the error of parsing the expression, or nil. | |
| 208 | + | func (t *BasicExpression) Err() error { return t.parseErr } | |
| 209 | + | ||
| 204 | 210 | // Add appends a pair. | |
| 205 | 211 | func (m *Mapping) Add(key, value Token) { m.Pairs = append(m.Pairs, Pair{key, value}) } | |
| 206 | 212 | ||
| @@ -140,7 +140,8 @@ type BasicExpression struct { | |||||
| 140 | // Source is the unprocessed text of the value the token was read from. | 140 | // Source is the unprocessed text of the value the token was read from. | ||
| 141 | Source string | 141 | Source string | ||
| 142 | 142 | ||||
| 143 | - | node expr.Node | 143 | + | node expr.Node |
| 144 | + | parseErr error | |||
| 144 | } | 145 | } | ||
| 145 | 146 | ||||
| 146 | // InsertExpression is the ${{ insert }} directive. | 147 | // InsertExpression is the ${{ insert }} directive. | ||
| @@ -192,15 +193,20 @@ func NewSequence(o Origin, items ...Token) *Sequence { | |||||
| 192 | // NewMapping returns an empty mapping token. | 193 | // NewMapping returns an empty mapping token. | ||
| 193 | func NewMapping(o Origin) *Mapping { return &Mapping{tokenBase: mk(o)} } | 194 | func NewMapping(o Origin) *Mapping { return &Mapping{tokenBase: mk(o)} } | ||
| 194 | 195 | ||||
| 195 | - | // NewExpression returns an expression token. It is parsed when first | 196 | + | // NewExpression returns an expression token. The text is parsed at once |
| 196 | - | // evaluated, against the contexts and functions allowed by def, or, when def | 197 | + | // against the contexts and functions allowed by def; with a nil def no |
| 197 | - | // is nil, against whatever the evaluation environment provides. | 198 | + | // context and no function besides the built-in ones is allowed. A parse error |
| 199 | + | // is returned by [Evaluate] and [BasicExpression.Err]. | |||
| 198 | func NewExpression(o Origin, expression string, def *DefinitionInfo) *BasicExpression { | 200 | func NewExpression(o Origin, expression string, def *DefinitionInfo) *BasicExpression { | ||
| 199 | t := &BasicExpression{tokenBase: mk(o), Expression: expression} | 201 | t := &BasicExpression{tokenBase: mk(o), Expression: expression} | ||
| 200 | t.def = def | 202 | t.def = def | ||
| 203 | + | t.node, t.parseErr = parseWith(expression, def) | |||
| 201 | return t | 204 | return t | ||
| 202 | } | 205 | } | ||
| 203 | 206 | ||||
| 207 | + | // Err returns the error of parsing the expression, or nil. | |||
| 208 | + | func (t *BasicExpression) Err() error { return t.parseErr } | |||
| 209 | + | ||||
| 204 | // Add appends a pair. | 210 | // Add appends a pair. | ||
| 205 | func (m *Mapping) Add(key, value Token) { m.Pairs = append(m.Pairs, Pair{key, value}) } | 211 | func (m *Mapping) Add(key, value Token) { m.Pairs = append(m.Pairs, Pair{key, value}) } | ||
| 206 | 212 | ||||
A template/yaml.go +264 -0
| @@ -0,0 +1,264 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package template | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "io" | |
| 10 | + | "math" | |
| 11 | + | "regexp" | |
| 12 | + | "strconv" | |
| 13 | + | "strings" | |
| 14 | + | ||
| 15 | + | "go.yaml.in/yaml/v3" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // MaxNodes is the number of YAML nodes a document may expand to, counting | |
| 19 | + | // every use of an alias. The reference expands aliases without a limit, so a | |
| 20 | + | // small document of nested aliases can allocate gigabytes. | |
| 21 | + | const MaxNodes = 200_000 | |
| 22 | + | ||
| 23 | + | type rkind int | |
| 24 | + | ||
| 25 | + | const ( | |
| 26 | + | rLiteral rkind = iota | |
| 27 | + | rSeq | |
| 28 | + | rMap | |
| 29 | + | ) | |
| 30 | + | ||
| 31 | + | // rnode is a YAML node after alias resolution: the tree the template reader | |
| 32 | + | // walks. | |
| 33 | + | type rnode struct { | |
| 34 | + | kind rkind | |
| 35 | + | pos Pos | |
| 36 | + | lit Token // rLiteral: a *String, *Number, *Boolean or *Null | |
| 37 | + | items []*rnode | |
| 38 | + | keys []*rnode // rMap: the keys, rLiteral nodes with string tokens | |
| 39 | + | vals []*rnode // rMap: the values, parallel to keys | |
| 40 | + | // quoted and block describe how a string scalar was written; they only | |
| 41 | + | // matter to the position of expressions inside it. | |
| 42 | + | quoted bool | |
| 43 | + | block bool | |
| 44 | + | // badKey is set on a key that is a collection. | |
| 45 | + | badKey bool | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // yamlResult is the outcome of reading a YAML document. | |
| 49 | + | type yamlResult struct { | |
| 50 | + | root *rnode // nil for an empty document | |
| 51 | + | errs []*Error | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | type yamlBuilder struct { | |
| 55 | + | file string | |
| 56 | + | nodes int | |
| 57 | + | errs []*Error | |
| 58 | + | stack map[*yaml.Node]bool | |
| 59 | + | over bool | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // readYAMLDocument parses content with yaml.v3 and builds the tree. Syntax | |
| 63 | + | // errors are returned in errs, with root nil. | |
| 64 | + | func readYAMLDocument(file string, content []byte) (res yamlResult) { | |
| 65 | + | defer func() { | |
| 66 | + | if r := recover(); r != nil { | |
| 67 | + | res = yamlResult{errs: []*Error{{File: file, Raw: fmt.Sprintf("%v", r)}}} | |
| 68 | + | } | |
| 69 | + | }() | |
| 70 | + | dec := yaml.NewDecoder(bytes.NewReader(content)) | |
| 71 | + | var doc yaml.Node | |
| 72 | + | err := dec.Decode(&doc) | |
| 73 | + | if errors.Is(err, io.EOF) { | |
| 74 | + | return yamlResult{} | |
| 75 | + | } | |
| 76 | + | if err != nil { | |
| 77 | + | return yamlResult{errs: []*Error{yamlError(file, err)}} | |
| 78 | + | } | |
| 79 | + | var second yaml.Node | |
| 80 | + | if err := dec.Decode(&second); err == nil { | |
| 81 | + | e := &Error{File: file, Raw: "Source contains multiple documents; please use YAML.parseAllDocuments()"} | |
| 82 | + | e.Pos = Pos{second.Line, second.Column} | |
| 83 | + | e.fileOnly = true | |
| 84 | + | e.Raw += fmt.Sprintf(" at line %d, column %d", second.Line, second.Column) | |
| 85 | + | return yamlResult{errs: []*Error{e}} | |
| 86 | + | } else if !errors.Is(err, io.EOF) { | |
| 87 | + | return yamlResult{errs: []*Error{yamlError(file, err)}} | |
| 88 | + | } | |
| 89 | + | if len(doc.Content) == 0 { | |
| 90 | + | return yamlResult{} | |
| 91 | + | } | |
| 92 | + | b := &yamlBuilder{file: file, stack: map[*yaml.Node]bool{}} | |
| 93 | + | root := b.build(doc.Content[0], false) | |
| 94 | + | if b.over { | |
| 95 | + | return yamlResult{errs: b.errs} | |
| 96 | + | } | |
| 97 | + | return yamlResult{root: root, errs: b.errs} | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | var yamlLineRE = regexp.MustCompile(`^(?:yaml: )?(?:unmarshal errors:\n\s*)?(?:line (\d+): )?`) | |
| 101 | + | ||
| 102 | + | // yamlError converts an error of yaml.v3 ("yaml: line 3: did not find ...") | |
| 103 | + | // to an Error. The wording is yaml.v3's, not the JavaScript library's, and | |
| 104 | + | // only the line is known. | |
| 105 | + | func yamlError(file string, err error) *Error { | |
| 106 | + | msg := err.Error() | |
| 107 | + | e := &Error{File: file, fileOnly: true} | |
| 108 | + | if m := yamlLineRE.FindStringSubmatch(msg); m != nil { | |
| 109 | + | if m[1] != "" { | |
| 110 | + | e.Pos.Line, _ = strconv.Atoi(m[1]) | |
| 111 | + | } | |
| 112 | + | msg = msg[len(m[0]):] | |
| 113 | + | } | |
| 114 | + | e.Raw = msg | |
| 115 | + | if e.Pos.Line > 0 { | |
| 116 | + | e.Raw += fmt.Sprintf(" at line %d", e.Pos.Line) | |
| 117 | + | } | |
| 118 | + | return e | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | func (b *yamlBuilder) count() bool { | |
| 122 | + | b.nodes++ | |
| 123 | + | if b.nodes > MaxNodes { | |
| 124 | + | if !b.over { | |
| 125 | + | b.over = true | |
| 126 | + | b.errs = append(b.errs, &Error{File: b.file, Raw: fmt.Sprintf("The document is too large: it has more than %d nodes once aliases are expanded", MaxNodes)}) | |
| 127 | + | } | |
| 128 | + | return false | |
| 129 | + | } | |
| 130 | + | return true | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func (b *yamlBuilder) build(n *yaml.Node, asKey bool) *rnode { | |
| 134 | + | if b.over || !b.count() { | |
| 135 | + | return &rnode{kind: rLiteral, lit: NewNull(Origin{})} | |
| 136 | + | } | |
| 137 | + | pos := Pos{n.Line, n.Column} | |
| 138 | + | o := Origin{File: b.file, Pos: pos} | |
| 139 | + | switch n.Kind { | |
| 140 | + | case yaml.AliasNode: | |
| 141 | + | target := n.Alias | |
| 142 | + | if target == nil || b.stack[target] { | |
| 143 | + | b.errs = append(b.errs, &Error{File: b.file, Pos: pos, Raw: fmt.Sprintf("Circular reference through the YAML alias '*%s'", n.Value)}) | |
| 144 | + | return &rnode{kind: rLiteral, pos: pos, lit: NewNull(o)} | |
| 145 | + | } | |
| 146 | + | b.stack[target] = true | |
| 147 | + | r := b.build(target, asKey) | |
| 148 | + | delete(b.stack, target) | |
| 149 | + | return r | |
| 150 | + | case yaml.SequenceNode: | |
| 151 | + | if asKey { | |
| 152 | + | return &rnode{kind: rLiteral, pos: pos, lit: NewString(o, ""), badKey: true} | |
| 153 | + | } | |
| 154 | + | r := &rnode{kind: rSeq, pos: pos} | |
| 155 | + | b.stack[n] = true | |
| 156 | + | for _, c := range n.Content { | |
| 157 | + | r.items = append(r.items, b.build(c, false)) | |
| 158 | + | } | |
| 159 | + | delete(b.stack, n) | |
| 160 | + | return r | |
| 161 | + | case yaml.MappingNode: | |
| 162 | + | if asKey { | |
| 163 | + | return &rnode{kind: rLiteral, pos: pos, lit: NewString(o, ""), badKey: true} | |
| 164 | + | } | |
| 165 | + | r := &rnode{kind: rMap, pos: pos} | |
| 166 | + | b.stack[n] = true | |
| 167 | + | for i := 0; i+1 < len(n.Content); i += 2 { | |
| 168 | + | r.keys = append(r.keys, b.build(n.Content[i], true)) | |
| 169 | + | r.vals = append(r.vals, b.build(n.Content[i+1], false)) | |
| 170 | + | } | |
| 171 | + | delete(b.stack, n) | |
| 172 | + | return r | |
| 173 | + | case yaml.ScalarNode: | |
| 174 | + | r := &rnode{kind: rLiteral, pos: pos} | |
| 175 | + | if asKey { | |
| 176 | + | // Keys are always strings, whatever they look like. | |
| 177 | + | r.lit = NewString(o, n.Value) | |
| 178 | + | } else { | |
| 179 | + | r.lit = resolveScalar(o, n) | |
| 180 | + | } | |
| 181 | + | r.quoted = n.Style&(yaml.DoubleQuotedStyle|yaml.SingleQuotedStyle) != 0 | |
| 182 | + | r.block = n.Style&(yaml.LiteralStyle|yaml.FoldedStyle) != 0 | |
| 183 | + | return r | |
| 184 | + | } | |
| 185 | + | return &rnode{kind: rLiteral, pos: pos, lit: NewNull(o)} | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | // resolveScalar gives a scalar its type the way the YAML 1.2 core schema of | |
| 189 | + | // the JavaScript yaml library does: only plain scalars are resolved, and an | |
| 190 | + | // explicit !!tag overrides the guess. | |
| 191 | + | func resolveScalar(o Origin, n *yaml.Node) Token { | |
| 192 | + | if n.Style&yaml.TaggedStyle != 0 { | |
| 193 | + | switch n.ShortTag() { | |
| 194 | + | case "!!str": | |
| 195 | + | return NewString(o, n.Value) | |
| 196 | + | case "!!null", "!!bool", "!!int", "!!float": | |
| 197 | + | if t := resolvePlain(o, n.Value); t.Kind() != KindString { | |
| 198 | + | return t | |
| 199 | + | } | |
| 200 | + | if n.ShortTag() == "!!null" && n.Value == "" { | |
| 201 | + | return NewNull(o) | |
| 202 | + | } | |
| 203 | + | } | |
| 204 | + | return NewString(o, n.Value) | |
| 205 | + | } | |
| 206 | + | if n.Style&(yaml.DoubleQuotedStyle|yaml.SingleQuotedStyle|yaml.LiteralStyle|yaml.FoldedStyle) != 0 { | |
| 207 | + | return NewString(o, n.Value) | |
| 208 | + | } | |
| 209 | + | return resolvePlain(o, n.Value) | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | var ( | |
| 213 | + | reInt = regexp.MustCompile(`^[-+]?[0-9]+$`) | |
| 214 | + | reOct = regexp.MustCompile(`^0o[0-7]+$`) | |
| 215 | + | reHex = regexp.MustCompile(`^0x[0-9a-fA-F]+$`) | |
| 216 | + | reFloat = regexp.MustCompile(`^[-+]?(?:\.[0-9]+|[0-9]+(?:\.[0-9]*)?)(?:[eE][-+]?[0-9]+)?$`) | |
| 217 | + | reInf = regexp.MustCompile(`^[-+]?\.(?:inf|Inf|INF)$`) | |
| 218 | + | reNaN = regexp.MustCompile(`^\.(?:nan|NaN|NAN)$`) | |
| 219 | + | ) | |
| 220 | + | ||
| 221 | + | func resolvePlain(o Origin, s string) Token { | |
| 222 | + | switch s { | |
| 223 | + | case "", "~", "null", "Null", "NULL": | |
| 224 | + | return NewNull(o) | |
| 225 | + | case "true", "True", "TRUE": | |
| 226 | + | return NewBoolean(o, true) | |
| 227 | + | case "false", "False", "FALSE": | |
| 228 | + | return NewBoolean(o, false) | |
| 229 | + | } | |
| 230 | + | switch { | |
| 231 | + | case reInt.MatchString(s): | |
| 232 | + | f, _ := strconv.ParseFloat(s, 64) | |
| 233 | + | return NewNumber(o, f) | |
| 234 | + | case reOct.MatchString(s): | |
| 235 | + | return NewNumber(o, parseBase(s[2:], 8)) | |
| 236 | + | case reHex.MatchString(s): | |
| 237 | + | return NewNumber(o, parseBase(s[2:], 16)) | |
| 238 | + | case reFloat.MatchString(s): | |
| 239 | + | f, _ := strconv.ParseFloat(s, 64) | |
| 240 | + | return NewNumber(o, f) | |
| 241 | + | case reInf.MatchString(s): | |
| 242 | + | if s[0] == '-' { | |
| 243 | + | return NewNumber(o, math.Inf(-1)) | |
| 244 | + | } | |
| 245 | + | return NewNumber(o, math.Inf(1)) | |
| 246 | + | case reNaN.MatchString(s): | |
| 247 | + | return NewNumber(o, math.NaN()) | |
| 248 | + | } | |
| 249 | + | return NewString(o, s) | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | // parseBase converts digits in base 8 or 16 to a float64 without overflowing, | |
| 253 | + | // like JavaScript's parseInt. | |
| 254 | + | func parseBase(digits string, base int) float64 { | |
| 255 | + | var f float64 | |
| 256 | + | for _, c := range strings.ToLower(digits) { | |
| 257 | + | d := int(c - '0') | |
| 258 | + | if c >= 'a' { | |
| 259 | + | d = int(c-'a') + 10 | |
| 260 | + | } | |
| 261 | + | f = f*float64(base) + float64(d) | |
| 262 | + | } | |
| 263 | + | return f | |
| 264 | + | } | |
| @@ -0,0 +1,264 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package template | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "bytes" | |||
| 7 | + | "errors" | |||
| 8 | + | "fmt" | |||
| 9 | + | "io" | |||
| 10 | + | "math" | |||
| 11 | + | "regexp" | |||
| 12 | + | "strconv" | |||
| 13 | + | "strings" | |||
| 14 | + | ||||
| 15 | + | "go.yaml.in/yaml/v3" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | // MaxNodes is the number of YAML nodes a document may expand to, counting | |||
| 19 | + | // every use of an alias. The reference expands aliases without a limit, so a | |||
| 20 | + | // small document of nested aliases can allocate gigabytes. | |||
| 21 | + | const MaxNodes = 200_000 | |||
| 22 | + | ||||
| 23 | + | type rkind int | |||
| 24 | + | ||||
| 25 | + | const ( | |||
| 26 | + | rLiteral rkind = iota | |||
| 27 | + | rSeq | |||
| 28 | + | rMap | |||
| 29 | + | ) | |||
| 30 | + | ||||
| 31 | + | // rnode is a YAML node after alias resolution: the tree the template reader | |||
| 32 | + | // walks. | |||
| 33 | + | type rnode struct { | |||
| 34 | + | kind rkind | |||
| 35 | + | pos Pos | |||
| 36 | + | lit Token // rLiteral: a *String, *Number, *Boolean or *Null | |||
| 37 | + | items []*rnode | |||
| 38 | + | keys []*rnode // rMap: the keys, rLiteral nodes with string tokens | |||
| 39 | + | vals []*rnode // rMap: the values, parallel to keys | |||
| 40 | + | // quoted and block describe how a string scalar was written; they only | |||
| 41 | + | // matter to the position of expressions inside it. | |||
| 42 | + | quoted bool | |||
| 43 | + | block bool | |||
| 44 | + | // badKey is set on a key that is a collection. | |||
| 45 | + | badKey bool | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // yamlResult is the outcome of reading a YAML document. | |||
| 49 | + | type yamlResult struct { | |||
| 50 | + | root *rnode // nil for an empty document | |||
| 51 | + | errs []*Error | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | type yamlBuilder struct { | |||
| 55 | + | file string | |||
| 56 | + | nodes int | |||
| 57 | + | errs []*Error | |||
| 58 | + | stack map[*yaml.Node]bool | |||
| 59 | + | over bool | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | // readYAMLDocument parses content with yaml.v3 and builds the tree. Syntax | |||
| 63 | + | // errors are returned in errs, with root nil. | |||
| 64 | + | func readYAMLDocument(file string, content []byte) (res yamlResult) { | |||
| 65 | + | defer func() { | |||
| 66 | + | if r := recover(); r != nil { | |||
| 67 | + | res = yamlResult{errs: []*Error{{File: file, Raw: fmt.Sprintf("%v", r)}}} | |||
| 68 | + | } | |||
| 69 | + | }() | |||
| 70 | + | dec := yaml.NewDecoder(bytes.NewReader(content)) | |||
| 71 | + | var doc yaml.Node | |||
| 72 | + | err := dec.Decode(&doc) | |||
| 73 | + | if errors.Is(err, io.EOF) { | |||
| 74 | + | return yamlResult{} | |||
| 75 | + | } | |||
| 76 | + | if err != nil { | |||
| 77 | + | return yamlResult{errs: []*Error{yamlError(file, err)}} | |||
| 78 | + | } | |||
| 79 | + | var second yaml.Node | |||
| 80 | + | if err := dec.Decode(&second); err == nil { | |||
| 81 | + | e := &Error{File: file, Raw: "Source contains multiple documents; please use YAML.parseAllDocuments()"} | |||
| 82 | + | e.Pos = Pos{second.Line, second.Column} | |||
| 83 | + | e.fileOnly = true | |||
| 84 | + | e.Raw += fmt.Sprintf(" at line %d, column %d", second.Line, second.Column) | |||
| 85 | + | return yamlResult{errs: []*Error{e}} | |||
| 86 | + | } else if !errors.Is(err, io.EOF) { | |||
| 87 | + | return yamlResult{errs: []*Error{yamlError(file, err)}} | |||
| 88 | + | } | |||
| 89 | + | if len(doc.Content) == 0 { | |||
| 90 | + | return yamlResult{} | |||
| 91 | + | } | |||
| 92 | + | b := &yamlBuilder{file: file, stack: map[*yaml.Node]bool{}} | |||
| 93 | + | root := b.build(doc.Content[0], false) | |||
| 94 | + | if b.over { | |||
| 95 | + | return yamlResult{errs: b.errs} | |||
| 96 | + | } | |||
| 97 | + | return yamlResult{root: root, errs: b.errs} | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | var yamlLineRE = regexp.MustCompile(`^(?:yaml: )?(?:unmarshal errors:\n\s*)?(?:line (\d+): )?`) | |||
| 101 | + | ||||
| 102 | + | // yamlError converts an error of yaml.v3 ("yaml: line 3: did not find ...") | |||
| 103 | + | // to an Error. The wording is yaml.v3's, not the JavaScript library's, and | |||
| 104 | + | // only the line is known. | |||
| 105 | + | func yamlError(file string, err error) *Error { | |||
| 106 | + | msg := err.Error() | |||
| 107 | + | e := &Error{File: file, fileOnly: true} | |||
| 108 | + | if m := yamlLineRE.FindStringSubmatch(msg); m != nil { | |||
| 109 | + | if m[1] != "" { | |||
| 110 | + | e.Pos.Line, _ = strconv.Atoi(m[1]) | |||
| 111 | + | } | |||
| 112 | + | msg = msg[len(m[0]):] | |||
| 113 | + | } | |||
| 114 | + | e.Raw = msg | |||
| 115 | + | if e.Pos.Line > 0 { | |||
| 116 | + | e.Raw += fmt.Sprintf(" at line %d", e.Pos.Line) | |||
| 117 | + | } | |||
| 118 | + | return e | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | func (b *yamlBuilder) count() bool { | |||
| 122 | + | b.nodes++ | |||
| 123 | + | if b.nodes > MaxNodes { | |||
| 124 | + | if !b.over { | |||
| 125 | + | b.over = true | |||
| 126 | + | b.errs = append(b.errs, &Error{File: b.file, Raw: fmt.Sprintf("The document is too large: it has more than %d nodes once aliases are expanded", MaxNodes)}) | |||
| 127 | + | } | |||
| 128 | + | return false | |||
| 129 | + | } | |||
| 130 | + | return true | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | func (b *yamlBuilder) build(n *yaml.Node, asKey bool) *rnode { | |||
| 134 | + | if b.over || !b.count() { | |||
| 135 | + | return &rnode{kind: rLiteral, lit: NewNull(Origin{})} | |||
| 136 | + | } | |||
| 137 | + | pos := Pos{n.Line, n.Column} | |||
| 138 | + | o := Origin{File: b.file, Pos: pos} | |||
| 139 | + | switch n.Kind { | |||
| 140 | + | case yaml.AliasNode: | |||
| 141 | + | target := n.Alias | |||
| 142 | + | if target == nil || b.stack[target] { | |||
| 143 | + | b.errs = append(b.errs, &Error{File: b.file, Pos: pos, Raw: fmt.Sprintf("Circular reference through the YAML alias '*%s'", n.Value)}) | |||
| 144 | + | return &rnode{kind: rLiteral, pos: pos, lit: NewNull(o)} | |||
| 145 | + | } | |||
| 146 | + | b.stack[target] = true | |||
| 147 | + | r := b.build(target, asKey) | |||
| 148 | + | delete(b.stack, target) | |||
| 149 | + | return r | |||
| 150 | + | case yaml.SequenceNode: | |||
| 151 | + | if asKey { | |||
| 152 | + | return &rnode{kind: rLiteral, pos: pos, lit: NewString(o, ""), badKey: true} | |||
| 153 | + | } | |||
| 154 | + | r := &rnode{kind: rSeq, pos: pos} | |||
| 155 | + | b.stack[n] = true | |||
| 156 | + | for _, c := range n.Content { | |||
| 157 | + | r.items = append(r.items, b.build(c, false)) | |||
| 158 | + | } | |||
| 159 | + | delete(b.stack, n) | |||
| 160 | + | return r | |||
| 161 | + | case yaml.MappingNode: | |||
| 162 | + | if asKey { | |||
| 163 | + | return &rnode{kind: rLiteral, pos: pos, lit: NewString(o, ""), badKey: true} | |||
| 164 | + | } | |||
| 165 | + | r := &rnode{kind: rMap, pos: pos} | |||
| 166 | + | b.stack[n] = true | |||
| 167 | + | for i := 0; i+1 < len(n.Content); i += 2 { | |||
| 168 | + | r.keys = append(r.keys, b.build(n.Content[i], true)) | |||
| 169 | + | r.vals = append(r.vals, b.build(n.Content[i+1], false)) | |||
| 170 | + | } | |||
| 171 | + | delete(b.stack, n) | |||
| 172 | + | return r | |||
| 173 | + | case yaml.ScalarNode: | |||
| 174 | + | r := &rnode{kind: rLiteral, pos: pos} | |||
| 175 | + | if asKey { | |||
| 176 | + | // Keys are always strings, whatever they look like. | |||
| 177 | + | r.lit = NewString(o, n.Value) | |||
| 178 | + | } else { | |||
| 179 | + | r.lit = resolveScalar(o, n) | |||
| 180 | + | } | |||
| 181 | + | r.quoted = n.Style&(yaml.DoubleQuotedStyle|yaml.SingleQuotedStyle) != 0 | |||
| 182 | + | r.block = n.Style&(yaml.LiteralStyle|yaml.FoldedStyle) != 0 | |||
| 183 | + | return r | |||
| 184 | + | } | |||
| 185 | + | return &rnode{kind: rLiteral, pos: pos, lit: NewNull(o)} | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | // resolveScalar gives a scalar its type the way the YAML 1.2 core schema of | |||
| 189 | + | // the JavaScript yaml library does: only plain scalars are resolved, and an | |||
| 190 | + | // explicit !!tag overrides the guess. | |||
| 191 | + | func resolveScalar(o Origin, n *yaml.Node) Token { | |||
| 192 | + | if n.Style&yaml.TaggedStyle != 0 { | |||
| 193 | + | switch n.ShortTag() { | |||
| 194 | + | case "!!str": | |||
| 195 | + | return NewString(o, n.Value) | |||
| 196 | + | case "!!null", "!!bool", "!!int", "!!float": | |||
| 197 | + | if t := resolvePlain(o, n.Value); t.Kind() != KindString { | |||
| 198 | + | return t | |||
| 199 | + | } | |||
| 200 | + | if n.ShortTag() == "!!null" && n.Value == "" { | |||
| 201 | + | return NewNull(o) | |||
| 202 | + | } | |||
| 203 | + | } | |||
| 204 | + | return NewString(o, n.Value) | |||
| 205 | + | } | |||
| 206 | + | if n.Style&(yaml.DoubleQuotedStyle|yaml.SingleQuotedStyle|yaml.LiteralStyle|yaml.FoldedStyle) != 0 { | |||
| 207 | + | return NewString(o, n.Value) | |||
| 208 | + | } | |||
| 209 | + | return resolvePlain(o, n.Value) | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | var ( | |||
| 213 | + | reInt = regexp.MustCompile(`^[-+]?[0-9]+$`) | |||
| 214 | + | reOct = regexp.MustCompile(`^0o[0-7]+$`) | |||
| 215 | + | reHex = regexp.MustCompile(`^0x[0-9a-fA-F]+$`) | |||
| 216 | + | reFloat = regexp.MustCompile(`^[-+]?(?:\.[0-9]+|[0-9]+(?:\.[0-9]*)?)(?:[eE][-+]?[0-9]+)?$`) | |||
| 217 | + | reInf = regexp.MustCompile(`^[-+]?\.(?:inf|Inf|INF)$`) | |||
| 218 | + | reNaN = regexp.MustCompile(`^\.(?:nan|NaN|NAN)$`) | |||
| 219 | + | ) | |||
| 220 | + | ||||
| 221 | + | func resolvePlain(o Origin, s string) Token { | |||
| 222 | + | switch s { | |||
| 223 | + | case "", "~", "null", "Null", "NULL": | |||
| 224 | + | return NewNull(o) | |||
| 225 | + | case "true", "True", "TRUE": | |||
| 226 | + | return NewBoolean(o, true) | |||
| 227 | + | case "false", "False", "FALSE": | |||
| 228 | + | return NewBoolean(o, false) | |||
| 229 | + | } | |||
| 230 | + | switch { | |||
| 231 | + | case reInt.MatchString(s): | |||
| 232 | + | f, _ := strconv.ParseFloat(s, 64) | |||
| 233 | + | return NewNumber(o, f) | |||
| 234 | + | case reOct.MatchString(s): | |||
| 235 | + | return NewNumber(o, parseBase(s[2:], 8)) | |||
| 236 | + | case reHex.MatchString(s): | |||
| 237 | + | return NewNumber(o, parseBase(s[2:], 16)) | |||
| 238 | + | case reFloat.MatchString(s): | |||
| 239 | + | f, _ := strconv.ParseFloat(s, 64) | |||
| 240 | + | return NewNumber(o, f) | |||
| 241 | + | case reInf.MatchString(s): | |||
| 242 | + | if s[0] == '-' { | |||
| 243 | + | return NewNumber(o, math.Inf(-1)) | |||
| 244 | + | } | |||
| 245 | + | return NewNumber(o, math.Inf(1)) | |||
| 246 | + | case reNaN.MatchString(s): | |||
| 247 | + | return NewNumber(o, math.NaN()) | |||
| 248 | + | } | |||
| 249 | + | return NewString(o, s) | |||
| 250 | + | } | |||
| 251 | + | ||||
| 252 | + | // parseBase converts digits in base 8 or 16 to a float64 without overflowing, | |||
| 253 | + | // like JavaScript's parseInt. | |||
| 254 | + | func parseBase(digits string, base int) float64 { | |||
| 255 | + | var f float64 | |||
| 256 | + | for _, c := range strings.ToLower(digits) { | |||
| 257 | + | d := int(c - '0') | |||
| 258 | + | if c >= 'a' { | |||
| 259 | + | d = int(c-'a') + 10 | |||
| 260 | + | } | |||
| 261 | + | f = f*float64(base) + float64(d) | |||
| 262 | + | } | |||
| 263 | + | return f | |||
| 264 | + | } | |||