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}