blob: 5dd980c24666bf9e5875b79330f90ea12d6e49ff [file] [log] [blame]
// Copyright 2023 The Shac Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package reporting
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sync"
"time"
"github.com/mattn/go-colorable"
"github.com/mattn/go-isatty"
"go.fuchsia.dev/shac-project/shac/internal/engine"
)
// Report is a closable engine.Report.
type Report interface {
io.Closer
engine.Report
}
// Get returns the right reporting implementation based on the current
// environment.
func Get(ctx context.Context) (*MultiReport, error) {
r := &MultiReport{}
// On LUCI/Swarming. ResultDB!
if os.Getenv("LUCI_CONTEXT") != "" {
l := &luci{
batchWaitDuration: 20 * time.Millisecond,
}
if err := l.init(ctx); err != nil {
return nil, err
}
r.Reporters = append(r.Reporters, l)
}
// The following reporters all emit to stdout so they are mutually
// exclusive.
switch {
case os.Getenv("GITHUB_RUN_ID") != "":
// On GitHub Actions. Emits GitHub Workflows commands.
r.Reporters = append(r.Reporters, &synchronized{r: &github{out: os.Stdout}})
case os.Getenv("TERM") != "dumb" && isatty.IsTerminal(os.Stderr.Fd()):
// Active terminal. Colors! This includes VSCode's integrated terminal.
r.Reporters = append(r.Reporters, &synchronized{r: &interactive{
out: colorable.NewColorableStdout(),
}})
case os.Getenv("VSCODE_GIT_IPC_HANDLE") != "":
// VSCode extension.
// TODO(maruel): Return SARIF.
r.Reporters = append(r.Reporters, &synchronized{r: &basic{out: os.Stdout}})
default:
// Anything else, e.g. redirected output.
r.Reporters = append(r.Reporters, &synchronized{r: &basic{out: os.Stdout}})
}
return r, nil
}
// synchronized wraps a Report object and adds synchronization of calls to
// ensure that checks cannot emit potentially multi-line data simultaneously.
// For example, we don't want two checks to simultaneously emit multi-line
// chunks of output to the command line and have those chunks of output be
// interleaved.
//
// It should be used to wrap any reporter that writes to stdout.
type synchronized struct {
r Report
mu sync.Mutex
}
func (s *synchronized) Close() error {
return s.r.Close()
}
func (s *synchronized) EmitFinding(ctx context.Context, check string, level engine.Level, message, root, file string, span engine.Span, replacements []string) error {
s.mu.Lock()
defer s.mu.Unlock()
return s.r.EmitFinding(ctx, check, level, message, root, file, span, replacements)
}
func (s *synchronized) EmitArtifact(ctx context.Context, check, root, file string, content []byte) error {
s.mu.Lock()
defer s.mu.Unlock()
return s.r.EmitArtifact(ctx, check, root, file, content)
}
func (s *synchronized) CheckCompleted(ctx context.Context, check string, start time.Time, d time.Duration, level engine.Level, err error) {
s.mu.Lock()
defer s.mu.Unlock()
s.r.CheckCompleted(ctx, check, start, d, level, err)
}
func (s *synchronized) Print(ctx context.Context, check, file string, line int, message string) {
s.mu.Lock()
defer s.mu.Unlock()
s.r.Print(ctx, check, file, line, message)
}
type basic struct {
out io.Writer
}
func (b *basic) Close() error {
return nil
}
func (b *basic) EmitFinding(ctx context.Context, check string, level engine.Level, message, root, file string, s engine.Span, replacements []string) error {
if file != "" {
// TODO(maruel): Do not drop span and replacements!
if s.Start.Line > 0 {
_, err := fmt.Fprintf(b.out, "[%s/%s] %s(%d): %s\n", check, level, file, s.Start.Line, message)
return err
}
_, err := fmt.Fprintf(b.out, "[%s/%s] %s: %s\n", check, level, file, message)
return err
}
_, err := fmt.Fprintf(b.out, "[%s/%s] %s\n", check, level, message)
return err
}
func (b *basic) EmitArtifact(ctx context.Context, check, root, file string, content []byte) error {
return errors.New("not implemented")
}
func (b *basic) CheckCompleted(ctx context.Context, check string, start time.Time, d time.Duration, level engine.Level, err error) {
if err != nil {
level = engine.Error
}
l := string(level)
if level == "" || level == engine.Notice {
l = "success"
}
if err != nil {
fmt.Fprintf(b.out, "- %s (%s in %s): %s\n", check, l, d.Round(time.Millisecond), err)
} else {
fmt.Fprintf(b.out, "- %s (%s in %s)\n", check, l, d.Round(time.Millisecond))
}
}
func (b *basic) Print(ctx context.Context, check, file string, line int, message string) {
if check != "" {
fmt.Fprintf(b.out, "- %s [%s:%d] %s\n", check, file, line, message)
} else {
fmt.Fprintf(b.out, "[%s:%d] %s\n", file, line, message)
}
}
// github is the Report implementation when running inside a GitHub Actions
// Workflow.
//
// See https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions
type github struct {
out io.Writer
}
func (g *github) Close() error {
return nil
}
func (g *github) EmitFinding(ctx context.Context, check string, level engine.Level, message, root, file string, s engine.Span, replacements []string) error {
if file != "" {
// TODO(maruel): Do not drop replacements!
if s.Start.Line > 0 {
if s.End.Line > 0 {
if s.End.Col > 0 {
_, err := fmt.Fprintf(g.out, "::%s ::file=%s,line=%d,col=%d,endLine=%d,endCol=%d,title=%s::%s\n",
level, file, s.Start.Line, s.Start.Col, s.End.Line, s.End.Col, check, message)
return err
}
_, err := fmt.Fprintf(g.out, "::%s ::file=%s,line=%d,endLine=%d,title=%s::%s\n",
level, file, s.Start.Line, s.End.Line, check, message)
return err
}
if s.Start.Col > 0 {
_, err := fmt.Fprintf(g.out, "::%s ::file=%s,line=%d,col=%d,title=%s::%s\n",
level, file, s.Start.Line, s.Start.Col, check, message)
return err
}
_, err := fmt.Fprintf(g.out, "::%s ::file=%s,line=%d,title=%s::%s\n",
level, file, s.Start.Line, check, message)
return err
}
_, err := fmt.Fprintf(g.out, "::%s ::file=%stitle=%s::%s\n", level, file, check, message)
return err
}
_, err := fmt.Fprintf(g.out, "::%s ::title=%s::%s\n", level, check, message)
return err
}
func (g *github) EmitArtifact(ctx context.Context, check, root, file string, content []byte) error {
return errors.New("not implemented")
}
func (g *github) CheckCompleted(ctx context.Context, check string, start time.Time, d time.Duration, l engine.Level, err error) {
}
func (g *github) Print(ctx context.Context, check, file string, line int, message string) {
// Use debug here instead of notice since the file/line reference comes from
// starlark, which will likely not be in the delta or even in your source
// tree for load()'ed packages. This means GitHub may not be able to
// reference it anyway.
if check != "" {
fmt.Fprintf(g.out, "::debug::%s [%s:%d] %s\n", check, file, line, message)
} else {
fmt.Fprintf(g.out, "::debug::[%s:%d] %s\n", file, line, message)
}
}
type interactive struct {
out io.Writer
}
func (i *interactive) Close() error {
return nil
}
func (i *interactive) EmitFinding(ctx context.Context, check string, level engine.Level, message, root, file string, s engine.Span, replacements []string) error {
c := levelColor[level]
if file != "" {
// TODO(maruel): Do not drop replacements!
if s.Start.Line > 0 {
fmt.Fprintf(i.out, "%s[%s%s%s/%s%s%s] %s(%d): %s\n", reset, fgHiCyan, check, reset, c, level, reset, file, s.Start.Line, message)
// Emit the line and a bit of context in interactive mode.
b, err := os.ReadFile(filepath.Join(root, file))
if err != nil {
return err
}
lines := bytes.Split(b, []byte("\n"))
end := s.End.Line
if end == 0 {
end = s.Start.Line
}
if s.Start.Line >= len(lines) {
// Consider raising an alert so the check can be fixed.
return nil
}
fmt.Fprintf(i.out, "\n")
for l := s.Start.Line - 2; l <= end && l < len(lines); l++ {
if l < 0 {
continue
}
if l == s.Start.Line-1 {
// First highlighted line.
if s.Start.Col > 0 {
if s.End.Line == s.Start.Line && s.End.Col > 0 {
// Silently ignore when the ending offset is misaligned. It's easy to get wrong.
ec := s.End.Col
if ec > len(lines[l])+1 {
// Consider raising an alert so the check can be fixed.
ec = len(lines[l]) + 1
}
// Intra-line highlight.
fmt.Fprintf(i.out, " %s%s%s%s%s\n", lines[l][:s.Start.Col-1], c, lines[l][s.Start.Col-1:ec-1], reset, lines[l][ec-1:])
} else {
fmt.Fprintf(i.out, " %s%s%s%s\n", lines[l][:s.Start.Col-1], c, lines[l][s.Start.Col-1:], reset)
}
} else {
fmt.Fprintf(i.out, " %s%s%s\n", c, lines[l], reset)
}
} else if l > s.Start.Line-1 && l < end-1 {
// Middle lines.
fmt.Fprintf(i.out, " %s%s%s\n", c, lines[l], reset)
} else if l >= s.Start.Line && l == end-1 {
// Last highlighted line.
if s.End.Col > 0 {
// Silently ignore when the ending offset is misaligned. It's easy to get wrong.
ec := s.End.Col
if ec > len(lines[l])+1 {
// Consider raising an alert so the check can be fixed.
ec = len(lines[l]) + 1
}
fmt.Fprintf(i.out, " %s%s%s%s\n", c, lines[l][:ec-1], reset, lines[l][ec-1:])
} else {
fmt.Fprintf(i.out, " %s%s%s\n", c, lines[l], reset)
}
} else {
fmt.Fprintf(i.out, " %s\n", lines[l])
}
}
_, err = fmt.Fprintf(i.out, "\n")
return err
}
_, err := fmt.Fprintf(i.out, "%s[%s%s%s/%s%s%s] %s: %s\n", reset, fgHiCyan, check, reset, c, level, reset, file, message)
return err
}
_, err := fmt.Fprintf(i.out, "%s[%s%s%s/%s%s%s] %s\n", reset, fgHiCyan, check, reset, c, level, reset, message)
return err
}
func (i *interactive) EmitArtifact(ctx context.Context, root, check, file string, content []byte) error {
return errors.New("not implemented")
}
func (i *interactive) CheckCompleted(ctx context.Context, check string, start time.Time, d time.Duration, level engine.Level, err error) {
if err != nil {
level = engine.Error
}
c := levelColor[level]
l := string(level)
if level == "" || level == engine.Notice {
l = "success"
}
if err != nil {
fmt.Fprintf(i.out, "%s- %s%s%s (%s in %s): %s\n", reset, c, check, reset, l, d.Round(time.Millisecond), err)
} else {
fmt.Fprintf(i.out, "%s- %s%s%s (%s in %s)\n", reset, c, check, reset, l, d.Round(time.Millisecond))
}
}
func (i *interactive) Print(ctx context.Context, check, file string, line int, message string) {
if check != "" {
fmt.Fprintf(i.out, "%s- %s%s %s[%s%s:%d%s] %s%s%s\n", reset, fgYellow, check, reset, fgHiBlue, file, line, reset, bold, message, reset)
} else {
fmt.Fprintf(i.out, "%s[%s%s:%d%s] %s%s%s\n", reset, fgHiBlue, file, line, reset, bold, message, reset)
}
}
var levelColor = map[engine.Level]ansiCode{
engine.Notice: fgGreen,
engine.Warning: fgYellow,
engine.Error: fgRed,
engine.Nothing: fgGreen,
}