main 98594d0

scalar.go RawHistoryFolder

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}

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