v0.2.0 43088fd

matchers.go RawHistoryFolder

1// SPDX-License-Identifier: MIT
2
3package executor
4
5import (
6	"encoding/json"
7	"errors"
8	"fmt"
9	"strings"
10	"sync"
11	"time"
12
13	"github.com/dlclark/regexp2"
14	"github.com/dlclark/regexp2/syntax"
15
16	"bnema.dev/ermite-actions/expr"
17)
18
19// Problem matchers: a port of Runner.Worker/IssueMatcher.cs (actions/runner,
20// MIT). The regular expressions are .NET's with RegexOptions.CultureInvariant |
21// RegexOptions.ECMAScript (IssueMatcher.cs:450); regexp2 is the .NET engine in
22// Go (RE2 has no lookarounds or backreferences, which real matchers use).
23
24// optInt is an int? of the JSON files: a number, a numeric string or null.
25type optInt struct {
26	set bool
27	v   int
28}
29
30func (o *optInt) UnmarshalJSON(b []byte) error {
31	s := strings.TrimSpace(string(b))
32	if s == "null" {
33		return nil
34	}
35	s = strings.Trim(s, `"`)
36	var n int
37	if _, err := fmt.Sscanf(s, "%d", &n); err != nil {
38		return ghErrorf("Error converting value %s to type 'System.Nullable`1[System.Int32]'.", string(b))
39	}
40	o.set, o.v = true, n
41	return nil
42}
43
44// optBool is a bool of the JSON files: a boolean or the string "true"/"false".
45type optBool bool
46
47func (o *optBool) UnmarshalJSON(b []byte) error {
48	switch strings.ToLower(strings.Trim(strings.TrimSpace(string(b)), `"`)) {
49	case "true":
50		*o = true
51	case "false", "null":
52		*o = false
53	default:
54		return ghErrorf("Error converting value %s to type 'System.Boolean'.", string(b))
55	}
56	return nil
57}
58
59// patternConfig is IssuePatternConfig.
60type patternConfig struct {
61	File     optInt  `json:"file"`
62	Line     optInt  `json:"line"`
63	Column   optInt  `json:"column"`
64	Severity optInt  `json:"severity"`
65	Code     optInt  `json:"code"`
66	Message  optInt  `json:"message"`
67	FromPath optInt  `json:"fromPath"`
68	Loop     optBool `json:"loop"`
69	Regexp   string  `json:"regexp"`
70}
71
72// matcherConfig is IssueMatcherConfig.
73type matcherConfig struct {
74	Owner    string          `json:"owner"`
75	Severity string          `json:"severity"`
76	Patterns []patternConfig `json:"pattern"`
77	FromPath string          `json:"fromPath"`
78}
79
80// matchersConfig is IssueMatchersConfig.
81type matchersConfig struct {
82	Matchers []*matcherConfig `json:"problemMatcher"`
83}
84
85// parseMatchersConfig reads the JSON of a matchers file.
86func parseMatchersConfig(data []byte) (*matchersConfig, error) {
87	data = []byte(strings.TrimPrefix(string(data), "\xef\xbb\xbf"))
88	var c matchersConfig
89	if len(strings.TrimSpace(string(data))) == 0 {
90		return nil, nil // StringUtil.ConvertFromJson of an empty text is null
91	}
92	// IOUtil.LoadObject reads with Json.NET: its reader decides what is valid
93	// (single quotes, trailing commas...) and words the errors. The value it
94	// accepts is turned back into strict JSON for the structure below.
95	v, err := expr.FromJSON(data)
96	if err != nil {
97		return nil, msgError(err.Error())
98	}
99	if err := json.Unmarshal([]byte(expr.ToJSON(v)), &c); err != nil {
100		return nil, err
101	}
102	return &c, nil
103}
104
105// regexOptions is IssuePatternConfig.RegexOptions. regexp2 has no
106// CultureInvariant: its matching is culture independent.
107const regexOptions = regexp2.ECMAScript
108
109func compilePattern(p string) (*regexp2.Regexp, error) {
110	re, err := regexp2.Compile(p, regexOptions)
111	if err != nil {
112		return nil, dotnetRegexError(p, err)
113	}
114	return re, nil
115}
116
117// dotnetRegexError words a pattern that does not compile like .NET's
118// RegexParseException ("Invalid pattern '<p>' at offset <n>. <reason>"), for the
119// failures whose offset is known: those at the end of the pattern. Others keep
120// the text of the engine.
121func dotnetRegexError(p string, err error) error {
122	var se *syntax.Error
123	if !errors.As(err, &se) {
124		return err
125	}
126	var reason string
127	switch se.Code {
128	case syntax.ErrMissingParen:
129		reason = "Not enough )'s."
130	case syntax.ErrUnterminatedBracket:
131		reason = "Unterminated [] set."
132	case syntax.ErrIllegalEndEscape:
133		reason = "Illegal \\ at end of pattern."
134	default:
135		return err
136	}
137	return ghErrorf("Invalid pattern '%s' at offset %d. %s", p, utf16Len(p), reason)
138}
139
140// validate is IssueMatchersConfig.Validate (IssueMatcher.cs:262). The error
141// texts are the runner's.
142func (c *matchersConfig) validate() error {
143	owners := map[string]bool{}
144	for _, m := range c.Matchers {
145		if m == nil {
146			m = &matcherConfig{}
147		}
148		if err := m.validate(); err != nil {
149			return err
150		}
151		lo := strings.ToLower(m.Owner)
152		if owners[lo] {
153			return ghErrorf("Duplicate owner name '%s'", m.Owner)
154		}
155		owners[lo] = true
156	}
157	return nil
158}
159
160// validate is IssueMatcherConfig.Validate (IssueMatcher.cs:376).
161func (m *matcherConfig) validate() error {
162	if m.Owner == "" {
163		return ghErrorf("Owner must not be empty")
164	}
165	switch strings.ToUpper(m.Severity) {
166	case "", "ERROR", "WARNING", "NOTICE":
167	default:
168		return ghErrorf("Matcher '%s' contains unexpected default severity '%s'", m.Owner, m.Severity)
169	}
170	if len(m.Patterns) == 0 {
171		return ghErrorf("Matcher '%s' does not contain any patterns", m.Owner)
172	}
173	var file, line, column, severity, code, message, fromPath *int
174	for i := range m.Patterns {
175		isFirst, isLast := i == 0, i == len(m.Patterns)-1
176		if err := m.Patterns[i].validate(isFirst, isLast, &file, &line, &column, &severity, &code, &message, &fromPath); err != nil {
177			return err
178		}
179	}
180	if message == nil {
181		return ghErrorf("At least one pattern must set 'message'")
182	}
183	return nil
184}
185
186// validate is IssuePatternConfig.Validate (IssueMatcher.cs:495).
187func (p *patternConfig) validate(isFirst, isLast bool, file, line, column, severity, code, message, fromPath **int) error {
188	if bool(p.Loop) && (isFirst || !isLast) {
189		return ghErrorf("Only the last pattern in a multiline matcher may set 'loop'")
190	}
191	if bool(p.Loop) && !p.Message.set {
192		return ghErrorf("The loop pattern must set 'message'")
193	}
194	re, err := compilePattern(p.Regexp)
195	if err != nil {
196		return err
197	}
198	groupCount := len(re.GetGroupNumbers())
199	for _, f := range []struct {
200		name string
201		v    optInt
202		to   **int
203	}{
204		{"file", p.File, file}, {"line", p.Line, line}, {"column", p.Column, column},
205		{"severity", p.Severity, severity}, {"code", p.Code, code}, {"message", p.Message, message},
206		{"fromPath", p.FromPath, fromPath},
207	} {
208		if !f.v.set {
209			continue
210		}
211		if *f.to != nil {
212			return ghErrorf("The property '%s' is set twice", f.name)
213		}
214		if f.v.v < 0 || f.v.v >= groupCount {
215			return ghErrorf("The property '%s' is set to %d which is out of range", f.name, f.v.v)
216		}
217		n := f.v.v
218		*f.to = &n
219	}
220	return nil
221}
222
223// errMatchTimeout is the RegexMatchTimeoutException; its message is .NET's.
224var errMatchTimeout = msgError("The Regex engine has timed out while trying to match a pattern to an input string. This can occur for many reasons, including very large inputs or excessive backtracking caused by nested quantifiers, back-references and other factors.")
225
226// issuePattern is IssuePattern.
227type issuePattern struct {
228	file, line, column, severity, code, message, fromPath optInt
229	loop                                                  bool
230	re                                                    *regexp2.Regexp
231}
232
233// nstr is a string that can be null, as the values of IssueMatch are: a group
234// that the pattern does not name is null, a group that matched nothing is "".
235type nstr struct {
236	s  string
237	ok bool
238}
239
240func (n nstr) or(o nstr) nstr {
241	if n.ok {
242		return n
243	}
244	return o
245}
246
247// issueMatch is IssueMatch.
248type issueMatch struct {
249	file, line, column, severity, code, message, fromPath nstr
250}
251
252// issueMatcher is IssueMatcher: a matcher config with the state of the
253// multi-line patterns.
254type issueMatcher struct {
255	owner, defaultSeverity, defaultFromPath string
256	patterns                                []*issuePattern
257	state                                   []*issueMatch
258}
259
260func newIssueMatcher(c *matcherConfig, timeout time.Duration) (*issueMatcher, error) {
261	m := &issueMatcher{owner: c.Owner, defaultSeverity: c.Severity, defaultFromPath: c.FromPath}
262	for _, pc := range c.Patterns {
263		re, err := compilePattern(pc.Regexp)
264		if err != nil {
265			return nil, err
266		}
267		re.MatchTimeout = timeout
268		m.patterns = append(m.patterns, &issuePattern{
269			file: pc.File, line: pc.Line, column: pc.Column, severity: pc.Severity, code: pc.Code,
270			message: pc.Message, fromPath: pc.FromPath, loop: bool(pc.Loop), re: re,
271		})
272	}
273	m.reset()
274	return m, nil
275}
276
277func (m *issueMatcher) reset() { m.state = make([]*issueMatch, max(len(m.patterns)-1, 0)) }
278
279// groupValue is IssueMatch.GetValue: the text of a group, null when the
280// pattern does not name one or its number is out of range.
281func groupValue(match *regexp2.Match, idx optInt) nstr {
282	if !idx.set || idx.v >= match.GroupCount() {
283		return nstr{}
284	}
285	g := match.GroupByNumber(idx.v)
286	if g == nil {
287		return nstr{}
288	}
289	return nstr{g.String(), true}
290}
291
292func newIssueMatch(running *issueMatch, p *issuePattern, g *regexp2.Match, defSeverity, defFromPath string) *issueMatch {
293	r := running
294	if r == nil {
295		r = &issueMatch{}
296	}
297	im := &issueMatch{
298		file:     r.file.or(groupValue(g, p.file)),
299		line:     r.line.or(groupValue(g, p.line)),
300		column:   r.column.or(groupValue(g, p.column)),
301		severity: r.severity.or(groupValue(g, p.severity)),
302		code:     r.code.or(groupValue(g, p.code)),
303		message:  r.message.or(groupValue(g, p.message)),
304		fromPath: r.fromPath.or(groupValue(g, p.fromPath)),
305	}
306	if im.severity.s == "" && defSeverity != "" {
307		im.severity = nstr{defSeverity, true}
308	}
309	if im.fromPath.s == "" && defFromPath != "" {
310		im.fromPath = nstr{defFromPath, true}
311	}
312	return im
313}
314
315// match is IssueMatcher.Match. A timeout of the engine is returned as
316// errMatchTimeout.
317func (m *issueMatcher) match(line string) (*issueMatch, error) {
318	find := func(p *issuePattern) (*regexp2.Match, error) {
319		mt, err := p.re.FindStringMatch(line)
320		if err != nil {
321			return nil, errMatchTimeout
322		}
323		return mt, nil
324	}
325	if len(m.patterns) == 1 {
326		p := m.patterns[0]
327		mt, err := find(p)
328		if err != nil || mt == nil {
329			return nil, err
330		}
331		return newIssueMatch(nil, p, mt, m.defaultSeverity, m.defaultFromPath), nil
332	}
333	for i := len(m.patterns) - 1; i >= 0; i-- {
334		var running *issueMatch
335		if i > 0 {
336			running = m.state[i-1]
337		}
338		if i != 0 && running == nil {
339			continue
340		}
341		p := m.patterns[i]
342		isLast := i == len(m.patterns)-1
343		mt, err := find(p)
344		if err != nil {
345			return nil, err
346		}
347		if mt != nil {
348			if isLast {
349				if p.loop {
350					m.reset()
351					m.state[i-1] = running
352				} else {
353					m.reset()
354				}
355				return newIssueMatch(running, p, mt, m.defaultSeverity, m.defaultFromPath), nil
356			}
357			m.state[i] = newIssueMatch(running, p, mt, "", "")
358		} else if isLast {
359			m.state[i-1] = nil
360		} else {
361			m.state[i] = nil
362		}
363	}
364	return nil, nil
365}
366
367// matcherRoot holds the matchers of the job (ExecutionContext._matchers, on the
368// root context) and tells the output managers of the steps that run when they
369// change (OnMatcherChanged).
370type matcherRoot struct {
371	mu       sync.Mutex
372	configs  []*matcherConfig
373	handlers map[*outputManager]struct{}
374}
375
376// snapshot returns the matchers now.
377func (r *matcherRoot) snapshot() []*matcherConfig {
378	r.mu.Lock()
379	defer r.mu.Unlock()
380	return append([]*matcherConfig(nil), r.configs...)
381}
382
383func (r *matcherRoot) subscribe(o *outputManager) {
384	r.mu.Lock()
385	defer r.mu.Unlock()
386	if r.handlers == nil {
387		r.handlers = map[*outputManager]struct{}{}
388	}
389	r.handlers[o] = struct{}{}
390}
391
392func (r *matcherRoot) unsubscribe(o *outputManager) {
393	r.mu.Lock()
394	defer r.mu.Unlock()
395	delete(r.handlers, o)
396}
397
398// add is ExecutionContext.AddMatchers (:1173): the new matchers go first, those
399// of the same owner (ignoring case) are replaced. It returns the debug line.
400func (r *matcherRoot) add(c *matchersConfig) string {
401	r.mu.Lock()
402	defer r.mu.Unlock()
403	owners := map[string]bool{}
404	var next []*matcherConfig
405	for _, m := range c.Matchers {
406		owners[strings.ToLower(m.Owner)] = true
407		next = append(next, m)
408	}
409	for _, m := range r.configs {
410		if !owners[strings.ToLower(m.Owner)] {
411			next = append(next, m)
412		}
413	}
414	r.configs = next
415	var names []string
416	for _, m := range c.Matchers {
417		names = append(names, "'"+m.Owner+"'")
418		for o := range r.handlers {
419			o.matcherChanged(m)
420		}
421	}
422	return "Added matchers: " + strings.Join(names, ", ") + ". Problem matchers scan action output for known warning or error strings and report these inline."
423}
424
425// remove is ExecutionContext.RemoveMatchers (:1212). It returns the debug line.
426func (r *matcherRoot) remove(owners []string) string {
427	r.mu.Lock()
428	defer r.mu.Unlock()
429	distinct := map[string]bool{}
430	for _, o := range owners {
431		distinct[strings.ToLower(o)] = true
432	}
433	var kept, removed []*matcherConfig
434	for _, m := range r.configs {
435		if distinct[strings.ToLower(m.Owner)] {
436			removed = append(removed, m)
437		} else {
438			kept = append(kept, m)
439		}
440	}
441	r.configs = kept
442	var names []string
443	for _, m := range removed {
444		names = append(names, "'"+m.Owner+"'")
445		for o := range r.handlers {
446			o.matcherChanged(&matcherConfig{Owner: m.Owner})
447		}
448	}
449	return "Removed matchers: " + strings.Join(names, ", ")
450}

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 4

Tags 11

↑↓ moveenter openesc close