detached 3694c60

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 @@
11 module bnema.dev/ermite-actions
22
33 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-actions1 module bnema.dev/ermite-actions
2 2
3 go 1.27.13 go 1.27.1
4+
5+require go.yaml.in/yaml/v3 v3.0.5 // indirect
A go.sum +2 -0 generated file: collapsed
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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 3

↑↓ moveenter openesc close