1// SPDX-License-Identifier: MIT
2
3package template
4
5import (
6 "fmt"
7 "strings"
8 "unicode"
9
10 "bnema.dev/ermite-actions/expr"
11)
12
13const (
14 openExpression = "${{"
15 insertDirective = "insert"
16)
17
18// parseScalar splits a string that holds ${{ }} expressions into literal
19// text and expression tokens. Strings without an expression, other scalars
20// and strings with an error in an expression are returned as they are.
21//
22// The reference works on the raw source of a multi-line YAML scalar, which
23// keeps the indentation of a block scalar; this port always works on the
24// value, as the runner does.
25func (r *reader) parseScalar(_ *rnode, token Token, def *DefinitionInfo) Token {
26 s, ok := token.(*String)
27 if r.noExpressions || !ok || s.Value == "" {
28 return token
29 }
30 raw := s.Value
31 startExpression := strings.Index(raw, openExpression)
32 if startExpression < 0 {
33 return token
34 }
35 var segments []Token
36 encounteredError := false
37 i := 0
38 for i < len(raw) {
39 switch {
40 case i == startExpression:
41 end := -1
42 inString := false
43 for i += len(openExpression); i < len(raw); i++ {
44 if raw[i] == '\'' {
45 inString = !inString
46 } else if !inString && raw[i] == '}' && raw[i-1] == '}' {
47 end = i
48 i++
49 break
50 }
51 }
52 if end < startExpression {
53 r.ctx.ErrorAt(token, "The expression is not closed. An unescaped ${{ sequence was found, but the closing }} sequence was not found.")
54 return token
55 }
56 rawExpression := raw[startExpression+len(openExpression) : end-1]
57 e := r.parseIntoExpressionToken(token, rawExpression, def)
58 if e == nil {
59 encounteredError = true
60 } else {
61 if _, isInsert := e.(*InsertExpression); isInsert && (startExpression != 0 || i < len(raw)) {
62 r.ctx.ErrorAt(token, "The directive 'insert' is not allowed in this context. Directives are not supported for expressions that are embedded within a string. Directives are only supported when the entire value is an expression.")
63 return token
64 }
65 segments = append(segments, e)
66 }
67 if j := strings.Index(raw[i:], openExpression); j >= 0 {
68 startExpression = i + j
69 } else {
70 startExpression = -1
71 }
72 case i < startExpression:
73 segments = r.addString(segments, token, raw[i:startExpression], s.def)
74 i = startExpression
75 default:
76 segments = r.addString(segments, token, raw[i:], s.def)
77 i = len(raw)
78 }
79 }
80 if encounteredError {
81 return token
82 }
83 if len(segments) == 1 {
84 if be, ok := segments[0].(*BasicExpression); ok {
85 if str, ok := expressionString(be.Expression); ok {
86 return &String{tokenBase: tokenBase{file: r.file, pos: token.Pos(), def: s.def}, Value: str}
87 }
88 }
89 return segments[0]
90 }
91 var format strings.Builder
92 var args strings.Builder
93 var originals []*BasicExpression
94 argIndex := 0
95 for _, seg := range segments {
96 if ss, ok := seg.(*String); ok {
97 text := strings.ReplaceAll(ss.Value, "'", "''")
98 text = strings.ReplaceAll(text, "{", "{{")
99 text = strings.ReplaceAll(text, "}", "}}")
100 format.WriteString(text)
101 continue
102 }
103 be := seg.(*BasicExpression)
104 fmt.Fprintf(&format, "{%d}", argIndex)
105 argIndex++
106 args.WriteString(", ")
107 args.WriteString(be.Expression)
108 originals = append(originals, be)
109 }
110 t := &BasicExpression{
111 tokenBase: tokenBase{file: r.file, pos: token.Pos(), def: def},
112 Expression: "format('" + format.String() + "'" + args.String() + ")",
113 Original: originals,
114 Source: raw,
115 }
116 t.node, t.parseErr = parseWith(t.Expression, def)
117 return t
118}
119
120func (r *reader) addString(segs []Token, at Token, value string, def *DefinitionInfo) []Token {
121 if n := len(segs); n > 0 {
122 if last, ok := segs[n-1].(*String); ok {
123 segs[n-1] = &String{tokenBase: tokenBase{file: r.file, pos: at.Pos(), def: def}, Value: last.Value + value}
124 return segs
125 }
126 }
127 return append(segs, &String{tokenBase: tokenBase{file: r.file, pos: at.Pos(), def: def}, Value: value})
128}
129
130func (r *reader) parseIntoExpressionToken(token Token, rawExpression string, def *DefinitionInfo) Token {
131 e, err := r.parseExpression(token, rawExpression, def)
132 if err != nil {
133 r.ctx.ErrorAt(token, err.Error())
134 return nil
135 }
136 return e
137}
138
139func (r *reader) parseExpression(token Token, value string, def *DefinitionInfo) (Token, error) {
140 trimmed := strings.TrimSpace(value)
141 if trimmed == "" {
142 return nil, message("An expression was expected")
143 }
144 isMatch, err := matchDirective(trimmed, insertDirective, 0)
145 if err != nil {
146 return nil, err
147 }
148 if isMatch {
149 return &InsertExpression{tokenBase{file: r.file, pos: token.Pos(), def: def}}, nil
150 }
151 node, err := parseWith(trimmed, def)
152 if err != nil {
153 return nil, err
154 }
155 src, _ := token.(*String)
156 t := &BasicExpression{
157 tokenBase: tokenBase{file: r.file, pos: token.Pos(), def: def},
158 Expression: trimmed,
159 node: node,
160 }
161 if src != nil {
162 t.Source = src.Value
163 }
164 return t, nil
165}
166
167// parseWith parses an expression against the contexts and functions allowed
168// by def. A nil def allows nothing.
169func parseWith(text string, def *DefinitionInfo) (expr.Node, error) {
170 var named []string
171 var funcs []expr.FuncInfo
172 if def != nil {
173 named, funcs = def.named, def.funcs
174 }
175 return expr.Parse(text, named, funcs)
176}
177
178// matchDirective reports whether trimmed is the directive followed by exactly
179// expectedParameters parameters. It is a literal port of the reference,
180// including the way it counts parameters.
181func matchDirective(trimmed, directive string, expectedParameters int) (bool, error) {
182 if !strings.HasPrefix(trimmed, directive) {
183 return false, nil
184 }
185 rest := trimmed[len(directive):]
186 if rest != "" {
187 c := []rune(rest)[0]
188 if !unicode.IsSpace(c) && c != '\ufeff' {
189 return false, nil
190 }
191 }
192 // The reference only counts the text after the last separator that is
193 // outside quotes and parentheses; its loop never counts earlier ones.
194 rs := []rune(trimmed)
195 startIndex := len([]rune(directive))
196 inString := false
197 parens := 0
198 for i := startIndex; i < len(rs); i++ {
199 c := rs[i]
200 switch {
201 case unicode.IsSpace(c) && !inString && parens == 0:
202 startIndex = i + 1
203 case c == '\'':
204 inString = !inString
205 case c == '(' && !inString:
206 parens++
207 case c == ')' && !inString:
208 parens--
209 }
210 }
211 parameters := 0
212 if startIndex < len(rs) {
213 parameters = 1
214 }
215 if expectedParameters != parameters {
216 return false, message(fmt.Sprintf("Exactly %d parameter(s) were expected following the directive '%s'. Actual parameter count: %d", expectedParameters, directive, parameters))
217 }
218 return true, nil
219}
220
221// expressionString returns the text of an expression that is a single quoted
222// string, such as ${{ '{{ literal }}' }}, which is how GitHub lets a workflow
223// contain a literal ${{.
224func expressionString(trimmed string) (string, bool) {
225 var out []byte
226 inString := false
227 for i := 0; i < len(trimmed); i++ {
228 c := trimmed[i]
229 switch {
230 case c == '\'':
231 inString = !inString
232 if inString && i != 0 {
233 out = append(out, c)
234 }
235 case !inString:
236 return "", false
237 default:
238 out = append(out, c)
239 }
240 }
241 return string(out), true
242}