blob: f1502555412c887c210722f15b9dcde41e1c914a [file] [log] [blame]
// Copyright 2021 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package core
import (
"bytes"
"encoding/json"
"fmt"
"io"
"regexp"
"sort"
"strings"
)
// A Reporter provides utilities to aggregate warning messages attached to
// specific tokens, and to pretty print these aggregates messages.
type Reporter interface {
// Warnf formats and adds warning message using the format specifier.
Warnf(tok Token, format string, a ...interface{})
}
// RootReporter is the linter wide reporter, and may be used to report general
// warnings. When reporting within rules, prefer creating rule reporters with:
//
// rootReporter := ...
// ruleReporter := rootReporter.ForRule("name-of-rule")
type RootReporter struct {
messages sortableMessages
// JSONOutput enables JSON output, instead of the pretty printed human
// readable output.
JSONOutput bool
}
type ruleReporter struct {
parent *RootReporter
rule string
}
// ForRule creates a reporter for a specific rule.
func (r *RootReporter) ForRule(rule string) Reporter {
return &ruleReporter{
parent: r,
rule: rule,
}
}
var _ = []Reporter{
(*RootReporter)(nil),
(*ruleReporter)(nil),
}
type message struct {
category string
tok Token
content string
}
type sortableMessages []message
var _ sort.Interface = (sortableMessages)(nil)
func (s sortableMessages) Len() int {
return len(s)
}
func (s sortableMessages) Less(i, j int) bool {
if byFilename := strings.Compare(s[i].tok.Doc.Filename, s[j].tok.Doc.Filename); byFilename < 0 {
return true
} else if byFilename > 0 {
return false
}
if byLnNo := s[i].tok.Ln - s[j].tok.Ln; byLnNo < 0 {
return true
} else if byLnNo > 0 {
return false
}
if byColNo := s[i].tok.Col - s[j].tok.Col; byColNo < 0 {
return true
} else if byColNo > 0 {
return false
}
byContent := strings.Compare(s[i].content, s[j].content)
return byContent < 0
}
func (s sortableMessages) Swap(i, j int) {
s[i], s[j] = s[j], s[i]
}
func (r *RootReporter) Warnf(tok Token, format string, a ...interface{}) {
r.warnf(generalName, tok, format, a...)
}
func (r *ruleReporter) Warnf(tok Token, format string, a ...interface{}) {
r.parent.warnf(r.rule, tok, format, a...)
}
func (r *RootReporter) warnf(category string, tok Token, format string, a ...interface{}) {
r.messages = append(r.messages, message{
category: category,
tok: tok,
content: fmt.Sprintf(format, a...),
})
}
// HasMessages indicates whether any message was added to this reporter.
func (r *RootReporter) HasMessages(filenamesFilter *regexp.Regexp) bool {
for _, msg := range r.messages {
if !filenamesFilter.MatchString(msg.tok.Doc.Filename) {
continue
}
return true
}
return false
}
// findingJSON captures the information needed to emit a `message Comment` as
// specified in
// https://chromium.googlesource.com/infra/infra/+/refs/heads/master/go/src/infra/tricium/api/v1/data.proto
type findingJSON struct {
Category string `json:"category"`
Message string `json:"message"`
Path string `json:"path"`
StartLine int `json:"start_line"`
StartChar int `json:"start_char"`
EndLine int `json:"end_line"`
EndChar int `json:"end_char"`
}
// messagesToFindingsJSON converts the reporter's messages to `findingJSON`.
// This method is used to test the internals of the reporter, but should not be
// used otherwise.
func (r *RootReporter) messagesToFindingsJSON(filenamesFilter *regexp.Regexp) []findingJSON {
sort.Sort(r.messages)
var findings []findingJSON
for _, msg := range r.messages {
if !filenamesFilter.MatchString(msg.tok.Doc.Filename) {
continue
}
numLines, numCharsOnLastLine := numLinesAndCharsOnLastLine(msg.tok)
findings = append(findings, findingJSON{
Category: fmt.Sprintf("mdlint/%s", msg.category),
Message: msg.content,
Path: msg.tok.Doc.Filename,
StartLine: msg.tok.Ln,
StartChar: msg.tok.Col - 1,
EndLine: msg.tok.Ln + numLines,
EndChar: numCharsOnLastLine,
})
}
return findings
}
func numLinesAndCharsOnLastLine(tok Token) (int, int) {
var (
numLines int
numCharsOnLastLine = tok.Col - 1
)
for _, r := range tok.Content {
if r == '\n' {
numLines++
numCharsOnLastLine = 0
} else {
numCharsOnLastLine++
}
}
return numLines, numCharsOnLastLine
}
func (r *RootReporter) printAsJSON(filenamesFilter *regexp.Regexp, writer io.Writer) error {
data, err := json.Marshal(r.messagesToFindingsJSON(filenamesFilter))
if err != nil {
return err
}
if _, err := writer.Write(data); err != nil {
return err
}
return nil
}
func (r *RootReporter) printAsPrettyPrint(filenamesFilter *regexp.Regexp, writer io.Writer) error {
sort.Sort(r.messages)
isFirst := true
for _, msg := range r.messages {
if !filenamesFilter.MatchString(msg.tok.Doc.Filename) {
continue
}
if isFirst {
isFirst = false
} else {
if _, err := writer.Write([]byte("\n")); err != nil {
return err
}
}
var (
explanation = fmt.Sprintf("%s:%d:%d: %s", msg.tok.Doc.Filename, msg.tok.Ln, msg.tok.Col, msg.content)
lineFromDoc = msg.tok.Doc.lines[msg.tok.Ln-1]
squiggle = makeSquiggle(lineFromDoc, msg.tok)
)
for _, line := range []string{explanation, "\n", lineFromDoc, "\n", squiggle, "\n"} {
if _, err := writer.Write([]byte(line)); err != nil {
return err
}
}
}
return nil
}
var allFilenames = regexp.MustCompile("")
// Print prints this report to the writer. For instance:
//
// reporter.Print(os.Stderr)
func (r *RootReporter) Print(writer io.Writer) error {
return r.PrintOnlyForFiles(allFilenames, writer)
}
func (r *RootReporter) PrintOnlyForFiles(filenamesFilter *regexp.Regexp, writer io.Writer) error {
if r.JSONOutput {
return r.printAsJSON(filenamesFilter, writer)
}
return r.printAsPrettyPrint(filenamesFilter, writer)
}
func makeSquiggle(line string, tok Token) string {
var (
col int = 1
squiggle bytes.Buffer
)
for _, r := range line {
if col == tok.Col {
break
}
if isSeparatorSpace(r) {
squiggle.WriteRune(r)
} else {
squiggle.WriteRune(' ')
}
col++
}
squiggle.WriteRune('^')
squiggleLen := len(tok.Content) - 1
if index := strings.Index(tok.Content, "\n"); index != -1 {
squiggleLen = index - 1
}
squiggle.WriteString(strings.Repeat("~", squiggleLen))
return squiggle.String()
}