// SPDX-License-Identifier: MIT
package executor
import (
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
"time"
"github.com/dlclark/regexp2"
"github.com/dlclark/regexp2/syntax"
"bnema.dev/ermite-actions/expr"
)
// Problem matchers: a port of Runner.Worker/IssueMatcher.cs (actions/runner,
// MIT). The regular expressions are .NET's with RegexOptions.CultureInvariant |
// RegexOptions.ECMAScript (IssueMatcher.cs:450); regexp2 is the .NET engine in
// Go (RE2 has no lookarounds or backreferences, which real matchers use).
// optInt is an int? of the JSON files: a number, a numeric string or null.
type optInt struct {
set bool
v int
}
func (o *optInt) UnmarshalJSON(b []byte) error {
s := strings.TrimSpace(string(b))
if s == "null" {
return nil
}
s = strings.Trim(s, `"`)
var n int
if _, err := fmt.Sscanf(s, "%d", &n); err != nil {
return ghErrorf("Error converting value %s to type 'System.Nullable`1[System.Int32]'.", string(b))
}
o.set, o.v = true, n
return nil
}
// optBool is a bool of the JSON files: a boolean or the string "true"/"false".
type optBool bool
func (o *optBool) UnmarshalJSON(b []byte) error {
switch strings.ToLower(strings.Trim(strings.TrimSpace(string(b)), `"`)) {
case "true":
*o = true
case "false", "null":
*o = false
default:
return ghErrorf("Error converting value %s to type 'System.Boolean'.", string(b))
}
return nil
}
// patternConfig is IssuePatternConfig.
type patternConfig struct {
File optInt `json:"file"`
Line optInt `json:"line"`
Column optInt `json:"column"`
Severity optInt `json:"severity"`
Code optInt `json:"code"`
Message optInt `json:"message"`
FromPath optInt `json:"fromPath"`
Loop optBool `json:"loop"`
Regexp string `json:"regexp"`
}
// matcherConfig is IssueMatcherConfig.
type matcherConfig struct {
Owner string `json:"owner"`
Severity string `json:"severity"`
Patterns []patternConfig `json:"pattern"`
FromPath string `json:"fromPath"`
}
// matchersConfig is IssueMatchersConfig.
type matchersConfig struct {
Matchers []*matcherConfig `json:"problemMatcher"`
}
// parseMatchersConfig reads the JSON of a matchers file.
func parseMatchersConfig(data []byte) (*matchersConfig, error) {
data = []byte(strings.TrimPrefix(string(data), "\xef\xbb\xbf"))
var c matchersConfig
if len(strings.TrimSpace(string(data))) == 0 {
return nil, nil // StringUtil.ConvertFromJson of an empty text is null
}
// IOUtil.LoadObject reads with Json.NET: its reader decides what is valid
// (single quotes, trailing commas...) and words the errors. The value it
// accepts is turned back into strict JSON for the structure below.
v, err := expr.FromJSON(data)
if err != nil {
return nil, msgError(err.Error())
}
if err := json.Unmarshal([]byte(expr.ToJSON(v)), &c); err != nil {
return nil, err
}
return &c, nil
}
// regexOptions is IssuePatternConfig.RegexOptions. regexp2 has no
// CultureInvariant: its matching is culture independent.
const regexOptions = regexp2.ECMAScript
func compilePattern(p string) (*regexp2.Regexp, error) {
re, err := regexp2.Compile(p, regexOptions)
if err != nil {
return nil, dotnetRegexError(p, err)
}
return re, nil
}
// dotnetRegexError words a pattern that does not compile like .NET's
// RegexParseException ("Invalid pattern '
' at offset . "), for the
// failures whose offset is known: those at the end of the pattern. Others keep
// the text of the engine.
func dotnetRegexError(p string, err error) error {
var se *syntax.Error
if !errors.As(err, &se) {
return err
}
var reason string
switch se.Code {
case syntax.ErrMissingParen:
reason = "Not enough )'s."
case syntax.ErrUnterminatedBracket:
reason = "Unterminated [] set."
case syntax.ErrIllegalEndEscape:
reason = "Illegal \\ at end of pattern."
default:
return err
}
return ghErrorf("Invalid pattern '%s' at offset %d. %s", p, utf16Len(p), reason)
}
// validate is IssueMatchersConfig.Validate (IssueMatcher.cs:262). The error
// texts are the runner's.
func (c *matchersConfig) validate() error {
owners := map[string]bool{}
for _, m := range c.Matchers {
if m == nil {
m = &matcherConfig{}
}
if err := m.validate(); err != nil {
return err
}
lo := strings.ToLower(m.Owner)
if owners[lo] {
return ghErrorf("Duplicate owner name '%s'", m.Owner)
}
owners[lo] = true
}
return nil
}
// validate is IssueMatcherConfig.Validate (IssueMatcher.cs:376).
func (m *matcherConfig) validate() error {
if m.Owner == "" {
return ghErrorf("Owner must not be empty")
}
switch strings.ToUpper(m.Severity) {
case "", "ERROR", "WARNING", "NOTICE":
default:
return ghErrorf("Matcher '%s' contains unexpected default severity '%s'", m.Owner, m.Severity)
}
if len(m.Patterns) == 0 {
return ghErrorf("Matcher '%s' does not contain any patterns", m.Owner)
}
var file, line, column, severity, code, message, fromPath *int
for i := range m.Patterns {
isFirst, isLast := i == 0, i == len(m.Patterns)-1
if err := m.Patterns[i].validate(isFirst, isLast, &file, &line, &column, &severity, &code, &message, &fromPath); err != nil {
return err
}
}
if message == nil {
return ghErrorf("At least one pattern must set 'message'")
}
return nil
}
// validate is IssuePatternConfig.Validate (IssueMatcher.cs:495).
func (p *patternConfig) validate(isFirst, isLast bool, file, line, column, severity, code, message, fromPath **int) error {
if bool(p.Loop) && (isFirst || !isLast) {
return ghErrorf("Only the last pattern in a multiline matcher may set 'loop'")
}
if bool(p.Loop) && !p.Message.set {
return ghErrorf("The loop pattern must set 'message'")
}
re, err := compilePattern(p.Regexp)
if err != nil {
return err
}
groupCount := len(re.GetGroupNumbers())
for _, f := range []struct {
name string
v optInt
to **int
}{
{"file", p.File, file}, {"line", p.Line, line}, {"column", p.Column, column},
{"severity", p.Severity, severity}, {"code", p.Code, code}, {"message", p.Message, message},
{"fromPath", p.FromPath, fromPath},
} {
if !f.v.set {
continue
}
if *f.to != nil {
return ghErrorf("The property '%s' is set twice", f.name)
}
if f.v.v < 0 || f.v.v >= groupCount {
return ghErrorf("The property '%s' is set to %d which is out of range", f.name, f.v.v)
}
n := f.v.v
*f.to = &n
}
return nil
}
// errMatchTimeout is the RegexMatchTimeoutException; its message is .NET's.
var 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.")
// issuePattern is IssuePattern.
type issuePattern struct {
file, line, column, severity, code, message, fromPath optInt
loop bool
re *regexp2.Regexp
}
// nstr is a string that can be null, as the values of IssueMatch are: a group
// that the pattern does not name is null, a group that matched nothing is "".
type nstr struct {
s string
ok bool
}
func (n nstr) or(o nstr) nstr {
if n.ok {
return n
}
return o
}
// issueMatch is IssueMatch.
type issueMatch struct {
file, line, column, severity, code, message, fromPath nstr
}
// issueMatcher is IssueMatcher: a matcher config with the state of the
// multi-line patterns.
type issueMatcher struct {
owner, defaultSeverity, defaultFromPath string
patterns []*issuePattern
state []*issueMatch
}
func newIssueMatcher(c *matcherConfig, timeout time.Duration) (*issueMatcher, error) {
m := &issueMatcher{owner: c.Owner, defaultSeverity: c.Severity, defaultFromPath: c.FromPath}
for _, pc := range c.Patterns {
re, err := compilePattern(pc.Regexp)
if err != nil {
return nil, err
}
re.MatchTimeout = timeout
m.patterns = append(m.patterns, &issuePattern{
file: pc.File, line: pc.Line, column: pc.Column, severity: pc.Severity, code: pc.Code,
message: pc.Message, fromPath: pc.FromPath, loop: bool(pc.Loop), re: re,
})
}
m.reset()
return m, nil
}
func (m *issueMatcher) reset() { m.state = make([]*issueMatch, max(len(m.patterns)-1, 0)) }
// groupValue is IssueMatch.GetValue: the text of a group, null when the
// pattern does not name one or its number is out of range.
func groupValue(match *regexp2.Match, idx optInt) nstr {
if !idx.set || idx.v >= match.GroupCount() {
return nstr{}
}
g := match.GroupByNumber(idx.v)
if g == nil {
return nstr{}
}
return nstr{g.String(), true}
}
func newIssueMatch(running *issueMatch, p *issuePattern, g *regexp2.Match, defSeverity, defFromPath string) *issueMatch {
r := running
if r == nil {
r = &issueMatch{}
}
im := &issueMatch{
file: r.file.or(groupValue(g, p.file)),
line: r.line.or(groupValue(g, p.line)),
column: r.column.or(groupValue(g, p.column)),
severity: r.severity.or(groupValue(g, p.severity)),
code: r.code.or(groupValue(g, p.code)),
message: r.message.or(groupValue(g, p.message)),
fromPath: r.fromPath.or(groupValue(g, p.fromPath)),
}
if im.severity.s == "" && defSeverity != "" {
im.severity = nstr{defSeverity, true}
}
if im.fromPath.s == "" && defFromPath != "" {
im.fromPath = nstr{defFromPath, true}
}
return im
}
// match is IssueMatcher.Match. A timeout of the engine is returned as
// errMatchTimeout.
func (m *issueMatcher) match(line string) (*issueMatch, error) {
find := func(p *issuePattern) (*regexp2.Match, error) {
mt, err := p.re.FindStringMatch(line)
if err != nil {
return nil, errMatchTimeout
}
return mt, nil
}
if len(m.patterns) == 1 {
p := m.patterns[0]
mt, err := find(p)
if err != nil || mt == nil {
return nil, err
}
return newIssueMatch(nil, p, mt, m.defaultSeverity, m.defaultFromPath), nil
}
for i := len(m.patterns) - 1; i >= 0; i-- {
var running *issueMatch
if i > 0 {
running = m.state[i-1]
}
if i != 0 && running == nil {
continue
}
p := m.patterns[i]
isLast := i == len(m.patterns)-1
mt, err := find(p)
if err != nil {
return nil, err
}
if mt != nil {
if isLast {
if p.loop {
m.reset()
m.state[i-1] = running
} else {
m.reset()
}
return newIssueMatch(running, p, mt, m.defaultSeverity, m.defaultFromPath), nil
}
m.state[i] = newIssueMatch(running, p, mt, "", "")
} else if isLast {
m.state[i-1] = nil
} else {
m.state[i] = nil
}
}
return nil, nil
}
// matcherRoot holds the matchers of the job (ExecutionContext._matchers, on the
// root context) and tells the output managers of the steps that run when they
// change (OnMatcherChanged).
type matcherRoot struct {
mu sync.Mutex
configs []*matcherConfig
handlers map[*outputManager]struct{}
}
// snapshot returns the matchers now.
func (r *matcherRoot) snapshot() []*matcherConfig {
r.mu.Lock()
defer r.mu.Unlock()
return append([]*matcherConfig(nil), r.configs...)
}
func (r *matcherRoot) subscribe(o *outputManager) {
r.mu.Lock()
defer r.mu.Unlock()
if r.handlers == nil {
r.handlers = map[*outputManager]struct{}{}
}
r.handlers[o] = struct{}{}
}
func (r *matcherRoot) unsubscribe(o *outputManager) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.handlers, o)
}
// add is ExecutionContext.AddMatchers (:1173): the new matchers go first, those
// of the same owner (ignoring case) are replaced. It returns the debug line.
func (r *matcherRoot) add(c *matchersConfig) string {
r.mu.Lock()
defer r.mu.Unlock()
owners := map[string]bool{}
var next []*matcherConfig
for _, m := range c.Matchers {
owners[strings.ToLower(m.Owner)] = true
next = append(next, m)
}
for _, m := range r.configs {
if !owners[strings.ToLower(m.Owner)] {
next = append(next, m)
}
}
r.configs = next
var names []string
for _, m := range c.Matchers {
names = append(names, "'"+m.Owner+"'")
for o := range r.handlers {
o.matcherChanged(m)
}
}
return "Added matchers: " + strings.Join(names, ", ") + ". Problem matchers scan action output for known warning or error strings and report these inline."
}
// remove is ExecutionContext.RemoveMatchers (:1212). It returns the debug line.
func (r *matcherRoot) remove(owners []string) string {
r.mu.Lock()
defer r.mu.Unlock()
distinct := map[string]bool{}
for _, o := range owners {
distinct[strings.ToLower(o)] = true
}
var kept, removed []*matcherConfig
for _, m := range r.configs {
if distinct[strings.ToLower(m.Owner)] {
removed = append(removed, m)
} else {
kept = append(kept, m)
}
}
r.configs = kept
var names []string
for _, m := range removed {
names = append(names, "'"+m.Owner+"'")
for o := range r.handlers {
o.matcherChanged(&matcherConfig{Owner: m.Owner})
}
}
return "Removed matchers: " + strings.Join(names, ", ")
}