feat(template): add tokens, errors and the schema model with the vendored workflow schema
- commit
- 3694c603fba5c5db1a151b1bc21bca32a70731dc
- author
- brice · · 2026-10-12 15:23 UTC
- committer
- brice · · 2026-10-12 16:17 UTC
- parent
- 0925bf3
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
12 files · +1181 -0
this diff is incomplete: 1 file is too large to show. the totals above count everything; clone the repository to read the rest.
M go.mod +2 -0
| @@ -1,3 +1,5 @@ | |||
| 1 | 1 | module bnema.dev/ermite-actions | |
| 2 | 2 | ||
| 3 | 3 | go 1.27.1 | |
| 4 | + | ||
| 5 | + | require go.yaml.in/yaml/v3 v3.0.5 // indirect | |
| @@ -1,3 +1,5 @@ | |||||
| 1 | module bnema.dev/ermite-actions | 1 | module bnema.dev/ermite-actions | ||
| 2 | 2 | ||||
| 3 | go 1.27.1 | 3 | go 1.27.1 | ||
| 4 | + | ||||
| 5 | + | require go.yaml.in/yaml/v3 v3.0.5 // indirect | |||
A go.sum +2 -0
| @@ -0,0 +1,2 @@ | |||
| 1 | + | go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= | |
| 2 | + | go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= | |
| @@ -0,0 +1,2 @@ | |||||
| 1 | + | go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= | |||
| 2 | + | go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= | |||
A template/context.go +106 -0
| @@ -0,0 +1,106 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package template | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "fmt" | |
| 7 | + | "strings" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // MaxErrors is the number of errors a [Context] keeps; later ones are | |
| 11 | + | // dropped. The reference keeps all of them, which lets a hostile file make the | |
| 12 | + | // list as long as the file. | |
| 13 | + | const MaxErrors = 1000 | |
| 14 | + | ||
| 15 | + | // Error is a problem found while reading or evaluating a template. | |
| 16 | + | type Error struct { | |
| 17 | + | // File is the name of the file the problem is in, or "". | |
| 18 | + | File string | |
| 19 | + | // Pos is where the problem is; the zero value means no position. | |
| 20 | + | Pos Pos | |
| 21 | + | // Raw is the message without the "file (Line: l, Col: c): " prefix. | |
| 22 | + | Raw string | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // Error returns the message with its prefix, as the reference does: | |
| 26 | + | // "file.yml (Line: 3, Col: 5): message". | |
| 27 | + | func (e *Error) Error() string { | |
| 28 | + | if p := errorPrefix(e.File, e.Pos); p != "" { | |
| 29 | + | return p + ": " + e.Raw | |
| 30 | + | } | |
| 31 | + | return e.Raw | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func errorPrefix(file string, p Pos) string { | |
| 35 | + | switch { | |
| 36 | + | case file != "" && p.Line > 0: | |
| 37 | + | return fmt.Sprintf("%s (Line: %d, Col: %d)", file, p.Line, p.Col) | |
| 38 | + | case file != "": | |
| 39 | + | return file | |
| 40 | + | case p.Line > 0: | |
| 41 | + | return fmt.Sprintf("(Line: %d, Col: %d)", p.Line, p.Col) | |
| 42 | + | } | |
| 43 | + | return "" | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // Context collects the errors of a read and carries its schema. | |
| 47 | + | type Context struct { | |
| 48 | + | schema *Schema | |
| 49 | + | errors []*Error | |
| 50 | + | // Dropped counts the errors beyond [MaxErrors]. | |
| 51 | + | dropped int | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // NewContext returns a context that validates against schema. | |
| 55 | + | func NewContext(schema *Schema) *Context { return &Context{schema: schema} } | |
| 56 | + | ||
| 57 | + | // Schema returns the schema the context validates against. | |
| 58 | + | func (c *Context) Schema() *Schema { return c.schema } | |
| 59 | + | ||
| 60 | + | // Errors returns the errors recorded so far. | |
| 61 | + | func (c *Context) Errors() []*Error { return append([]*Error(nil), c.errors...) } | |
| 62 | + | ||
| 63 | + | // HasErrors reports whether an error was recorded. | |
| 64 | + | func (c *Context) HasErrors() bool { return len(c.errors) > 0 } | |
| 65 | + | ||
| 66 | + | // Errorf records an error located at tok, which may be nil. | |
| 67 | + | func (c *Context) Errorf(tok Token, format string, a ...any) { | |
| 68 | + | c.ErrorAt(tok, fmt.Sprintf(format, a...)) | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // ErrorAt records an error with message msg located at tok, which may be nil. | |
| 72 | + | func (c *Context) ErrorAt(tok Token, msg string) { | |
| 73 | + | e := &Error{Raw: msg} | |
| 74 | + | if tok != nil { | |
| 75 | + | e.File, e.Pos = tok.File(), tok.Pos() | |
| 76 | + | } | |
| 77 | + | c.add(e) | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | // ErrorInFile records an error that is about a whole file. | |
| 81 | + | func (c *Context) ErrorInFile(file, msg string) { c.add(&Error{File: file, Raw: msg}) } | |
| 82 | + | ||
| 83 | + | // ErrorAtPos records an error at an explicit position of file. | |
| 84 | + | func (c *Context) ErrorAtPos(file string, p Pos, msg string) { | |
| 85 | + | c.add(&Error{File: file, Pos: p, Raw: msg}) | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func (c *Context) add(e *Error) { | |
| 89 | + | if len(c.errors) >= MaxErrors { | |
| 90 | + | c.dropped++ | |
| 91 | + | return | |
| 92 | + | } | |
| 93 | + | c.errors = append(c.errors, e) | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | // Messages returns the full text of the errors. | |
| 97 | + | func (c *Context) Messages() []string { | |
| 98 | + | out := make([]string, len(c.errors)) | |
| 99 | + | for i, e := range c.errors { | |
| 100 | + | out[i] = e.Error() | |
| 101 | + | } | |
| 102 | + | return out | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | // Join returns the errors as one text, one per line. | |
| 106 | + | func (c *Context) Join() string { return strings.Join(c.Messages(), "\n") } | |
| @@ -0,0 +1,106 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package template | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "fmt" | |||
| 7 | + | "strings" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // MaxErrors is the number of errors a [Context] keeps; later ones are | |||
| 11 | + | // dropped. The reference keeps all of them, which lets a hostile file make the | |||
| 12 | + | // list as long as the file. | |||
| 13 | + | const MaxErrors = 1000 | |||
| 14 | + | ||||
| 15 | + | // Error is a problem found while reading or evaluating a template. | |||
| 16 | + | type Error struct { | |||
| 17 | + | // File is the name of the file the problem is in, or "". | |||
| 18 | + | File string | |||
| 19 | + | // Pos is where the problem is; the zero value means no position. | |||
| 20 | + | Pos Pos | |||
| 21 | + | // Raw is the message without the "file (Line: l, Col: c): " prefix. | |||
| 22 | + | Raw string | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // Error returns the message with its prefix, as the reference does: | |||
| 26 | + | // "file.yml (Line: 3, Col: 5): message". | |||
| 27 | + | func (e *Error) Error() string { | |||
| 28 | + | if p := errorPrefix(e.File, e.Pos); p != "" { | |||
| 29 | + | return p + ": " + e.Raw | |||
| 30 | + | } | |||
| 31 | + | return e.Raw | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func errorPrefix(file string, p Pos) string { | |||
| 35 | + | switch { | |||
| 36 | + | case file != "" && p.Line > 0: | |||
| 37 | + | return fmt.Sprintf("%s (Line: %d, Col: %d)", file, p.Line, p.Col) | |||
| 38 | + | case file != "": | |||
| 39 | + | return file | |||
| 40 | + | case p.Line > 0: | |||
| 41 | + | return fmt.Sprintf("(Line: %d, Col: %d)", p.Line, p.Col) | |||
| 42 | + | } | |||
| 43 | + | return "" | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // Context collects the errors of a read and carries its schema. | |||
| 47 | + | type Context struct { | |||
| 48 | + | schema *Schema | |||
| 49 | + | errors []*Error | |||
| 50 | + | // Dropped counts the errors beyond [MaxErrors]. | |||
| 51 | + | dropped int | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | // NewContext returns a context that validates against schema. | |||
| 55 | + | func NewContext(schema *Schema) *Context { return &Context{schema: schema} } | |||
| 56 | + | ||||
| 57 | + | // Schema returns the schema the context validates against. | |||
| 58 | + | func (c *Context) Schema() *Schema { return c.schema } | |||
| 59 | + | ||||
| 60 | + | // Errors returns the errors recorded so far. | |||
| 61 | + | func (c *Context) Errors() []*Error { return append([]*Error(nil), c.errors...) } | |||
| 62 | + | ||||
| 63 | + | // HasErrors reports whether an error was recorded. | |||
| 64 | + | func (c *Context) HasErrors() bool { return len(c.errors) > 0 } | |||
| 65 | + | ||||
| 66 | + | // Errorf records an error located at tok, which may be nil. | |||
| 67 | + | func (c *Context) Errorf(tok Token, format string, a ...any) { | |||
| 68 | + | c.ErrorAt(tok, fmt.Sprintf(format, a...)) | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | // ErrorAt records an error with message msg located at tok, which may be nil. | |||
| 72 | + | func (c *Context) ErrorAt(tok Token, msg string) { | |||
| 73 | + | e := &Error{Raw: msg} | |||
| 74 | + | if tok != nil { | |||
| 75 | + | e.File, e.Pos = tok.File(), tok.Pos() | |||
| 76 | + | } | |||
| 77 | + | c.add(e) | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | // ErrorInFile records an error that is about a whole file. | |||
| 81 | + | func (c *Context) ErrorInFile(file, msg string) { c.add(&Error{File: file, Raw: msg}) } | |||
| 82 | + | ||||
| 83 | + | // ErrorAtPos records an error at an explicit position of file. | |||
| 84 | + | func (c *Context) ErrorAtPos(file string, p Pos, msg string) { | |||
| 85 | + | c.add(&Error{File: file, Pos: p, Raw: msg}) | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | func (c *Context) add(e *Error) { | |||
| 89 | + | if len(c.errors) >= MaxErrors { | |||
| 90 | + | c.dropped++ | |||
| 91 | + | return | |||
| 92 | + | } | |||
| 93 | + | c.errors = append(c.errors, e) | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | // Messages returns the full text of the errors. | |||
| 97 | + | func (c *Context) Messages() []string { | |||
| 98 | + | out := make([]string, len(c.errors)) | |||
| 99 | + | for i, e := range c.errors { | |||
| 100 | + | out[i] = e.Error() | |||
| 101 | + | } | |||
| 102 | + | return out | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | // Join returns the errors as one text, one per line. | |||
| 106 | + | func (c *Context) Join() string { return strings.Join(c.Messages(), "\n") } | |||
A template/definfo.go +118 -0
| @@ -0,0 +1,118 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package template | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "strconv" | |
| 7 | + | "strings" | |
| 8 | + | ||
| 9 | + | "bnema.dev/ermite-actions/expr" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // DefinitionInfo is a definition together with the contexts and functions | |
| 13 | + | // expressions at that place may use: those of the definition and of every | |
| 14 | + | // definition it was reached through. | |
| 15 | + | type DefinitionInfo struct { | |
| 16 | + | schema *Schema | |
| 17 | + | // Definition is the schema definition. | |
| 18 | + | Definition *Definition | |
| 19 | + | ||
| 20 | + | allowed []string | |
| 21 | + | named []string | |
| 22 | + | funcs []expr.FuncInfo | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func newDefinitionInfo(s *Schema, parent *DefinitionInfo, d *Definition) *DefinitionInfo { | |
| 26 | + | di := &DefinitionInfo{schema: s, Definition: d} | |
| 27 | + | if len(d.Context) > 0 { | |
| 28 | + | seen := map[string]bool{} | |
| 29 | + | if parent != nil { | |
| 30 | + | for _, c := range parent.allowed { | |
| 31 | + | di.allowed = append(di.allowed, c) | |
| 32 | + | seen[strings.ToUpper(c)] = true | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | for _, c := range d.Context { | |
| 36 | + | if u := strings.ToUpper(c); !seen[u] { | |
| 37 | + | di.allowed = append(di.allowed, c) | |
| 38 | + | seen[u] = true | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | } else if parent != nil { | |
| 42 | + | di.allowed = parent.allowed | |
| 43 | + | } | |
| 44 | + | di.named, di.funcs = splitAllowedContext(di.allowed) | |
| 45 | + | return di | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // child returns the info of the definition called name, reached through di. | |
| 49 | + | func (di *DefinitionInfo) child(name string) *DefinitionInfo { | |
| 50 | + | return newDefinitionInfo(di.schema, di, di.schema.get(name)) | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // AllowedContext returns the raw entries: context names and function | |
| 54 | + | // signatures such as "hashFiles(1,255)". | |
| 55 | + | func (di *DefinitionInfo) AllowedContext() []string { return di.allowed } | |
| 56 | + | ||
| 57 | + | // NamedContexts returns the names of the contexts expressions may use. | |
| 58 | + | func (di *DefinitionInfo) NamedContexts() []string { return di.named } | |
| 59 | + | ||
| 60 | + | // Functions returns the extra functions expressions may call, with their | |
| 61 | + | // arity. | |
| 62 | + | func (di *DefinitionInfo) Functions() []expr.FuncInfo { return di.funcs } | |
| 63 | + | ||
| 64 | + | // splitAllowedContext separates "name(min,max)" entries, with max a number or | |
| 65 | + | // MAX, from plain context names. | |
| 66 | + | func splitAllowedContext(allowed []string) ([]string, []expr.FuncInfo) { | |
| 67 | + | var named []string | |
| 68 | + | var funcs []expr.FuncInfo | |
| 69 | + | for _, item := range allowed { | |
| 70 | + | if f, ok := parseFuncEntry(item); ok { | |
| 71 | + | funcs = append(funcs, f) | |
| 72 | + | } else { | |
| 73 | + | named = append(named, item) | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | return named, funcs | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | // parseFuncEntry matches ^([a-zA-Z0-9_]+)\(([0-9]+),([0-9]+|MAX)\)$. | |
| 80 | + | func parseFuncEntry(s string) (expr.FuncInfo, bool) { | |
| 81 | + | open := strings.IndexByte(s, '(') | |
| 82 | + | if open <= 0 || !strings.HasSuffix(s, ")") { | |
| 83 | + | return expr.FuncInfo{}, false | |
| 84 | + | } | |
| 85 | + | name := s[:open] | |
| 86 | + | for _, r := range name { | |
| 87 | + | if !(r == '_' || r >= '0' && r <= '9' || r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z') { | |
| 88 | + | return expr.FuncInfo{}, false | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | minS, maxS, ok := strings.Cut(s[open+1:len(s)-1], ",") | |
| 92 | + | if !ok || !isDigits(minS) || !(isDigits(maxS) || maxS == "MAX") { | |
| 93 | + | return expr.FuncInfo{}, false | |
| 94 | + | } | |
| 95 | + | lo, err := strconv.Atoi(minS) | |
| 96 | + | if err != nil { | |
| 97 | + | return expr.FuncInfo{}, false | |
| 98 | + | } | |
| 99 | + | hi := -1 | |
| 100 | + | if maxS != "MAX" { | |
| 101 | + | if hi, err = strconv.Atoi(maxS); err != nil { | |
| 102 | + | return expr.FuncInfo{}, false | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | return expr.FuncInfo{Name: name, MinArgs: lo, MaxArgs: hi}, true | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | func isDigits(s string) bool { | |
| 109 | + | if s == "" { | |
| 110 | + | return false | |
| 111 | + | } | |
| 112 | + | for i := 0; i < len(s); i++ { | |
| 113 | + | if s[i] < '0' || s[i] > '9' { | |
| 114 | + | return false | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | return true | |
| 118 | + | } | |
| @@ -0,0 +1,118 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package template | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "strconv" | |||
| 7 | + | "strings" | |||
| 8 | + | ||||
| 9 | + | "bnema.dev/ermite-actions/expr" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // DefinitionInfo is a definition together with the contexts and functions | |||
| 13 | + | // expressions at that place may use: those of the definition and of every | |||
| 14 | + | // definition it was reached through. | |||
| 15 | + | type DefinitionInfo struct { | |||
| 16 | + | schema *Schema | |||
| 17 | + | // Definition is the schema definition. | |||
| 18 | + | Definition *Definition | |||
| 19 | + | ||||
| 20 | + | allowed []string | |||
| 21 | + | named []string | |||
| 22 | + | funcs []expr.FuncInfo | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func newDefinitionInfo(s *Schema, parent *DefinitionInfo, d *Definition) *DefinitionInfo { | |||
| 26 | + | di := &DefinitionInfo{schema: s, Definition: d} | |||
| 27 | + | if len(d.Context) > 0 { | |||
| 28 | + | seen := map[string]bool{} | |||
| 29 | + | if parent != nil { | |||
| 30 | + | for _, c := range parent.allowed { | |||
| 31 | + | di.allowed = append(di.allowed, c) | |||
| 32 | + | seen[strings.ToUpper(c)] = true | |||
| 33 | + | } | |||
| 34 | + | } | |||
| 35 | + | for _, c := range d.Context { | |||
| 36 | + | if u := strings.ToUpper(c); !seen[u] { | |||
| 37 | + | di.allowed = append(di.allowed, c) | |||
| 38 | + | seen[u] = true | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | } else if parent != nil { | |||
| 42 | + | di.allowed = parent.allowed | |||
| 43 | + | } | |||
| 44 | + | di.named, di.funcs = splitAllowedContext(di.allowed) | |||
| 45 | + | return di | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // child returns the info of the definition called name, reached through di. | |||
| 49 | + | func (di *DefinitionInfo) child(name string) *DefinitionInfo { | |||
| 50 | + | return newDefinitionInfo(di.schema, di, di.schema.get(name)) | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | // AllowedContext returns the raw entries: context names and function | |||
| 54 | + | // signatures such as "hashFiles(1,255)". | |||
| 55 | + | func (di *DefinitionInfo) AllowedContext() []string { return di.allowed } | |||
| 56 | + | ||||
| 57 | + | // NamedContexts returns the names of the contexts expressions may use. | |||
| 58 | + | func (di *DefinitionInfo) NamedContexts() []string { return di.named } | |||
| 59 | + | ||||
| 60 | + | // Functions returns the extra functions expressions may call, with their | |||
| 61 | + | // arity. | |||
| 62 | + | func (di *DefinitionInfo) Functions() []expr.FuncInfo { return di.funcs } | |||
| 63 | + | ||||
| 64 | + | // splitAllowedContext separates "name(min,max)" entries, with max a number or | |||
| 65 | + | // MAX, from plain context names. | |||
| 66 | + | func splitAllowedContext(allowed []string) ([]string, []expr.FuncInfo) { | |||
| 67 | + | var named []string | |||
| 68 | + | var funcs []expr.FuncInfo | |||
| 69 | + | for _, item := range allowed { | |||
| 70 | + | if f, ok := parseFuncEntry(item); ok { | |||
| 71 | + | funcs = append(funcs, f) | |||
| 72 | + | } else { | |||
| 73 | + | named = append(named, item) | |||
| 74 | + | } | |||
| 75 | + | } | |||
| 76 | + | return named, funcs | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | // parseFuncEntry matches ^([a-zA-Z0-9_]+)\(([0-9]+),([0-9]+|MAX)\)$. | |||
| 80 | + | func parseFuncEntry(s string) (expr.FuncInfo, bool) { | |||
| 81 | + | open := strings.IndexByte(s, '(') | |||
| 82 | + | if open <= 0 || !strings.HasSuffix(s, ")") { | |||
| 83 | + | return expr.FuncInfo{}, false | |||
| 84 | + | } | |||
| 85 | + | name := s[:open] | |||
| 86 | + | for _, r := range name { | |||
| 87 | + | if !(r == '_' || r >= '0' && r <= '9' || r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z') { | |||
| 88 | + | return expr.FuncInfo{}, false | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | minS, maxS, ok := strings.Cut(s[open+1:len(s)-1], ",") | |||
| 92 | + | if !ok || !isDigits(minS) || !(isDigits(maxS) || maxS == "MAX") { | |||
| 93 | + | return expr.FuncInfo{}, false | |||
| 94 | + | } | |||
| 95 | + | lo, err := strconv.Atoi(minS) | |||
| 96 | + | if err != nil { | |||
| 97 | + | return expr.FuncInfo{}, false | |||
| 98 | + | } | |||
| 99 | + | hi := -1 | |||
| 100 | + | if maxS != "MAX" { | |||
| 101 | + | if hi, err = strconv.Atoi(maxS); err != nil { | |||
| 102 | + | return expr.FuncInfo{}, false | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | return expr.FuncInfo{Name: name, MinArgs: lo, MaxArgs: hi}, true | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | func isDigits(s string) bool { | |||
| 109 | + | if s == "" { | |||
| 110 | + | return false | |||
| 111 | + | } | |||
| 112 | + | for i := 0; i < len(s); i++ { | |||
| 113 | + | if s[i] < '0' || s[i] > '9' { | |||
| 114 | + | return false | |||
| 115 | + | } | |||
| 116 | + | } | |||
| 117 | + | return true | |||
| 118 | + | } | |||
A template/doc.go +21 -0
| @@ -0,0 +1,21 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | // Package template reads, validates and evaluates GitHub Actions workflow | |
| 4 | + | // templates: YAML documents whose strings may contain ${{ }} expressions. | |
| 5 | + | // | |
| 6 | + | // It is a port of the template layer of the npm package | |
| 7 | + | // @actions/workflow-parser 0.3.61 (MIT, GitHub, actions/languageservices): | |
| 8 | + | // the same tokens, the same reading rules and, where feasible, the same error | |
| 9 | + | // messages. The workflow schema (schema/workflow-v1.0.min.json) is vendored | |
| 10 | + | // unmodified and drives both validation and the contexts and functions that | |
| 11 | + | // each key may use in its expressions. | |
| 12 | + | // | |
| 13 | + | // [ReadYAML] turns a document into a tree of [Token] values, checked against | |
| 14 | + | // a [Schema]; problems are collected in a [Context] as [Error] values. A | |
| 15 | + | // string holding expressions becomes a [*BasicExpression] (a single ${{ }}) or | |
| 16 | + | // a format() call that joins the literal text and the expressions; ${{ insert }} | |
| 17 | + | // becomes an [*InsertExpression]. [Evaluate] later computes a token against | |
| 18 | + | // named contexts and functions, using package expr. | |
| 19 | + | // | |
| 20 | + | // Differences from the reference are listed in PORTING.md. | |
| 21 | + | package template | |
| @@ -0,0 +1,21 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | // Package template reads, validates and evaluates GitHub Actions workflow | |||
| 4 | + | // templates: YAML documents whose strings may contain ${{ }} expressions. | |||
| 5 | + | // | |||
| 6 | + | // It is a port of the template layer of the npm package | |||
| 7 | + | // @actions/workflow-parser 0.3.61 (MIT, GitHub, actions/languageservices): | |||
| 8 | + | // the same tokens, the same reading rules and, where feasible, the same error | |||
| 9 | + | // messages. The workflow schema (schema/workflow-v1.0.min.json) is vendored | |||
| 10 | + | // unmodified and drives both validation and the contexts and functions that | |||
| 11 | + | // each key may use in its expressions. | |||
| 12 | + | // | |||
| 13 | + | // [ReadYAML] turns a document into a tree of [Token] values, checked against | |||
| 14 | + | // a [Schema]; problems are collected in a [Context] as [Error] values. A | |||
| 15 | + | // string holding expressions becomes a [*BasicExpression] (a single ${{ }}) or | |||
| 16 | + | // a format() call that joins the literal text and the expressions; ${{ insert }} | |||
| 17 | + | // becomes an [*InsertExpression]. [Evaluate] later computes a token against | |||
| 18 | + | // named contexts and functions, using package expr. | |||
| 19 | + | // | |||
| 20 | + | // Differences from the reference are listed in PORTING.md. | |||
| 21 | + | package template | |||
A template/number.go +55 -0
| @@ -0,0 +1,55 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package template | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "math" | |
| 7 | + | "strconv" | |
| 8 | + | "strings" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // JSNumberString formats x like JavaScript's Number.prototype.toString: the | |
| 12 | + | // shortest decimal that round-trips, positional for decimal exponents from -7 | |
| 13 | + | // to 20, exponent notation beyond. The reference uses it for the text of a | |
| 14 | + | // number token. | |
| 15 | + | func JSNumberString(x float64) string { | |
| 16 | + | switch { | |
| 17 | + | case math.IsNaN(x): | |
| 18 | + | return "NaN" | |
| 19 | + | case x == 0: | |
| 20 | + | return "0" | |
| 21 | + | case math.IsInf(x, 1): | |
| 22 | + | return "Infinity" | |
| 23 | + | case math.IsInf(x, -1): | |
| 24 | + | return "-Infinity" | |
| 25 | + | } | |
| 26 | + | sign := "" | |
| 27 | + | if x < 0 { | |
| 28 | + | sign = "-" | |
| 29 | + | x = -x | |
| 30 | + | } | |
| 31 | + | s := strconv.FormatFloat(x, 'e', -1, 64) | |
| 32 | + | mant, expStr, _ := strings.Cut(s, "e") | |
| 33 | + | exp, _ := strconv.Atoi(expStr) | |
| 34 | + | digits := strings.Replace(mant, ".", "", 1) | |
| 35 | + | k := len(digits) | |
| 36 | + | n := exp + 1 | |
| 37 | + | switch { | |
| 38 | + | case k <= n && n <= 21: | |
| 39 | + | return sign + digits + strings.Repeat("0", n-k) | |
| 40 | + | case 0 < n && n <= 21: | |
| 41 | + | return sign + digits[:n] + "." + digits[n:] | |
| 42 | + | case -6 < n && n <= 0: | |
| 43 | + | return sign + "0." + strings.Repeat("0", -n) + digits | |
| 44 | + | } | |
| 45 | + | e := n - 1 | |
| 46 | + | esign := "+" | |
| 47 | + | if e < 0 { | |
| 48 | + | esign = "-" | |
| 49 | + | e = -e | |
| 50 | + | } | |
| 51 | + | if k == 1 { | |
| 52 | + | return sign + digits + "e" + esign + strconv.Itoa(e) | |
| 53 | + | } | |
| 54 | + | return sign + digits[:1] + "." + digits[1:] + "e" + esign + strconv.Itoa(e) | |
| 55 | + | } | |
| @@ -0,0 +1,55 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package template | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "math" | |||
| 7 | + | "strconv" | |||
| 8 | + | "strings" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // JSNumberString formats x like JavaScript's Number.prototype.toString: the | |||
| 12 | + | // shortest decimal that round-trips, positional for decimal exponents from -7 | |||
| 13 | + | // to 20, exponent notation beyond. The reference uses it for the text of a | |||
| 14 | + | // number token. | |||
| 15 | + | func JSNumberString(x float64) string { | |||
| 16 | + | switch { | |||
| 17 | + | case math.IsNaN(x): | |||
| 18 | + | return "NaN" | |||
| 19 | + | case x == 0: | |||
| 20 | + | return "0" | |||
| 21 | + | case math.IsInf(x, 1): | |||
| 22 | + | return "Infinity" | |||
| 23 | + | case math.IsInf(x, -1): | |||
| 24 | + | return "-Infinity" | |||
| 25 | + | } | |||
| 26 | + | sign := "" | |||
| 27 | + | if x < 0 { | |||
| 28 | + | sign = "-" | |||
| 29 | + | x = -x | |||
| 30 | + | } | |||
| 31 | + | s := strconv.FormatFloat(x, 'e', -1, 64) | |||
| 32 | + | mant, expStr, _ := strings.Cut(s, "e") | |||
| 33 | + | exp, _ := strconv.Atoi(expStr) | |||
| 34 | + | digits := strings.Replace(mant, ".", "", 1) | |||
| 35 | + | k := len(digits) | |||
| 36 | + | n := exp + 1 | |||
| 37 | + | switch { | |||
| 38 | + | case k <= n && n <= 21: | |||
| 39 | + | return sign + digits + strings.Repeat("0", n-k) | |||
| 40 | + | case 0 < n && n <= 21: | |||
| 41 | + | return sign + digits[:n] + "." + digits[n:] | |||
| 42 | + | case -6 < n && n <= 0: | |||
| 43 | + | return sign + "0." + strings.Repeat("0", -n) + digits | |||
| 44 | + | } | |||
| 45 | + | e := n - 1 | |||
| 46 | + | esign := "+" | |||
| 47 | + | if e < 0 { | |||
| 48 | + | esign = "-" | |||
| 49 | + | e = -e | |||
| 50 | + | } | |||
| 51 | + | if k == 1 { | |||
| 52 | + | return sign + digits + "e" + esign + strconv.Itoa(e) | |||
| 53 | + | } | |||
| 54 | + | return sign + digits[:1] + "." + digits[1:] + "e" + esign + strconv.Itoa(e) | |||
| 55 | + | } | |||
A template/schema.go +169 -0
| @@ -0,0 +1,169 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package template | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "fmt" | |
| 7 | + | "strings" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // DefinitionType is the type of a schema [Definition]. | |
| 11 | + | type DefinitionType int | |
| 12 | + | ||
| 13 | + | // The definition types. | |
| 14 | + | const ( | |
| 15 | + | DefNull DefinitionType = iota | |
| 16 | + | DefBoolean | |
| 17 | + | DefNumber | |
| 18 | + | DefString | |
| 19 | + | DefSequence | |
| 20 | + | DefMapping | |
| 21 | + | DefOneOf | |
| 22 | + | ) | |
| 23 | + | ||
| 24 | + | // Property is a well-known key of a mapping definition. | |
| 25 | + | type Property struct { | |
| 26 | + | // Type is the name of the definition of the value. | |
| 27 | + | Type string | |
| 28 | + | Required bool | |
| 29 | + | Description string | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // Definition is a named type of the schema. Which fields are meaningful | |
| 33 | + | // depends on Type. | |
| 34 | + | type Definition struct { | |
| 35 | + | Name string | |
| 36 | + | Type DefinitionType | |
| 37 | + | Description string | |
| 38 | + | // Context lists the named contexts and functions ("github", | |
| 39 | + | // "success(0,MAX)") expressions in values of this type may use. | |
| 40 | + | Context []string | |
| 41 | + | ||
| 42 | + | // String definitions. | |
| 43 | + | Constant string | |
| 44 | + | IgnoreCase bool | |
| 45 | + | RequireNonEmpty bool | |
| 46 | + | IsExpression bool | |
| 47 | + | ||
| 48 | + | // Sequence definitions. | |
| 49 | + | ItemType string | |
| 50 | + | ||
| 51 | + | // Mapping definitions: Properties in schema order. | |
| 52 | + | PropertyNames []string | |
| 53 | + | Properties map[string]Property | |
| 54 | + | LooseKeyType string | |
| 55 | + | LooseValueType string | |
| 56 | + | ||
| 57 | + | // One-of definitions: the names of the alternatives. | |
| 58 | + | OneOf []string | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // Schema is a set of named definitions. | |
| 62 | + | type Schema struct { | |
| 63 | + | Version string | |
| 64 | + | definitions map[string]*Definition | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // Definition returns the definition called name, or nil. | |
| 68 | + | func (s *Schema) Definition(name string) *Definition { return s.definitions[name] } | |
| 69 | + | ||
| 70 | + | // get is the reference's getDefinition, which throws when the name is unknown. | |
| 71 | + | func (s *Schema) get(name string) *Definition { | |
| 72 | + | d := s.definitions[name] | |
| 73 | + | if d == nil { | |
| 74 | + | fatalf("Schema definition '%s' not found", name) | |
| 75 | + | } | |
| 76 | + | return d | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | // scalarDefinitions expands one-of definitions and returns all scalar ones. | |
| 80 | + | func (s *Schema) scalarDefinitions(d *Definition) []*Definition { | |
| 81 | + | switch d.Type { | |
| 82 | + | case DefNull, DefBoolean, DefNumber, DefString: | |
| 83 | + | return []*Definition{d} | |
| 84 | + | case DefOneOf: | |
| 85 | + | var out []*Definition | |
| 86 | + | for _, n := range d.OneOf { | |
| 87 | + | out = append(out, s.scalarDefinitions(s.get(n))...) | |
| 88 | + | } | |
| 89 | + | return out | |
| 90 | + | } | |
| 91 | + | return nil | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | // definitionsOfType expands a one-of definition one level and returns the | |
| 95 | + | // alternatives of type t. | |
| 96 | + | func (s *Schema) definitionsOfType(d *Definition, t DefinitionType) []*Definition { | |
| 97 | + | if d.Type == t { | |
| 98 | + | return []*Definition{d} | |
| 99 | + | } | |
| 100 | + | var out []*Definition | |
| 101 | + | if d.Type == DefOneOf { | |
| 102 | + | for _, n := range d.OneOf { | |
| 103 | + | if nd := s.get(n); nd.Type == t { | |
| 104 | + | out = append(out, nd) | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | return out | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | // isMatch reports whether a literal token is accepted by a scalar definition. | |
| 112 | + | func (d *Definition) isMatch(t Token) bool { | |
| 113 | + | switch d.Type { | |
| 114 | + | case DefNull: | |
| 115 | + | return t.Kind() == KindNull | |
| 116 | + | case DefBoolean: | |
| 117 | + | return t.Kind() == KindBoolean | |
| 118 | + | case DefNumber: | |
| 119 | + | return t.Kind() == KindNumber | |
| 120 | + | case DefString: | |
| 121 | + | s, ok := t.(*String) | |
| 122 | + | if !ok { | |
| 123 | + | return false | |
| 124 | + | } | |
| 125 | + | switch { | |
| 126 | + | case d.Constant != "": | |
| 127 | + | if d.IgnoreCase { | |
| 128 | + | return strings.EqualFold(d.Constant, s.Value) | |
| 129 | + | } | |
| 130 | + | return d.Constant == s.Value | |
| 131 | + | case d.RequireNonEmpty: | |
| 132 | + | return s.Value != "" | |
| 133 | + | } | |
| 134 | + | return true | |
| 135 | + | } | |
| 136 | + | return false | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | // matchPropertyAndFilter looks for a property called name in any of defs; when | |
| 140 | + | // found in some only, defs is narrowed to those. It returns the property, if | |
| 141 | + | // any, and the possibly narrowed list. | |
| 142 | + | func matchPropertyAndFilter(defs []*Definition, name string) (*Property, []*Definition) { | |
| 143 | + | var result *Property | |
| 144 | + | notFoundInSome := false | |
| 145 | + | for _, d := range defs { | |
| 146 | + | if p, ok := d.Properties[name]; ok { | |
| 147 | + | p := p | |
| 148 | + | result = &p | |
| 149 | + | } else { | |
| 150 | + | notFoundInSome = true | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | if result != nil && notFoundInSome { | |
| 154 | + | kept := defs[:0:0] | |
| 155 | + | for _, d := range defs { | |
| 156 | + | if _, ok := d.Properties[name]; ok { | |
| 157 | + | kept = append(kept, d) | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | defs = kept | |
| 161 | + | } | |
| 162 | + | return result, defs | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | // fatal is the panic value used for errors that abort a read, which the | |
| 166 | + | // reference throws as exceptions. | |
| 167 | + | type fatal struct{ msg string } | |
| 168 | + | ||
| 169 | + | func fatalf(format string, a ...any) { panic(fatal{fmt.Sprintf(format, a...)}) } | |
| @@ -0,0 +1,169 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package template | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "fmt" | |||
| 7 | + | "strings" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // DefinitionType is the type of a schema [Definition]. | |||
| 11 | + | type DefinitionType int | |||
| 12 | + | ||||
| 13 | + | // The definition types. | |||
| 14 | + | const ( | |||
| 15 | + | DefNull DefinitionType = iota | |||
| 16 | + | DefBoolean | |||
| 17 | + | DefNumber | |||
| 18 | + | DefString | |||
| 19 | + | DefSequence | |||
| 20 | + | DefMapping | |||
| 21 | + | DefOneOf | |||
| 22 | + | ) | |||
| 23 | + | ||||
| 24 | + | // Property is a well-known key of a mapping definition. | |||
| 25 | + | type Property struct { | |||
| 26 | + | // Type is the name of the definition of the value. | |||
| 27 | + | Type string | |||
| 28 | + | Required bool | |||
| 29 | + | Description string | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | // Definition is a named type of the schema. Which fields are meaningful | |||
| 33 | + | // depends on Type. | |||
| 34 | + | type Definition struct { | |||
| 35 | + | Name string | |||
| 36 | + | Type DefinitionType | |||
| 37 | + | Description string | |||
| 38 | + | // Context lists the named contexts and functions ("github", | |||
| 39 | + | // "success(0,MAX)") expressions in values of this type may use. | |||
| 40 | + | Context []string | |||
| 41 | + | ||||
| 42 | + | // String definitions. | |||
| 43 | + | Constant string | |||
| 44 | + | IgnoreCase bool | |||
| 45 | + | RequireNonEmpty bool | |||
| 46 | + | IsExpression bool | |||
| 47 | + | ||||
| 48 | + | // Sequence definitions. | |||
| 49 | + | ItemType string | |||
| 50 | + | ||||
| 51 | + | // Mapping definitions: Properties in schema order. | |||
| 52 | + | PropertyNames []string | |||
| 53 | + | Properties map[string]Property | |||
| 54 | + | LooseKeyType string | |||
| 55 | + | LooseValueType string | |||
| 56 | + | ||||
| 57 | + | // One-of definitions: the names of the alternatives. | |||
| 58 | + | OneOf []string | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // Schema is a set of named definitions. | |||
| 62 | + | type Schema struct { | |||
| 63 | + | Version string | |||
| 64 | + | definitions map[string]*Definition | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | // Definition returns the definition called name, or nil. | |||
| 68 | + | func (s *Schema) Definition(name string) *Definition { return s.definitions[name] } | |||
| 69 | + | ||||
| 70 | + | // get is the reference's getDefinition, which throws when the name is unknown. | |||
| 71 | + | func (s *Schema) get(name string) *Definition { | |||
| 72 | + | d := s.definitions[name] | |||
| 73 | + | if d == nil { | |||
| 74 | + | fatalf("Schema definition '%s' not found", name) | |||
| 75 | + | } | |||
| 76 | + | return d | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | // scalarDefinitions expands one-of definitions and returns all scalar ones. | |||
| 80 | + | func (s *Schema) scalarDefinitions(d *Definition) []*Definition { | |||
| 81 | + | switch d.Type { | |||
| 82 | + | case DefNull, DefBoolean, DefNumber, DefString: | |||
| 83 | + | return []*Definition{d} | |||
| 84 | + | case DefOneOf: | |||
| 85 | + | var out []*Definition | |||
| 86 | + | for _, n := range d.OneOf { | |||
| 87 | + | out = append(out, s.scalarDefinitions(s.get(n))...) | |||
| 88 | + | } | |||
| 89 | + | return out | |||
| 90 | + | } | |||
| 91 | + | return nil | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | // definitionsOfType expands a one-of definition one level and returns the | |||
| 95 | + | // alternatives of type t. | |||
| 96 | + | func (s *Schema) definitionsOfType(d *Definition, t DefinitionType) []*Definition { | |||
| 97 | + | if d.Type == t { | |||
| 98 | + | return []*Definition{d} | |||
| 99 | + | } | |||
| 100 | + | var out []*Definition | |||
| 101 | + | if d.Type == DefOneOf { | |||
| 102 | + | for _, n := range d.OneOf { | |||
| 103 | + | if nd := s.get(n); nd.Type == t { | |||
| 104 | + | out = append(out, nd) | |||
| 105 | + | } | |||
| 106 | + | } | |||
| 107 | + | } | |||
| 108 | + | return out | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | // isMatch reports whether a literal token is accepted by a scalar definition. | |||
| 112 | + | func (d *Definition) isMatch(t Token) bool { | |||
| 113 | + | switch d.Type { | |||
| 114 | + | case DefNull: | |||
| 115 | + | return t.Kind() == KindNull | |||
| 116 | + | case DefBoolean: | |||
| 117 | + | return t.Kind() == KindBoolean | |||
| 118 | + | case DefNumber: | |||
| 119 | + | return t.Kind() == KindNumber | |||
| 120 | + | case DefString: | |||
| 121 | + | s, ok := t.(*String) | |||
| 122 | + | if !ok { | |||
| 123 | + | return false | |||
| 124 | + | } | |||
| 125 | + | switch { | |||
| 126 | + | case d.Constant != "": | |||
| 127 | + | if d.IgnoreCase { | |||
| 128 | + | return strings.EqualFold(d.Constant, s.Value) | |||
| 129 | + | } | |||
| 130 | + | return d.Constant == s.Value | |||
| 131 | + | case d.RequireNonEmpty: | |||
| 132 | + | return s.Value != "" | |||
| 133 | + | } | |||
| 134 | + | return true | |||
| 135 | + | } | |||
| 136 | + | return false | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | // matchPropertyAndFilter looks for a property called name in any of defs; when | |||
| 140 | + | // found in some only, defs is narrowed to those. It returns the property, if | |||
| 141 | + | // any, and the possibly narrowed list. | |||
| 142 | + | func matchPropertyAndFilter(defs []*Definition, name string) (*Property, []*Definition) { | |||
| 143 | + | var result *Property | |||
| 144 | + | notFoundInSome := false | |||
| 145 | + | for _, d := range defs { | |||
| 146 | + | if p, ok := d.Properties[name]; ok { | |||
| 147 | + | p := p | |||
| 148 | + | result = &p | |||
| 149 | + | } else { | |||
| 150 | + | notFoundInSome = true | |||
| 151 | + | } | |||
| 152 | + | } | |||
| 153 | + | if result != nil && notFoundInSome { | |||
| 154 | + | kept := defs[:0:0] | |||
| 155 | + | for _, d := range defs { | |||
| 156 | + | if _, ok := d.Properties[name]; ok { | |||
| 157 | + | kept = append(kept, d) | |||
| 158 | + | } | |||
| 159 | + | } | |||
| 160 | + | defs = kept | |||
| 161 | + | } | |||
| 162 | + | return result, defs | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | // fatal is the panic value used for errors that abort a read, which the | |||
| 166 | + | // reference throws as exceptions. | |||
| 167 | + | type fatal struct{ msg string } | |||
| 168 | + | ||||
| 169 | + | func fatalf(format string, a ...any) { panic(fatal{fmt.Sprintf(format, a...)}) } | |||
A template/schema/SOURCE +4 -0
| @@ -0,0 +1,4 @@ | |||
| 1 | + | workflow-v1.0.min.json is dist/workflow-v1.0.min.json of the npm package | |
| 2 | + | @actions/workflow-parser 0.3.61 (https://github.com/actions/languageservices, | |
| 3 | + | MIT, Copyright GitHub, Inc.), copied unmodified. It drives validation and the | |
| 4 | + | contexts and functions allowed per key. See ../../THIRD_PARTY.md. | |
| @@ -0,0 +1,4 @@ | |||||
| 1 | + | workflow-v1.0.min.json is dist/workflow-v1.0.min.json of the npm package | |||
| 2 | + | @actions/workflow-parser 0.3.61 (https://github.com/actions/languageservices, | |||
| 3 | + | MIT, Copyright GitHub, Inc.), copied unmodified. It drives validation and the | |||
| 4 | + | contexts and functions allowed per key. See ../../THIRD_PARTY.md. | |||
A template/schema/workflow-v1.0.min.json +1 -0 too large to show in the browser
too large to show in the browser view raw
A template/schema_load.go +346 -0
| @@ -0,0 +1,346 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package template | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "encoding/json" | |
| 8 | + | "errors" | |
| 9 | + | "fmt" | |
| 10 | + | "io" | |
| 11 | + | "strings" | |
| 12 | + | "sync" | |
| 13 | + | ||
| 14 | + | _ "embed" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | //go:embed schema/workflow-v1.0.min.json | |
| 18 | + | var workflowSchemaJSON []byte | |
| 19 | + | ||
| 20 | + | var ( | |
| 21 | + | workflowSchemaOnce sync.Once | |
| 22 | + | workflowSchema *Schema | |
| 23 | + | workflowSchemaErr error | |
| 24 | + | ) | |
| 25 | + | ||
| 26 | + | // WorkflowSchema returns the vendored workflow schema (workflow-v1.0). The | |
| 27 | + | // schema is read once and shared; it must not be modified. | |
| 28 | + | func WorkflowSchema() (*Schema, error) { | |
| 29 | + | workflowSchemaOnce.Do(func() { | |
| 30 | + | workflowSchema, workflowSchemaErr = ParseSchema(workflowSchemaJSON) | |
| 31 | + | }) | |
| 32 | + | return workflowSchema, workflowSchemaErr | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // jnode is a JSON value that keeps the order of object keys. | |
| 36 | + | type jnode struct { | |
| 37 | + | kind byte // 'o'bject, 'a'rray, 's'tring, 'b'ool, 'n'umber, 'z' null | |
| 38 | + | keys []string | |
| 39 | + | vals []*jnode | |
| 40 | + | str string | |
| 41 | + | b bool | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func (n *jnode) get(key string) *jnode { | |
| 45 | + | for i, k := range n.keys { | |
| 46 | + | if k == key { | |
| 47 | + | return n.vals[i] | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | return nil | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func readJNode(dec *json.Decoder) (*jnode, error) { | |
| 54 | + | t, err := dec.Token() | |
| 55 | + | if err != nil { | |
| 56 | + | return nil, err | |
| 57 | + | } | |
| 58 | + | switch v := t.(type) { | |
| 59 | + | case json.Delim: | |
| 60 | + | switch v { | |
| 61 | + | case '{': | |
| 62 | + | n := &jnode{kind: 'o'} | |
| 63 | + | for dec.More() { | |
| 64 | + | kt, err := dec.Token() | |
| 65 | + | if err != nil { | |
| 66 | + | return nil, err | |
| 67 | + | } | |
| 68 | + | val, err := readJNode(dec) | |
| 69 | + | if err != nil { | |
| 70 | + | return nil, err | |
| 71 | + | } | |
| 72 | + | n.keys = append(n.keys, kt.(string)) | |
| 73 | + | n.vals = append(n.vals, val) | |
| 74 | + | } | |
| 75 | + | _, err := dec.Token() | |
| 76 | + | return n, err | |
| 77 | + | case '[': | |
| 78 | + | n := &jnode{kind: 'a'} | |
| 79 | + | for dec.More() { | |
| 80 | + | val, err := readJNode(dec) | |
| 81 | + | if err != nil { | |
| 82 | + | return nil, err | |
| 83 | + | } | |
| 84 | + | n.vals = append(n.vals, val) | |
| 85 | + | } | |
| 86 | + | _, err := dec.Token() | |
| 87 | + | return n, err | |
| 88 | + | } | |
| 89 | + | case string: | |
| 90 | + | return &jnode{kind: 's', str: v}, nil | |
| 91 | + | case bool: | |
| 92 | + | return &jnode{kind: 'b', b: v}, nil | |
| 93 | + | case nil: | |
| 94 | + | return &jnode{kind: 'z'}, nil | |
| 95 | + | case json.Number, float64: | |
| 96 | + | return &jnode{kind: 'n'}, nil | |
| 97 | + | } | |
| 98 | + | return nil, errors.New("unexpected JSON token") | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | // ParseSchema reads a schema in the JSON form of workflow-v1.0.min.json: an | |
| 102 | + | // object with "version" and "definitions". The built-in definitions null, | |
| 103 | + | // boolean, number, string, sequence, mapping and any are always present. | |
| 104 | + | func ParseSchema(data []byte) (s *Schema, err error) { | |
| 105 | + | defer func() { | |
| 106 | + | if r := recover(); r != nil { | |
| 107 | + | f, ok := r.(fatal) | |
| 108 | + | if !ok { | |
| 109 | + | panic(r) | |
| 110 | + | } | |
| 111 | + | s, err = nil, errors.New(f.msg) | |
| 112 | + | } | |
| 113 | + | }() | |
| 114 | + | dec := json.NewDecoder(bytes.NewReader(data)) | |
| 115 | + | root, err := readJNode(dec) | |
| 116 | + | if err != nil { | |
| 117 | + | return nil, fmt.Errorf("invalid schema JSON: %w", err) | |
| 118 | + | } | |
| 119 | + | if _, err := dec.Token(); err != io.EOF { | |
| 120 | + | return nil, errors.New("invalid schema JSON: trailing data") | |
| 121 | + | } | |
| 122 | + | if root.kind != 'o' { | |
| 123 | + | fatalf("The schema must be an object") | |
| 124 | + | } | |
| 125 | + | s = newBuiltinSchema() | |
| 126 | + | for i, key := range root.keys { | |
| 127 | + | val := root.vals[i] | |
| 128 | + | switch key { | |
| 129 | + | case "version": | |
| 130 | + | if val.kind != 's' || val.str == "" { | |
| 131 | + | fatalf("Unexpected value in template-schema version") | |
| 132 | + | } | |
| 133 | + | s.Version = val.str | |
| 134 | + | case "definitions": | |
| 135 | + | if val.kind != 'o' { | |
| 136 | + | fatalf("template-schema definitions must be an object") | |
| 137 | + | } | |
| 138 | + | for j, name := range val.keys { | |
| 139 | + | s.loadDefinition(name, val.vals[j]) | |
| 140 | + | } | |
| 141 | + | default: | |
| 142 | + | fatalf("Error while reading 'template-schema key'. Unexpected value '%s'", key) | |
| 143 | + | } | |
| 144 | + | } | |
| 145 | + | s.validate() | |
| 146 | + | return s, nil | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | func newBuiltinSchema() *Schema { | |
| 150 | + | s := &Schema{definitions: map[string]*Definition{}} | |
| 151 | + | for _, d := range []*Definition{ | |
| 152 | + | {Name: "null", Type: DefNull}, | |
| 153 | + | {Name: "boolean", Type: DefBoolean}, | |
| 154 | + | {Name: "number", Type: DefNumber}, | |
| 155 | + | {Name: "string", Type: DefString}, | |
| 156 | + | {Name: "sequence", Type: DefSequence, ItemType: "any"}, | |
| 157 | + | {Name: "mapping", Type: DefMapping, LooseKeyType: "string", LooseValueType: "any"}, | |
| 158 | + | {Name: "any", Type: DefOneOf, OneOf: []string{"null", "boolean", "number", "string", "sequence", "mapping"}}, | |
| 159 | + | } { | |
| 160 | + | s.definitions[d.Name] = d | |
| 161 | + | } | |
| 162 | + | return s | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func (s *Schema) loadDefinition(name string, n *jnode) { | |
| 166 | + | if n.kind != 'o' { | |
| 167 | + | fatalf("Error while reading 'definitions value'. Unexpected value") | |
| 168 | + | } | |
| 169 | + | d := &Definition{Name: name} | |
| 170 | + | var body *jnode | |
| 171 | + | typed := false | |
| 172 | + | for i, k := range n.keys { | |
| 173 | + | v := n.vals[i] | |
| 174 | + | switch k { | |
| 175 | + | case "description": | |
| 176 | + | d.Description = v.str | |
| 177 | + | case "context": | |
| 178 | + | seen := map[string]bool{} | |
| 179 | + | for _, it := range v.vals { | |
| 180 | + | u := strings.ToUpper(it.str) | |
| 181 | + | if seen[u] { | |
| 182 | + | fatalf("Duplicate context item '%s'", it.str) | |
| 183 | + | } | |
| 184 | + | seen[u] = true | |
| 185 | + | d.Context = append(d.Context, it.str) | |
| 186 | + | } | |
| 187 | + | case "null", "boolean", "number", "string", "sequence", "mapping", "one-of", "allowed-values": | |
| 188 | + | if typed { | |
| 189 | + | continue | |
| 190 | + | } | |
| 191 | + | typed = true | |
| 192 | + | body = v | |
| 193 | + | d.Type = map[string]DefinitionType{ | |
| 194 | + | "null": DefNull, "boolean": DefBoolean, "number": DefNumber, "string": DefString, | |
| 195 | + | "sequence": DefSequence, "mapping": DefMapping, "one-of": DefOneOf, "allowed-values": DefOneOf, | |
| 196 | + | }[k] | |
| 197 | + | if k == "allowed-values" { | |
| 198 | + | for _, it := range v.vals { | |
| 199 | + | key := name + "-" + it.str | |
| 200 | + | s.definitions[key] = &Definition{Name: key, Type: DefString, Constant: trimDisplay(it.str)} | |
| 201 | + | d.OneOf = append(d.OneOf, key) | |
| 202 | + | } | |
| 203 | + | } | |
| 204 | + | default: | |
| 205 | + | fatalf("Error while reading 'definition mapping key'. Unexpected value '%s'", k) | |
| 206 | + | } | |
| 207 | + | } | |
| 208 | + | if !typed { | |
| 209 | + | fatalf("Not enough information to construct definition '%s'", name) | |
| 210 | + | } | |
| 211 | + | switch d.Type { | |
| 212 | + | case DefString: | |
| 213 | + | for i, k := range body.keys { | |
| 214 | + | v := body.vals[i] | |
| 215 | + | switch k { | |
| 216 | + | case "constant": | |
| 217 | + | d.Constant = v.str | |
| 218 | + | case "ignore-case": | |
| 219 | + | d.IgnoreCase = v.b | |
| 220 | + | case "require-non-empty": | |
| 221 | + | d.RequireNonEmpty = v.b | |
| 222 | + | case "is-expression": | |
| 223 | + | d.IsExpression = v.b | |
| 224 | + | default: | |
| 225 | + | fatalf("Error while reading 'definition string key'. Unexpected value '%s'", k) | |
| 226 | + | } | |
| 227 | + | } | |
| 228 | + | case DefSequence: | |
| 229 | + | if t := body.get("item-type"); t != nil { | |
| 230 | + | d.ItemType = t.str | |
| 231 | + | } | |
| 232 | + | case DefMapping: | |
| 233 | + | d.Properties = map[string]Property{} | |
| 234 | + | for i, k := range body.keys { | |
| 235 | + | v := body.vals[i] | |
| 236 | + | switch k { | |
| 237 | + | case "properties": | |
| 238 | + | for j, pn := range v.keys { | |
| 239 | + | d.PropertyNames = append(d.PropertyNames, pn) | |
| 240 | + | d.Properties[pn] = loadProperty(v.vals[j]) | |
| 241 | + | } | |
| 242 | + | case "loose-key-type": | |
| 243 | + | d.LooseKeyType = v.str | |
| 244 | + | case "loose-value-type": | |
| 245 | + | d.LooseValueType = v.str | |
| 246 | + | default: | |
| 247 | + | fatalf("Error while reading 'definition mapping key'. Unexpected value '%s'", k) | |
| 248 | + | } | |
| 249 | + | } | |
| 250 | + | case DefOneOf: | |
| 251 | + | if body != nil && body.kind == 'a' && len(d.OneOf) == 0 { | |
| 252 | + | for _, it := range body.vals { | |
| 253 | + | d.OneOf = append(d.OneOf, it.str) | |
| 254 | + | } | |
| 255 | + | } | |
| 256 | + | } | |
| 257 | + | s.definitions[name] = d | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | func loadProperty(n *jnode) Property { | |
| 261 | + | if n.kind == 's' { | |
| 262 | + | return Property{Type: n.str} | |
| 263 | + | } | |
| 264 | + | var p Property | |
| 265 | + | for i, k := range n.keys { | |
| 266 | + | v := n.vals[i] | |
| 267 | + | switch k { | |
| 268 | + | case "type": | |
| 269 | + | p.Type = v.str | |
| 270 | + | case "required": | |
| 271 | + | p.Required = v.b | |
| 272 | + | case "description": | |
| 273 | + | p.Description = v.str | |
| 274 | + | default: | |
| 275 | + | fatalf("Error while reading 'mapping-property-value key'. Unexpected value '%s'", k) | |
| 276 | + | } | |
| 277 | + | } | |
| 278 | + | return p | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | // validate checks that every reference resolves and the one-of definitions | |
| 282 | + | // are unambiguous, as TemplateSchema.validate does. | |
| 283 | + | func (s *Schema) validate() { | |
| 284 | + | for name, d := range s.definitions { | |
| 285 | + | if !validDefinitionName(name) { | |
| 286 | + | fatalf("Invalid definition name '%s'", name) | |
| 287 | + | } | |
| 288 | + | switch d.Type { | |
| 289 | + | case DefString: | |
| 290 | + | if d.Constant != "" && d.RequireNonEmpty { | |
| 291 | + | fatalf("Properties 'constant' and 'require-non-empty' cannot both be set") | |
| 292 | + | } | |
| 293 | + | case DefSequence: | |
| 294 | + | if d.ItemType == "" { | |
| 295 | + | fatalf("'%s' does not defined 'item-type'", name) | |
| 296 | + | } | |
| 297 | + | s.get(d.ItemType) | |
| 298 | + | case DefMapping: | |
| 299 | + | switch { | |
| 300 | + | case d.LooseKeyType != "": | |
| 301 | + | s.get(d.LooseKeyType) | |
| 302 | + | if d.LooseValueType == "" { | |
| 303 | + | fatalf("Property 'loose-key-type' is defined but 'loose-value-type' is not defined on '%s'", name) | |
| 304 | + | } | |
| 305 | + | s.get(d.LooseValueType) | |
| 306 | + | case d.LooseValueType != "": | |
| 307 | + | fatalf("Property 'loose-value-type' is defined but 'loose-key-type' is not defined on '%s'", name) | |
| 308 | + | } | |
| 309 | + | for pn, p := range d.Properties { | |
| 310 | + | if p.Type == "" { | |
| 311 | + | fatalf("Type not specified for the property '%s' on '%s'", pn, name) | |
| 312 | + | } | |
| 313 | + | s.get(p.Type) | |
| 314 | + | } | |
| 315 | + | case DefOneOf: | |
| 316 | + | if len(d.OneOf) == 0 { | |
| 317 | + | fatalf("'%s' does not contain any references", name) | |
| 318 | + | } | |
| 319 | + | seen := map[string]bool{} | |
| 320 | + | for _, n := range d.OneOf { | |
| 321 | + | if seen[n] { | |
| 322 | + | fatalf("'%s' contains duplicate nested type '%s'", name, n) | |
| 323 | + | } | |
| 324 | + | seen[n] = true | |
| 325 | + | if len(s.get(n).Context) > 0 { | |
| 326 | + | fatalf("'%s' is a one-of definition and references another definition that defines context. This is currently not supported.", name) | |
| 327 | + | } | |
| 328 | + | } | |
| 329 | + | } | |
| 330 | + | } | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | func validDefinitionName(n string) bool { | |
| 334 | + | if n == "" { | |
| 335 | + | return false | |
| 336 | + | } | |
| 337 | + | for i, r := range n { | |
| 338 | + | switch { | |
| 339 | + | case r == '_' || r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z': | |
| 340 | + | case i > 0 && (r == '-' || r >= '0' && r <= '9'): | |
| 341 | + | default: | |
| 342 | + | return false | |
| 343 | + | } | |
| 344 | + | } | |
| 345 | + | return true | |
| 346 | + | } | |
| @@ -0,0 +1,346 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package template | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "bytes" | |||
| 7 | + | "encoding/json" | |||
| 8 | + | "errors" | |||
| 9 | + | "fmt" | |||
| 10 | + | "io" | |||
| 11 | + | "strings" | |||
| 12 | + | "sync" | |||
| 13 | + | ||||
| 14 | + | _ "embed" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | //go:embed schema/workflow-v1.0.min.json | |||
| 18 | + | var workflowSchemaJSON []byte | |||
| 19 | + | ||||
| 20 | + | var ( | |||
| 21 | + | workflowSchemaOnce sync.Once | |||
| 22 | + | workflowSchema *Schema | |||
| 23 | + | workflowSchemaErr error | |||
| 24 | + | ) | |||
| 25 | + | ||||
| 26 | + | // WorkflowSchema returns the vendored workflow schema (workflow-v1.0). The | |||
| 27 | + | // schema is read once and shared; it must not be modified. | |||
| 28 | + | func WorkflowSchema() (*Schema, error) { | |||
| 29 | + | workflowSchemaOnce.Do(func() { | |||
| 30 | + | workflowSchema, workflowSchemaErr = ParseSchema(workflowSchemaJSON) | |||
| 31 | + | }) | |||
| 32 | + | return workflowSchema, workflowSchemaErr | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // jnode is a JSON value that keeps the order of object keys. | |||
| 36 | + | type jnode struct { | |||
| 37 | + | kind byte // 'o'bject, 'a'rray, 's'tring, 'b'ool, 'n'umber, 'z' null | |||
| 38 | + | keys []string | |||
| 39 | + | vals []*jnode | |||
| 40 | + | str string | |||
| 41 | + | b bool | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func (n *jnode) get(key string) *jnode { | |||
| 45 | + | for i, k := range n.keys { | |||
| 46 | + | if k == key { | |||
| 47 | + | return n.vals[i] | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | return nil | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func readJNode(dec *json.Decoder) (*jnode, error) { | |||
| 54 | + | t, err := dec.Token() | |||
| 55 | + | if err != nil { | |||
| 56 | + | return nil, err | |||
| 57 | + | } | |||
| 58 | + | switch v := t.(type) { | |||
| 59 | + | case json.Delim: | |||
| 60 | + | switch v { | |||
| 61 | + | case '{': | |||
| 62 | + | n := &jnode{kind: 'o'} | |||
| 63 | + | for dec.More() { | |||
| 64 | + | kt, err := dec.Token() | |||
| 65 | + | if err != nil { | |||
| 66 | + | return nil, err | |||
| 67 | + | } | |||
| 68 | + | val, err := readJNode(dec) | |||
| 69 | + | if err != nil { | |||
| 70 | + | return nil, err | |||
| 71 | + | } | |||
| 72 | + | n.keys = append(n.keys, kt.(string)) | |||
| 73 | + | n.vals = append(n.vals, val) | |||
| 74 | + | } | |||
| 75 | + | _, err := dec.Token() | |||
| 76 | + | return n, err | |||
| 77 | + | case '[': | |||
| 78 | + | n := &jnode{kind: 'a'} | |||
| 79 | + | for dec.More() { | |||
| 80 | + | val, err := readJNode(dec) | |||
| 81 | + | if err != nil { | |||
| 82 | + | return nil, err | |||
| 83 | + | } | |||
| 84 | + | n.vals = append(n.vals, val) | |||
| 85 | + | } | |||
| 86 | + | _, err := dec.Token() | |||
| 87 | + | return n, err | |||
| 88 | + | } | |||
| 89 | + | case string: | |||
| 90 | + | return &jnode{kind: 's', str: v}, nil | |||
| 91 | + | case bool: | |||
| 92 | + | return &jnode{kind: 'b', b: v}, nil | |||
| 93 | + | case nil: | |||
| 94 | + | return &jnode{kind: 'z'}, nil | |||
| 95 | + | case json.Number, float64: | |||
| 96 | + | return &jnode{kind: 'n'}, nil | |||
| 97 | + | } | |||
| 98 | + | return nil, errors.New("unexpected JSON token") | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | // ParseSchema reads a schema in the JSON form of workflow-v1.0.min.json: an | |||
| 102 | + | // object with "version" and "definitions". The built-in definitions null, | |||
| 103 | + | // boolean, number, string, sequence, mapping and any are always present. | |||
| 104 | + | func ParseSchema(data []byte) (s *Schema, err error) { | |||
| 105 | + | defer func() { | |||
| 106 | + | if r := recover(); r != nil { | |||
| 107 | + | f, ok := r.(fatal) | |||
| 108 | + | if !ok { | |||
| 109 | + | panic(r) | |||
| 110 | + | } | |||
| 111 | + | s, err = nil, errors.New(f.msg) | |||
| 112 | + | } | |||
| 113 | + | }() | |||
| 114 | + | dec := json.NewDecoder(bytes.NewReader(data)) | |||
| 115 | + | root, err := readJNode(dec) | |||
| 116 | + | if err != nil { | |||
| 117 | + | return nil, fmt.Errorf("invalid schema JSON: %w", err) | |||
| 118 | + | } | |||
| 119 | + | if _, err := dec.Token(); err != io.EOF { | |||
| 120 | + | return nil, errors.New("invalid schema JSON: trailing data") | |||
| 121 | + | } | |||
| 122 | + | if root.kind != 'o' { | |||
| 123 | + | fatalf("The schema must be an object") | |||
| 124 | + | } | |||
| 125 | + | s = newBuiltinSchema() | |||
| 126 | + | for i, key := range root.keys { | |||
| 127 | + | val := root.vals[i] | |||
| 128 | + | switch key { | |||
| 129 | + | case "version": | |||
| 130 | + | if val.kind != 's' || val.str == "" { | |||
| 131 | + | fatalf("Unexpected value in template-schema version") | |||
| 132 | + | } | |||
| 133 | + | s.Version = val.str | |||
| 134 | + | case "definitions": | |||
| 135 | + | if val.kind != 'o' { | |||
| 136 | + | fatalf("template-schema definitions must be an object") | |||
| 137 | + | } | |||
| 138 | + | for j, name := range val.keys { | |||
| 139 | + | s.loadDefinition(name, val.vals[j]) | |||
| 140 | + | } | |||
| 141 | + | default: | |||
| 142 | + | fatalf("Error while reading 'template-schema key'. Unexpected value '%s'", key) | |||
| 143 | + | } | |||
| 144 | + | } | |||
| 145 | + | s.validate() | |||
| 146 | + | return s, nil | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | func newBuiltinSchema() *Schema { | |||
| 150 | + | s := &Schema{definitions: map[string]*Definition{}} | |||
| 151 | + | for _, d := range []*Definition{ | |||
| 152 | + | {Name: "null", Type: DefNull}, | |||
| 153 | + | {Name: "boolean", Type: DefBoolean}, | |||
| 154 | + | {Name: "number", Type: DefNumber}, | |||
| 155 | + | {Name: "string", Type: DefString}, | |||
| 156 | + | {Name: "sequence", Type: DefSequence, ItemType: "any"}, | |||
| 157 | + | {Name: "mapping", Type: DefMapping, LooseKeyType: "string", LooseValueType: "any"}, | |||
| 158 | + | {Name: "any", Type: DefOneOf, OneOf: []string{"null", "boolean", "number", "string", "sequence", "mapping"}}, | |||
| 159 | + | } { | |||
| 160 | + | s.definitions[d.Name] = d | |||
| 161 | + | } | |||
| 162 | + | return s | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | func (s *Schema) loadDefinition(name string, n *jnode) { | |||
| 166 | + | if n.kind != 'o' { | |||
| 167 | + | fatalf("Error while reading 'definitions value'. Unexpected value") | |||
| 168 | + | } | |||
| 169 | + | d := &Definition{Name: name} | |||
| 170 | + | var body *jnode | |||
| 171 | + | typed := false | |||
| 172 | + | for i, k := range n.keys { | |||
| 173 | + | v := n.vals[i] | |||
| 174 | + | switch k { | |||
| 175 | + | case "description": | |||
| 176 | + | d.Description = v.str | |||
| 177 | + | case "context": | |||
| 178 | + | seen := map[string]bool{} | |||
| 179 | + | for _, it := range v.vals { | |||
| 180 | + | u := strings.ToUpper(it.str) | |||
| 181 | + | if seen[u] { | |||
| 182 | + | fatalf("Duplicate context item '%s'", it.str) | |||
| 183 | + | } | |||
| 184 | + | seen[u] = true | |||
| 185 | + | d.Context = append(d.Context, it.str) | |||
| 186 | + | } | |||
| 187 | + | case "null", "boolean", "number", "string", "sequence", "mapping", "one-of", "allowed-values": | |||
| 188 | + | if typed { | |||
| 189 | + | continue | |||
| 190 | + | } | |||
| 191 | + | typed = true | |||
| 192 | + | body = v | |||
| 193 | + | d.Type = map[string]DefinitionType{ | |||
| 194 | + | "null": DefNull, "boolean": DefBoolean, "number": DefNumber, "string": DefString, | |||
| 195 | + | "sequence": DefSequence, "mapping": DefMapping, "one-of": DefOneOf, "allowed-values": DefOneOf, | |||
| 196 | + | }[k] | |||
| 197 | + | if k == "allowed-values" { | |||
| 198 | + | for _, it := range v.vals { | |||
| 199 | + | key := name + "-" + it.str | |||
| 200 | + | s.definitions[key] = &Definition{Name: key, Type: DefString, Constant: trimDisplay(it.str)} | |||
| 201 | + | d.OneOf = append(d.OneOf, key) | |||
| 202 | + | } | |||
| 203 | + | } | |||
| 204 | + | default: | |||
| 205 | + | fatalf("Error while reading 'definition mapping key'. Unexpected value '%s'", k) | |||
| 206 | + | } | |||
| 207 | + | } | |||
| 208 | + | if !typed { | |||
| 209 | + | fatalf("Not enough information to construct definition '%s'", name) | |||
| 210 | + | } | |||
| 211 | + | switch d.Type { | |||
| 212 | + | case DefString: | |||
| 213 | + | for i, k := range body.keys { | |||
| 214 | + | v := body.vals[i] | |||
| 215 | + | switch k { | |||
| 216 | + | case "constant": | |||
| 217 | + | d.Constant = v.str | |||
| 218 | + | case "ignore-case": | |||
| 219 | + | d.IgnoreCase = v.b | |||
| 220 | + | case "require-non-empty": | |||
| 221 | + | d.RequireNonEmpty = v.b | |||
| 222 | + | case "is-expression": | |||
| 223 | + | d.IsExpression = v.b | |||
| 224 | + | default: | |||
| 225 | + | fatalf("Error while reading 'definition string key'. Unexpected value '%s'", k) | |||
| 226 | + | } | |||
| 227 | + | } | |||
| 228 | + | case DefSequence: | |||
| 229 | + | if t := body.get("item-type"); t != nil { | |||
| 230 | + | d.ItemType = t.str | |||
| 231 | + | } | |||
| 232 | + | case DefMapping: | |||
| 233 | + | d.Properties = map[string]Property{} | |||
| 234 | + | for i, k := range body.keys { | |||
| 235 | + | v := body.vals[i] | |||
| 236 | + | switch k { | |||
| 237 | + | case "properties": | |||
| 238 | + | for j, pn := range v.keys { | |||
| 239 | + | d.PropertyNames = append(d.PropertyNames, pn) | |||
| 240 | + | d.Properties[pn] = loadProperty(v.vals[j]) | |||
| 241 | + | } | |||
| 242 | + | case "loose-key-type": | |||
| 243 | + | d.LooseKeyType = v.str | |||
| 244 | + | case "loose-value-type": | |||
| 245 | + | d.LooseValueType = v.str | |||
| 246 | + | default: | |||
| 247 | + | fatalf("Error while reading 'definition mapping key'. Unexpected value '%s'", k) | |||
| 248 | + | } | |||
| 249 | + | } | |||
| 250 | + | case DefOneOf: | |||
| 251 | + | if body != nil && body.kind == 'a' && len(d.OneOf) == 0 { | |||
| 252 | + | for _, it := range body.vals { | |||
| 253 | + | d.OneOf = append(d.OneOf, it.str) | |||
| 254 | + | } | |||
| 255 | + | } | |||
| 256 | + | } | |||
| 257 | + | s.definitions[name] = d | |||
| 258 | + | } | |||
| 259 | + | ||||
| 260 | + | func loadProperty(n *jnode) Property { | |||
| 261 | + | if n.kind == 's' { | |||
| 262 | + | return Property{Type: n.str} | |||
| 263 | + | } | |||
| 264 | + | var p Property | |||
| 265 | + | for i, k := range n.keys { | |||
| 266 | + | v := n.vals[i] | |||
| 267 | + | switch k { | |||
| 268 | + | case "type": | |||
| 269 | + | p.Type = v.str | |||
| 270 | + | case "required": | |||
| 271 | + | p.Required = v.b | |||
| 272 | + | case "description": | |||
| 273 | + | p.Description = v.str | |||
| 274 | + | default: | |||
| 275 | + | fatalf("Error while reading 'mapping-property-value key'. Unexpected value '%s'", k) | |||
| 276 | + | } | |||
| 277 | + | } | |||
| 278 | + | return p | |||
| 279 | + | } | |||
| 280 | + | ||||
| 281 | + | // validate checks that every reference resolves and the one-of definitions | |||
| 282 | + | // are unambiguous, as TemplateSchema.validate does. | |||
| 283 | + | func (s *Schema) validate() { | |||
| 284 | + | for name, d := range s.definitions { | |||
| 285 | + | if !validDefinitionName(name) { | |||
| 286 | + | fatalf("Invalid definition name '%s'", name) | |||
| 287 | + | } | |||
| 288 | + | switch d.Type { | |||
| 289 | + | case DefString: | |||
| 290 | + | if d.Constant != "" && d.RequireNonEmpty { | |||
| 291 | + | fatalf("Properties 'constant' and 'require-non-empty' cannot both be set") | |||
| 292 | + | } | |||
| 293 | + | case DefSequence: | |||
| 294 | + | if d.ItemType == "" { | |||
| 295 | + | fatalf("'%s' does not defined 'item-type'", name) | |||
| 296 | + | } | |||
| 297 | + | s.get(d.ItemType) | |||
| 298 | + | case DefMapping: | |||
| 299 | + | switch { | |||
| 300 | + | case d.LooseKeyType != "": | |||
| 301 | + | s.get(d.LooseKeyType) | |||
| 302 | + | if d.LooseValueType == "" { | |||
| 303 | + | fatalf("Property 'loose-key-type' is defined but 'loose-value-type' is not defined on '%s'", name) | |||
| 304 | + | } | |||
| 305 | + | s.get(d.LooseValueType) | |||
| 306 | + | case d.LooseValueType != "": | |||
| 307 | + | fatalf("Property 'loose-value-type' is defined but 'loose-key-type' is not defined on '%s'", name) | |||
| 308 | + | } | |||
| 309 | + | for pn, p := range d.Properties { | |||
| 310 | + | if p.Type == "" { | |||
| 311 | + | fatalf("Type not specified for the property '%s' on '%s'", pn, name) | |||
| 312 | + | } | |||
| 313 | + | s.get(p.Type) | |||
| 314 | + | } | |||
| 315 | + | case DefOneOf: | |||
| 316 | + | if len(d.OneOf) == 0 { | |||
| 317 | + | fatalf("'%s' does not contain any references", name) | |||
| 318 | + | } | |||
| 319 | + | seen := map[string]bool{} | |||
| 320 | + | for _, n := range d.OneOf { | |||
| 321 | + | if seen[n] { | |||
| 322 | + | fatalf("'%s' contains duplicate nested type '%s'", name, n) | |||
| 323 | + | } | |||
| 324 | + | seen[n] = true | |||
| 325 | + | if len(s.get(n).Context) > 0 { | |||
| 326 | + | fatalf("'%s' is a one-of definition and references another definition that defines context. This is currently not supported.", name) | |||
| 327 | + | } | |||
| 328 | + | } | |||
| 329 | + | } | |||
| 330 | + | } | |||
| 331 | + | } | |||
| 332 | + | ||||
| 333 | + | func validDefinitionName(n string) bool { | |||
| 334 | + | if n == "" { | |||
| 335 | + | return false | |||
| 336 | + | } | |||
| 337 | + | for i, r := range n { | |||
| 338 | + | switch { | |||
| 339 | + | case r == '_' || r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z': | |||
| 340 | + | case i > 0 && (r == '-' || r >= '0' && r <= '9'): | |||
| 341 | + | default: | |||
| 342 | + | return false | |||
| 343 | + | } | |||
| 344 | + | } | |||
| 345 | + | return true | |||
| 346 | + | } | |||
A template/schema_test.go +78 -0
| @@ -0,0 +1,78 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package template | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "reflect" | |
| 7 | + | "strings" | |
| 8 | + | "testing" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestWorkflowSchemaLoads(t *testing.T) { | |
| 12 | + | s, err := WorkflowSchema() | |
| 13 | + | if err != nil { | |
| 14 | + | t.Fatal(err) | |
| 15 | + | } | |
| 16 | + | if s.Version != "workflow-v1.0" { | |
| 17 | + | t.Errorf("version = %q", s.Version) | |
| 18 | + | } | |
| 19 | + | root := s.Definition("workflow-root-strict") | |
| 20 | + | if root == nil || !root.Properties["jobs"].Required || !root.Properties["on"].Required { | |
| 21 | + | t.Fatalf("workflow-root-strict = %+v", root) | |
| 22 | + | } | |
| 23 | + | if d := s.Definition("push-string"); d == nil || d.Constant != "push" { | |
| 24 | + | t.Errorf("push-string = %+v", d) | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func TestAllowedContext(t *testing.T) { | |
| 29 | + | s, err := WorkflowSchema() | |
| 30 | + | if err != nil { | |
| 31 | + | t.Fatal(err) | |
| 32 | + | } | |
| 33 | + | di := newDefinitionInfo(s, nil, s.Definition("step-if")) | |
| 34 | + | if got, want := di.NamedContexts(), []string{"github", "inputs", "vars", "needs", "strategy", "matrix", "steps", "job", "runner", "env"}; !reflect.DeepEqual(got, want) { | |
| 35 | + | t.Errorf("named = %v", got) | |
| 36 | + | } | |
| 37 | + | var fs []string | |
| 38 | + | for _, f := range di.Functions() { | |
| 39 | + | fs = append(fs, f.Name) | |
| 40 | + | } | |
| 41 | + | if got, want := strings.Join(fs, ","), "always,failure,cancelled,success,hashFiles"; got != want { | |
| 42 | + | t.Errorf("funcs = %s", got) | |
| 43 | + | } | |
| 44 | + | ji := newDefinitionInfo(s, nil, s.Definition("job-if")) | |
| 45 | + | for _, f := range ji.Functions() { | |
| 46 | + | if f.Name == "success" && f.MaxArgs != -1 { | |
| 47 | + | t.Errorf("job-if success max = %d", f.MaxArgs) | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func TestParseFuncEntry(t *testing.T) { | |
| 53 | + | for in, ok := range map[string]bool{ | |
| 54 | + | "hashFiles(1,255)": true, "always(0,0)": true, "failure(0,MAX)": true, | |
| 55 | + | "github": false, "f(1)": false, "f(a,2)": false, "(1,2)": false, "f(1,2) ": false, | |
| 56 | + | } { | |
| 57 | + | if _, got := parseFuncEntry(in); got != ok { | |
| 58 | + | t.Errorf("%q: %v", in, got) | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | func TestParseSchemaErrors(t *testing.T) { | |
| 64 | + | for in, want := range map[string]string{ | |
| 65 | + | `{"definitions":{"a":{"sequence":{}}}}`: "'a' does not defined 'item-type'", | |
| 66 | + | `{"definitions":{"a":{"mapping":{"loose-key-type":"string"}}}}`: "Property 'loose-key-type' is defined but 'loose-value-type' is not defined on 'a'", | |
| 67 | + | `{"definitions":{"a":{"one-of":["zzz"]}}}`: "Schema definition 'zzz' not found", | |
| 68 | + | `{"definitions":{"a":{"description":"x"}}}`: "Not enough information to construct definition 'a'", | |
| 69 | + | `{"definitions":{"a b":{"string":{}}}}`: "Invalid definition name 'a b'", | |
| 70 | + | `{"definitions":{"a":{"string":{"constant":"x","require-non-empty":true}}}}`: "cannot both be set", | |
| 71 | + | `{"foo":1}`: "Unexpected value 'foo'", | |
| 72 | + | } { | |
| 73 | + | _, err := ParseSchema([]byte(in)) | |
| 74 | + | if err == nil || !strings.Contains(err.Error(), want) { | |
| 75 | + | t.Errorf("%s: err = %v, want %q", in, err, want) | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | } | |
| @@ -0,0 +1,78 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package template | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "reflect" | |||
| 7 | + | "strings" | |||
| 8 | + | "testing" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestWorkflowSchemaLoads(t *testing.T) { | |||
| 12 | + | s, err := WorkflowSchema() | |||
| 13 | + | if err != nil { | |||
| 14 | + | t.Fatal(err) | |||
| 15 | + | } | |||
| 16 | + | if s.Version != "workflow-v1.0" { | |||
| 17 | + | t.Errorf("version = %q", s.Version) | |||
| 18 | + | } | |||
| 19 | + | root := s.Definition("workflow-root-strict") | |||
| 20 | + | if root == nil || !root.Properties["jobs"].Required || !root.Properties["on"].Required { | |||
| 21 | + | t.Fatalf("workflow-root-strict = %+v", root) | |||
| 22 | + | } | |||
| 23 | + | if d := s.Definition("push-string"); d == nil || d.Constant != "push" { | |||
| 24 | + | t.Errorf("push-string = %+v", d) | |||
| 25 | + | } | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func TestAllowedContext(t *testing.T) { | |||
| 29 | + | s, err := WorkflowSchema() | |||
| 30 | + | if err != nil { | |||
| 31 | + | t.Fatal(err) | |||
| 32 | + | } | |||
| 33 | + | di := newDefinitionInfo(s, nil, s.Definition("step-if")) | |||
| 34 | + | if got, want := di.NamedContexts(), []string{"github", "inputs", "vars", "needs", "strategy", "matrix", "steps", "job", "runner", "env"}; !reflect.DeepEqual(got, want) { | |||
| 35 | + | t.Errorf("named = %v", got) | |||
| 36 | + | } | |||
| 37 | + | var fs []string | |||
| 38 | + | for _, f := range di.Functions() { | |||
| 39 | + | fs = append(fs, f.Name) | |||
| 40 | + | } | |||
| 41 | + | if got, want := strings.Join(fs, ","), "always,failure,cancelled,success,hashFiles"; got != want { | |||
| 42 | + | t.Errorf("funcs = %s", got) | |||
| 43 | + | } | |||
| 44 | + | ji := newDefinitionInfo(s, nil, s.Definition("job-if")) | |||
| 45 | + | for _, f := range ji.Functions() { | |||
| 46 | + | if f.Name == "success" && f.MaxArgs != -1 { | |||
| 47 | + | t.Errorf("job-if success max = %d", f.MaxArgs) | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func TestParseFuncEntry(t *testing.T) { | |||
| 53 | + | for in, ok := range map[string]bool{ | |||
| 54 | + | "hashFiles(1,255)": true, "always(0,0)": true, "failure(0,MAX)": true, | |||
| 55 | + | "github": false, "f(1)": false, "f(a,2)": false, "(1,2)": false, "f(1,2) ": false, | |||
| 56 | + | } { | |||
| 57 | + | if _, got := parseFuncEntry(in); got != ok { | |||
| 58 | + | t.Errorf("%q: %v", in, got) | |||
| 59 | + | } | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | func TestParseSchemaErrors(t *testing.T) { | |||
| 64 | + | for in, want := range map[string]string{ | |||
| 65 | + | `{"definitions":{"a":{"sequence":{}}}}`: "'a' does not defined 'item-type'", | |||
| 66 | + | `{"definitions":{"a":{"mapping":{"loose-key-type":"string"}}}}`: "Property 'loose-key-type' is defined but 'loose-value-type' is not defined on 'a'", | |||
| 67 | + | `{"definitions":{"a":{"one-of":["zzz"]}}}`: "Schema definition 'zzz' not found", | |||
| 68 | + | `{"definitions":{"a":{"description":"x"}}}`: "Not enough information to construct definition 'a'", | |||
| 69 | + | `{"definitions":{"a b":{"string":{}}}}`: "Invalid definition name 'a b'", | |||
| 70 | + | `{"definitions":{"a":{"string":{"constant":"x","require-non-empty":true}}}}`: "cannot both be set", | |||
| 71 | + | `{"foo":1}`: "Unexpected value 'foo'", | |||
| 72 | + | } { | |||
| 73 | + | _, err := ParseSchema([]byte(in)) | |||
| 74 | + | if err == nil || !strings.Contains(err.Error(), want) { | |||
| 75 | + | t.Errorf("%s: err = %v, want %q", in, err, want) | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | } | |||
A template/tokens.go +279 -0
| @@ -0,0 +1,279 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package template | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "strings" | |
| 7 | + | ||
| 8 | + | "bnema.dev/ermite-actions/expr" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // Kind is the type of a [Token], numbered as in the reference. | |
| 12 | + | type Kind int | |
| 13 | + | ||
| 14 | + | // The token kinds. | |
| 15 | + | const ( | |
| 16 | + | KindString Kind = iota | |
| 17 | + | KindSequence | |
| 18 | + | KindMapping | |
| 19 | + | KindBasicExpression | |
| 20 | + | KindInsertExpression | |
| 21 | + | KindBoolean | |
| 22 | + | KindNumber | |
| 23 | + | KindNull | |
| 24 | + | ) | |
| 25 | + | ||
| 26 | + | // String returns the reference's name for the kind, such as "StringToken". | |
| 27 | + | func (k Kind) String() string { | |
| 28 | + | switch k { | |
| 29 | + | case KindString: | |
| 30 | + | return "StringToken" | |
| 31 | + | case KindSequence: | |
| 32 | + | return "SequenceToken" | |
| 33 | + | case KindMapping: | |
| 34 | + | return "MappingToken" | |
| 35 | + | case KindBasicExpression: | |
| 36 | + | return "BasicExpressionToken" | |
| 37 | + | case KindInsertExpression: | |
| 38 | + | return "InsertExpressionToken" | |
| 39 | + | case KindBoolean: | |
| 40 | + | return "BooleanToken" | |
| 41 | + | case KindNumber: | |
| 42 | + | return "NumberToken" | |
| 43 | + | case KindNull: | |
| 44 | + | return "NullToken" | |
| 45 | + | } | |
| 46 | + | return "unknown" | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // Pos is a position in a source file. Line and Col are 1-based; the zero | |
| 50 | + | // value means the position is unknown (a token made by the program). | |
| 51 | + | type Pos struct{ Line, Col int } | |
| 52 | + | ||
| 53 | + | // Token is a node of a template: a scalar, a sequence or a mapping. The | |
| 54 | + | // concrete types are [*String], [*Number], [*Boolean], [*Null], | |
| 55 | + | // [*Sequence], [*Mapping], [*BasicExpression] and [*InsertExpression]. | |
| 56 | + | type Token interface { | |
| 57 | + | // Kind reports the type of the token. | |
| 58 | + | Kind() Kind | |
| 59 | + | // File is the name of the file the token was read from, or "". | |
| 60 | + | File() string | |
| 61 | + | // Pos is where the token starts in File. | |
| 62 | + | Pos() Pos | |
| 63 | + | // Definition is the schema definition the token was read against, or nil. | |
| 64 | + | Definition() *DefinitionInfo | |
| 65 | + | // String is the text the reference shows in messages: the value of a | |
| 66 | + | // literal, "${{ expr }}" for an expression. | |
| 67 | + | String() string | |
| 68 | + | base() *tokenBase | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | type tokenBase struct { | |
| 72 | + | file string | |
| 73 | + | pos Pos | |
| 74 | + | def *DefinitionInfo | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func (m *tokenBase) File() string { return m.file } | |
| 78 | + | func (m *tokenBase) Pos() Pos { return m.pos } | |
| 79 | + | func (m *tokenBase) Definition() *DefinitionInfo { return m.def } | |
| 80 | + | func (m *tokenBase) base() *tokenBase { return m } | |
| 81 | + | ||
| 82 | + | // Origin is where a token comes from; the zero value is a token made by the | |
| 83 | + | // program. | |
| 84 | + | type Origin struct { | |
| 85 | + | File string | |
| 86 | + | Pos Pos | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func mk(o Origin) tokenBase { return tokenBase{file: o.File, pos: o.Pos} } | |
| 90 | + | ||
| 91 | + | // String is a string literal. | |
| 92 | + | type String struct { | |
| 93 | + | tokenBase | |
| 94 | + | Value string | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // Number is a number literal. | |
| 98 | + | type Number struct { | |
| 99 | + | tokenBase | |
| 100 | + | Value float64 | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | // Boolean is a true or false literal. | |
| 104 | + | type Boolean struct { | |
| 105 | + | tokenBase | |
| 106 | + | Value bool | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // Null is a null literal. | |
| 110 | + | type Null struct{ tokenBase } | |
| 111 | + | ||
| 112 | + | // Sequence is a list of tokens. | |
| 113 | + | type Sequence struct { | |
| 114 | + | tokenBase | |
| 115 | + | Items []Token | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | // Pair is a key and its value in a [Mapping]. The key is a scalar: a | |
| 119 | + | // [*String] or an expression. | |
| 120 | + | type Pair struct { | |
| 121 | + | Key, Value Token | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // Mapping is an ordered list of key-value pairs. Duplicate keys are rejected | |
| 125 | + | // when reading, comparing case-insensitively. | |
| 126 | + | type Mapping struct { | |
| 127 | + | tokenBase | |
| 128 | + | Pairs []Pair | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | // BasicExpression is a ${{ }} expression. Expression is the text between the | |
| 132 | + | // braces, trimmed, or a format() call when a string mixes text and | |
| 133 | + | // expressions. | |
| 134 | + | type BasicExpression struct { | |
| 135 | + | tokenBase | |
| 136 | + | Expression string | |
| 137 | + | // Original holds the individual expressions when Expression is the | |
| 138 | + | // format() call built from a string with several parts. | |
| 139 | + | Original []*BasicExpression | |
| 140 | + | // Source is the unprocessed text of the value the token was read from. | |
| 141 | + | Source string | |
| 142 | + | ||
| 143 | + | node expr.Node | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | // InsertExpression is the ${{ insert }} directive. | |
| 147 | + | type InsertExpression struct{ tokenBase } | |
| 148 | + | ||
| 149 | + | func (*String) Kind() Kind { return KindString } | |
| 150 | + | func (*Number) Kind() Kind { return KindNumber } | |
| 151 | + | func (*Boolean) Kind() Kind { return KindBoolean } | |
| 152 | + | func (*Null) Kind() Kind { return KindNull } | |
| 153 | + | func (*Sequence) Kind() Kind { return KindSequence } | |
| 154 | + | func (*Mapping) Kind() Kind { return KindMapping } | |
| 155 | + | func (*BasicExpression) Kind() Kind { return KindBasicExpression } | |
| 156 | + | func (*InsertExpression) Kind() Kind { return KindInsertExpression } | |
| 157 | + | ||
| 158 | + | func (t *String) String() string { return t.Value } | |
| 159 | + | func (t *Number) String() string { return JSNumberString(t.Value) } | |
| 160 | + | func (*Null) String() string { return "" } | |
| 161 | + | func (*Sequence) String() string { return "" } | |
| 162 | + | func (*Mapping) String() string { return "" } | |
| 163 | + | func (t *Boolean) String() string { return boolString(t.Value) } | |
| 164 | + | ||
| 165 | + | func (t *BasicExpression) String() string { return "${{ " + t.Expression + " }}" } | |
| 166 | + | func (*InsertExpression) String() string { return "${{ insert }}" } | |
| 167 | + | ||
| 168 | + | func boolString(b bool) string { | |
| 169 | + | if b { | |
| 170 | + | return "true" | |
| 171 | + | } | |
| 172 | + | return "false" | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | // NewString returns a string token. | |
| 176 | + | func NewString(o Origin, v string) *String { return &String{tokenBase: mk(o), Value: v} } | |
| 177 | + | ||
| 178 | + | // NewNumber returns a number token. | |
| 179 | + | func NewNumber(o Origin, v float64) *Number { return &Number{tokenBase: mk(o), Value: v} } | |
| 180 | + | ||
| 181 | + | // NewBoolean returns a boolean token. | |
| 182 | + | func NewBoolean(o Origin, v bool) *Boolean { return &Boolean{tokenBase: mk(o), Value: v} } | |
| 183 | + | ||
| 184 | + | // NewNull returns a null token. | |
| 185 | + | func NewNull(o Origin) *Null { return &Null{tokenBase: mk(o)} } | |
| 186 | + | ||
| 187 | + | // NewSequence returns a sequence token holding items. | |
| 188 | + | func NewSequence(o Origin, items ...Token) *Sequence { | |
| 189 | + | return &Sequence{tokenBase: mk(o), Items: items} | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | // NewMapping returns an empty mapping token. | |
| 193 | + | func NewMapping(o Origin) *Mapping { return &Mapping{tokenBase: mk(o)} } | |
| 194 | + | ||
| 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. | |
| 198 | + | func NewExpression(o Origin, expression string, def *DefinitionInfo) *BasicExpression { | |
| 199 | + | t := &BasicExpression{tokenBase: mk(o), Expression: expression} | |
| 200 | + | t.def = def | |
| 201 | + | return t | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | // Add appends a pair. | |
| 205 | + | func (m *Mapping) Add(key, value Token) { m.Pairs = append(m.Pairs, Pair{key, value}) } | |
| 206 | + | ||
| 207 | + | // Find returns the value of the first pair whose key's text is exactly key, | |
| 208 | + | // or nil. | |
| 209 | + | func (m *Mapping) Find(key string) Token { | |
| 210 | + | for _, p := range m.Pairs { | |
| 211 | + | if p.Key.String() == key { | |
| 212 | + | return p.Value | |
| 213 | + | } | |
| 214 | + | } | |
| 215 | + | return nil | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | // IsScalar reports whether t is neither a sequence nor a mapping. | |
| 219 | + | func IsScalar(t Token) bool { | |
| 220 | + | switch t.Kind() { | |
| 221 | + | case KindSequence, KindMapping: | |
| 222 | + | return false | |
| 223 | + | } | |
| 224 | + | return true | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | // IsLiteral reports whether t is a string, number, boolean or null. | |
| 228 | + | func IsLiteral(t Token) bool { | |
| 229 | + | switch t.Kind() { | |
| 230 | + | case KindString, KindNumber, KindBoolean, KindNull: | |
| 231 | + | return true | |
| 232 | + | } | |
| 233 | + | return false | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | // IsExpression reports whether t is a basic or insert expression. | |
| 237 | + | func IsExpression(t Token) bool { | |
| 238 | + | k := t.Kind() | |
| 239 | + | return k == KindBasicExpression || k == KindInsertExpression | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | // Walk calls fn for t and for every token below it, depth first, mapping keys | |
| 243 | + | // included. It stops descending into a token when fn returns false. | |
| 244 | + | func Walk(t Token, fn func(Token) bool) { | |
| 245 | + | if t == nil || !fn(t) { | |
| 246 | + | return | |
| 247 | + | } | |
| 248 | + | switch t := t.(type) { | |
| 249 | + | case *Sequence: | |
| 250 | + | for _, it := range t.Items { | |
| 251 | + | Walk(it, fn) | |
| 252 | + | } | |
| 253 | + | case *Mapping: | |
| 254 | + | for _, p := range t.Pairs { | |
| 255 | + | Walk(p.Key, fn) | |
| 256 | + | Walk(p.Value, fn) | |
| 257 | + | } | |
| 258 | + | } | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | // HasExpression reports whether t, or anything below it, is an expression. | |
| 262 | + | func HasExpression(t Token) bool { | |
| 263 | + | found := false | |
| 264 | + | Walk(t, func(x Token) bool { | |
| 265 | + | if IsExpression(x) { | |
| 266 | + | found = true | |
| 267 | + | } | |
| 268 | + | return !found | |
| 269 | + | }) | |
| 270 | + | return found | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | func trimDisplay(s string) string { | |
| 274 | + | s = strings.TrimLeft(s, " \t\r\n\v\f") | |
| 275 | + | if i := strings.IndexAny(s, "\r\n"); i >= 0 { | |
| 276 | + | s = s[:i] | |
| 277 | + | } | |
| 278 | + | return s | |
| 279 | + | } | |
| @@ -0,0 +1,279 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package template | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "strings" | |||
| 7 | + | ||||
| 8 | + | "bnema.dev/ermite-actions/expr" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // Kind is the type of a [Token], numbered as in the reference. | |||
| 12 | + | type Kind int | |||
| 13 | + | ||||
| 14 | + | // The token kinds. | |||
| 15 | + | const ( | |||
| 16 | + | KindString Kind = iota | |||
| 17 | + | KindSequence | |||
| 18 | + | KindMapping | |||
| 19 | + | KindBasicExpression | |||
| 20 | + | KindInsertExpression | |||
| 21 | + | KindBoolean | |||
| 22 | + | KindNumber | |||
| 23 | + | KindNull | |||
| 24 | + | ) | |||
| 25 | + | ||||
| 26 | + | // String returns the reference's name for the kind, such as "StringToken". | |||
| 27 | + | func (k Kind) String() string { | |||
| 28 | + | switch k { | |||
| 29 | + | case KindString: | |||
| 30 | + | return "StringToken" | |||
| 31 | + | case KindSequence: | |||
| 32 | + | return "SequenceToken" | |||
| 33 | + | case KindMapping: | |||
| 34 | + | return "MappingToken" | |||
| 35 | + | case KindBasicExpression: | |||
| 36 | + | return "BasicExpressionToken" | |||
| 37 | + | case KindInsertExpression: | |||
| 38 | + | return "InsertExpressionToken" | |||
| 39 | + | case KindBoolean: | |||
| 40 | + | return "BooleanToken" | |||
| 41 | + | case KindNumber: | |||
| 42 | + | return "NumberToken" | |||
| 43 | + | case KindNull: | |||
| 44 | + | return "NullToken" | |||
| 45 | + | } | |||
| 46 | + | return "unknown" | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | // Pos is a position in a source file. Line and Col are 1-based; the zero | |||
| 50 | + | // value means the position is unknown (a token made by the program). | |||
| 51 | + | type Pos struct{ Line, Col int } | |||
| 52 | + | ||||
| 53 | + | // Token is a node of a template: a scalar, a sequence or a mapping. The | |||
| 54 | + | // concrete types are [*String], [*Number], [*Boolean], [*Null], | |||
| 55 | + | // [*Sequence], [*Mapping], [*BasicExpression] and [*InsertExpression]. | |||
| 56 | + | type Token interface { | |||
| 57 | + | // Kind reports the type of the token. | |||
| 58 | + | Kind() Kind | |||
| 59 | + | // File is the name of the file the token was read from, or "". | |||
| 60 | + | File() string | |||
| 61 | + | // Pos is where the token starts in File. | |||
| 62 | + | Pos() Pos | |||
| 63 | + | // Definition is the schema definition the token was read against, or nil. | |||
| 64 | + | Definition() *DefinitionInfo | |||
| 65 | + | // String is the text the reference shows in messages: the value of a | |||
| 66 | + | // literal, "${{ expr }}" for an expression. | |||
| 67 | + | String() string | |||
| 68 | + | base() *tokenBase | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | type tokenBase struct { | |||
| 72 | + | file string | |||
| 73 | + | pos Pos | |||
| 74 | + | def *DefinitionInfo | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func (m *tokenBase) File() string { return m.file } | |||
| 78 | + | func (m *tokenBase) Pos() Pos { return m.pos } | |||
| 79 | + | func (m *tokenBase) Definition() *DefinitionInfo { return m.def } | |||
| 80 | + | func (m *tokenBase) base() *tokenBase { return m } | |||
| 81 | + | ||||
| 82 | + | // Origin is where a token comes from; the zero value is a token made by the | |||
| 83 | + | // program. | |||
| 84 | + | type Origin struct { | |||
| 85 | + | File string | |||
| 86 | + | Pos Pos | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func mk(o Origin) tokenBase { return tokenBase{file: o.File, pos: o.Pos} } | |||
| 90 | + | ||||
| 91 | + | // String is a string literal. | |||
| 92 | + | type String struct { | |||
| 93 | + | tokenBase | |||
| 94 | + | Value string | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | // Number is a number literal. | |||
| 98 | + | type Number struct { | |||
| 99 | + | tokenBase | |||
| 100 | + | Value float64 | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | // Boolean is a true or false literal. | |||
| 104 | + | type Boolean struct { | |||
| 105 | + | tokenBase | |||
| 106 | + | Value bool | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | // Null is a null literal. | |||
| 110 | + | type Null struct{ tokenBase } | |||
| 111 | + | ||||
| 112 | + | // Sequence is a list of tokens. | |||
| 113 | + | type Sequence struct { | |||
| 114 | + | tokenBase | |||
| 115 | + | Items []Token | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | // Pair is a key and its value in a [Mapping]. The key is a scalar: a | |||
| 119 | + | // [*String] or an expression. | |||
| 120 | + | type Pair struct { | |||
| 121 | + | Key, Value Token | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | // Mapping is an ordered list of key-value pairs. Duplicate keys are rejected | |||
| 125 | + | // when reading, comparing case-insensitively. | |||
| 126 | + | type Mapping struct { | |||
| 127 | + | tokenBase | |||
| 128 | + | Pairs []Pair | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | // BasicExpression is a ${{ }} expression. Expression is the text between the | |||
| 132 | + | // braces, trimmed, or a format() call when a string mixes text and | |||
| 133 | + | // expressions. | |||
| 134 | + | type BasicExpression struct { | |||
| 135 | + | tokenBase | |||
| 136 | + | Expression string | |||
| 137 | + | // Original holds the individual expressions when Expression is the | |||
| 138 | + | // format() call built from a string with several parts. | |||
| 139 | + | Original []*BasicExpression | |||
| 140 | + | // Source is the unprocessed text of the value the token was read from. | |||
| 141 | + | Source string | |||
| 142 | + | ||||
| 143 | + | node expr.Node | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | // InsertExpression is the ${{ insert }} directive. | |||
| 147 | + | type InsertExpression struct{ tokenBase } | |||
| 148 | + | ||||
| 149 | + | func (*String) Kind() Kind { return KindString } | |||
| 150 | + | func (*Number) Kind() Kind { return KindNumber } | |||
| 151 | + | func (*Boolean) Kind() Kind { return KindBoolean } | |||
| 152 | + | func (*Null) Kind() Kind { return KindNull } | |||
| 153 | + | func (*Sequence) Kind() Kind { return KindSequence } | |||
| 154 | + | func (*Mapping) Kind() Kind { return KindMapping } | |||
| 155 | + | func (*BasicExpression) Kind() Kind { return KindBasicExpression } | |||
| 156 | + | func (*InsertExpression) Kind() Kind { return KindInsertExpression } | |||
| 157 | + | ||||
| 158 | + | func (t *String) String() string { return t.Value } | |||
| 159 | + | func (t *Number) String() string { return JSNumberString(t.Value) } | |||
| 160 | + | func (*Null) String() string { return "" } | |||
| 161 | + | func (*Sequence) String() string { return "" } | |||
| 162 | + | func (*Mapping) String() string { return "" } | |||
| 163 | + | func (t *Boolean) String() string { return boolString(t.Value) } | |||
| 164 | + | ||||
| 165 | + | func (t *BasicExpression) String() string { return "${{ " + t.Expression + " }}" } | |||
| 166 | + | func (*InsertExpression) String() string { return "${{ insert }}" } | |||
| 167 | + | ||||
| 168 | + | func boolString(b bool) string { | |||
| 169 | + | if b { | |||
| 170 | + | return "true" | |||
| 171 | + | } | |||
| 172 | + | return "false" | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | // NewString returns a string token. | |||
| 176 | + | func NewString(o Origin, v string) *String { return &String{tokenBase: mk(o), Value: v} } | |||
| 177 | + | ||||
| 178 | + | // NewNumber returns a number token. | |||
| 179 | + | func NewNumber(o Origin, v float64) *Number { return &Number{tokenBase: mk(o), Value: v} } | |||
| 180 | + | ||||
| 181 | + | // NewBoolean returns a boolean token. | |||
| 182 | + | func NewBoolean(o Origin, v bool) *Boolean { return &Boolean{tokenBase: mk(o), Value: v} } | |||
| 183 | + | ||||
| 184 | + | // NewNull returns a null token. | |||
| 185 | + | func NewNull(o Origin) *Null { return &Null{tokenBase: mk(o)} } | |||
| 186 | + | ||||
| 187 | + | // NewSequence returns a sequence token holding items. | |||
| 188 | + | func NewSequence(o Origin, items ...Token) *Sequence { | |||
| 189 | + | return &Sequence{tokenBase: mk(o), Items: items} | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | // NewMapping returns an empty mapping token. | |||
| 193 | + | func NewMapping(o Origin) *Mapping { return &Mapping{tokenBase: mk(o)} } | |||
| 194 | + | ||||
| 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. | |||
| 198 | + | func NewExpression(o Origin, expression string, def *DefinitionInfo) *BasicExpression { | |||
| 199 | + | t := &BasicExpression{tokenBase: mk(o), Expression: expression} | |||
| 200 | + | t.def = def | |||
| 201 | + | return t | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | // Add appends a pair. | |||
| 205 | + | func (m *Mapping) Add(key, value Token) { m.Pairs = append(m.Pairs, Pair{key, value}) } | |||
| 206 | + | ||||
| 207 | + | // Find returns the value of the first pair whose key's text is exactly key, | |||
| 208 | + | // or nil. | |||
| 209 | + | func (m *Mapping) Find(key string) Token { | |||
| 210 | + | for _, p := range m.Pairs { | |||
| 211 | + | if p.Key.String() == key { | |||
| 212 | + | return p.Value | |||
| 213 | + | } | |||
| 214 | + | } | |||
| 215 | + | return nil | |||
| 216 | + | } | |||
| 217 | + | ||||
| 218 | + | // IsScalar reports whether t is neither a sequence nor a mapping. | |||
| 219 | + | func IsScalar(t Token) bool { | |||
| 220 | + | switch t.Kind() { | |||
| 221 | + | case KindSequence, KindMapping: | |||
| 222 | + | return false | |||
| 223 | + | } | |||
| 224 | + | return true | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | // IsLiteral reports whether t is a string, number, boolean or null. | |||
| 228 | + | func IsLiteral(t Token) bool { | |||
| 229 | + | switch t.Kind() { | |||
| 230 | + | case KindString, KindNumber, KindBoolean, KindNull: | |||
| 231 | + | return true | |||
| 232 | + | } | |||
| 233 | + | return false | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | // IsExpression reports whether t is a basic or insert expression. | |||
| 237 | + | func IsExpression(t Token) bool { | |||
| 238 | + | k := t.Kind() | |||
| 239 | + | return k == KindBasicExpression || k == KindInsertExpression | |||
| 240 | + | } | |||
| 241 | + | ||||
| 242 | + | // Walk calls fn for t and for every token below it, depth first, mapping keys | |||
| 243 | + | // included. It stops descending into a token when fn returns false. | |||
| 244 | + | func Walk(t Token, fn func(Token) bool) { | |||
| 245 | + | if t == nil || !fn(t) { | |||
| 246 | + | return | |||
| 247 | + | } | |||
| 248 | + | switch t := t.(type) { | |||
| 249 | + | case *Sequence: | |||
| 250 | + | for _, it := range t.Items { | |||
| 251 | + | Walk(it, fn) | |||
| 252 | + | } | |||
| 253 | + | case *Mapping: | |||
| 254 | + | for _, p := range t.Pairs { | |||
| 255 | + | Walk(p.Key, fn) | |||
| 256 | + | Walk(p.Value, fn) | |||
| 257 | + | } | |||
| 258 | + | } | |||
| 259 | + | } | |||
| 260 | + | ||||
| 261 | + | // HasExpression reports whether t, or anything below it, is an expression. | |||
| 262 | + | func HasExpression(t Token) bool { | |||
| 263 | + | found := false | |||
| 264 | + | Walk(t, func(x Token) bool { | |||
| 265 | + | if IsExpression(x) { | |||
| 266 | + | found = true | |||
| 267 | + | } | |||
| 268 | + | return !found | |||
| 269 | + | }) | |||
| 270 | + | return found | |||
| 271 | + | } | |||
| 272 | + | ||||
| 273 | + | func trimDisplay(s string) string { | |||
| 274 | + | s = strings.TrimLeft(s, " \t\r\n\v\f") | |||
| 275 | + | if i := strings.IndexAny(s, "\r\n"); i >= 0 { | |||
| 276 | + | s = s[:i] | |||
| 277 | + | } | |||
| 278 | + | return s | |||
| 279 | + | } | |||