1613 lines
37 KiB
Go
1613 lines
37 KiB
Go
|
package logger
|
||
|
|
||
|
// Logging is currently designed to look and feel like clang's error format.
|
||
|
// Errors are streamed asynchronously as they happen, each error contains the
|
||
|
// contents of the line with the error, and the error count is limited by
|
||
|
// default.
|
||
|
|
||
|
import (
|
||
|
"fmt"
|
||
|
"os"
|
||
|
"runtime"
|
||
|
"sort"
|
||
|
"strings"
|
||
|
"sync"
|
||
|
"time"
|
||
|
"unicode/utf8"
|
||
|
)
|
||
|
|
||
|
const defaultTerminalWidth = 80
|
||
|
|
||
|
type Log struct {
|
||
|
Level LogLevel
|
||
|
|
||
|
AddMsg func(Msg)
|
||
|
HasErrors func() bool
|
||
|
|
||
|
// This is called after the build has finished but before writing to stdout.
|
||
|
// It exists to ensure that deferred warning messages end up in the terminal
|
||
|
// before the data written to stdout.
|
||
|
AlmostDone func()
|
||
|
|
||
|
Done func() []Msg
|
||
|
}
|
||
|
|
||
|
type LogLevel int8
|
||
|
|
||
|
const (
|
||
|
LevelNone LogLevel = iota
|
||
|
LevelVerbose
|
||
|
LevelDebug
|
||
|
LevelInfo
|
||
|
LevelWarning
|
||
|
LevelError
|
||
|
LevelSilent
|
||
|
)
|
||
|
|
||
|
type MsgKind uint8
|
||
|
|
||
|
const (
|
||
|
Error MsgKind = iota
|
||
|
Warning
|
||
|
Info
|
||
|
Note
|
||
|
Debug
|
||
|
Verbose
|
||
|
)
|
||
|
|
||
|
func (kind MsgKind) String() string {
|
||
|
switch kind {
|
||
|
case Error:
|
||
|
return "ERROR"
|
||
|
case Warning:
|
||
|
return "WARNING"
|
||
|
case Info:
|
||
|
return "INFO"
|
||
|
case Note:
|
||
|
return "NOTE"
|
||
|
case Debug:
|
||
|
return "DEBUG"
|
||
|
case Verbose:
|
||
|
return "VERBOSE"
|
||
|
default:
|
||
|
panic("Internal error")
|
||
|
}
|
||
|
}
|
||
|
|
||
|
func (kind MsgKind) Icon() string {
|
||
|
// Special-case Windows command prompt, which only supports a few characters
|
||
|
if isProbablyWindowsCommandPrompt() {
|
||
|
switch kind {
|
||
|
case Error:
|
||
|
return "X"
|
||
|
case Warning:
|
||
|
return "▲"
|
||
|
case Info:
|
||
|
return "►"
|
||
|
case Note:
|
||
|
return "→"
|
||
|
case Debug:
|
||
|
return "●"
|
||
|
case Verbose:
|
||
|
return "♦"
|
||
|
default:
|
||
|
panic("Internal error")
|
||
|
}
|
||
|
}
|
||
|
|
||
|
switch kind {
|
||
|
case Error:
|
||
|
return "✘"
|
||
|
case Warning:
|
||
|
return "▲"
|
||
|
case Info:
|
||
|
return "▶"
|
||
|
case Note:
|
||
|
return "→"
|
||
|
case Debug:
|
||
|
return "●"
|
||
|
case Verbose:
|
||
|
return "⬥"
|
||
|
default:
|
||
|
panic("Internal error")
|
||
|
}
|
||
|
}
|
||
|
|
||
|
var windowsCommandPrompt struct {
|
||
|
mutex sync.Mutex
|
||
|
once bool
|
||
|
isProbablyCMD bool
|
||
|
}
|
||
|
|
||
|
func isProbablyWindowsCommandPrompt() bool {
|
||
|
windowsCommandPrompt.mutex.Lock()
|
||
|
defer windowsCommandPrompt.mutex.Unlock()
|
||
|
|
||
|
if !windowsCommandPrompt.once {
|
||
|
windowsCommandPrompt.once = true
|
||
|
|
||
|
// Assume we are running in Windows Command Prompt if we're on Windows. If
|
||
|
// so, we can't use emoji because it won't be supported. Except we can
|
||
|
// still use emoji if the WT_SESSION environment variable is present
|
||
|
// because that means we're running in the new Windows Terminal instead.
|
||
|
if runtime.GOOS == "windows" {
|
||
|
windowsCommandPrompt.isProbablyCMD = true
|
||
|
for _, env := range os.Environ() {
|
||
|
if strings.HasPrefix(env, "WT_SESSION=") {
|
||
|
windowsCommandPrompt.isProbablyCMD = false
|
||
|
break
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return windowsCommandPrompt.isProbablyCMD
|
||
|
}
|
||
|
|
||
|
type Msg struct {
|
||
|
PluginName string
|
||
|
Kind MsgKind
|
||
|
Data MsgData
|
||
|
Notes []MsgData
|
||
|
}
|
||
|
|
||
|
type MsgData struct {
|
||
|
Text string
|
||
|
Location *MsgLocation
|
||
|
|
||
|
// Optional user-specified data that is passed through unmodified
|
||
|
UserDetail interface{}
|
||
|
}
|
||
|
|
||
|
type MsgLocation struct {
|
||
|
File string
|
||
|
Namespace string
|
||
|
Line int // 1-based
|
||
|
Column int // 0-based, in bytes
|
||
|
Length int // in bytes
|
||
|
LineText string
|
||
|
Suggestion string
|
||
|
}
|
||
|
|
||
|
type Loc struct {
|
||
|
// This is the 0-based index of this location from the start of the file, in bytes
|
||
|
Start int32
|
||
|
}
|
||
|
|
||
|
type Range struct {
|
||
|
Loc Loc
|
||
|
Len int32
|
||
|
}
|
||
|
|
||
|
func (r Range) End() int32 {
|
||
|
return r.Loc.Start + r.Len
|
||
|
}
|
||
|
|
||
|
type Span struct {
|
||
|
Text string
|
||
|
Range Range
|
||
|
}
|
||
|
|
||
|
// This type is just so we can use Go's native sort function
|
||
|
type SortableMsgs []Msg
|
||
|
|
||
|
func (a SortableMsgs) Len() int { return len(a) }
|
||
|
func (a SortableMsgs) Swap(i int, j int) { a[i], a[j] = a[j], a[i] }
|
||
|
|
||
|
func (a SortableMsgs) Less(i int, j int) bool {
|
||
|
ai := a[i]
|
||
|
aj := a[j]
|
||
|
aiLoc := ai.Data.Location
|
||
|
ajLoc := aj.Data.Location
|
||
|
if aiLoc == nil || ajLoc == nil {
|
||
|
return aiLoc == nil && ajLoc != nil
|
||
|
}
|
||
|
if aiLoc.File != ajLoc.File {
|
||
|
return aiLoc.File < ajLoc.File
|
||
|
}
|
||
|
if aiLoc.Line != ajLoc.Line {
|
||
|
return aiLoc.Line < ajLoc.Line
|
||
|
}
|
||
|
if aiLoc.Column != ajLoc.Column {
|
||
|
return aiLoc.Column < ajLoc.Column
|
||
|
}
|
||
|
if ai.Kind != aj.Kind {
|
||
|
return ai.Kind < aj.Kind
|
||
|
}
|
||
|
return ai.Data.Text < aj.Data.Text
|
||
|
}
|
||
|
|
||
|
// This is used to represent both file system paths (Namespace == "file") and
|
||
|
// abstract module paths (Namespace != "file"). Abstract module paths represent
|
||
|
// "virtual modules" when used for an input file and "package paths" when used
|
||
|
// to represent an external module.
|
||
|
type Path struct {
|
||
|
Text string
|
||
|
Namespace string
|
||
|
|
||
|
// This feature was added to support ancient CSS libraries that append things
|
||
|
// like "?#iefix" and "#icons" to some of their import paths as a hack for IE6.
|
||
|
// The intent is for these suffix parts to be ignored but passed through to
|
||
|
// the output. This is supported by other bundlers, so we also support this.
|
||
|
IgnoredSuffix string
|
||
|
|
||
|
Flags PathFlags
|
||
|
}
|
||
|
|
||
|
type PathFlags uint8
|
||
|
|
||
|
const (
|
||
|
// This corresponds to a value of "false' in the "browser" package.json field
|
||
|
PathDisabled PathFlags = 1 << iota
|
||
|
)
|
||
|
|
||
|
func (p Path) IsDisabled() bool {
|
||
|
return (p.Flags & PathDisabled) != 0
|
||
|
}
|
||
|
|
||
|
func (a Path) ComesBeforeInSortedOrder(b Path) bool {
|
||
|
return a.Namespace > b.Namespace ||
|
||
|
(a.Namespace == b.Namespace && (a.Text < b.Text ||
|
||
|
(a.Text == b.Text && (a.Flags < b.Flags ||
|
||
|
(a.Flags == b.Flags && a.IgnoredSuffix < b.IgnoredSuffix)))))
|
||
|
}
|
||
|
|
||
|
var noColorResult bool
|
||
|
var noColorOnce sync.Once
|
||
|
|
||
|
func hasNoColorEnvironmentVariable() bool {
|
||
|
noColorOnce.Do(func() {
|
||
|
for _, key := range os.Environ() {
|
||
|
// Read "NO_COLOR" from the environment. This is a convention that some
|
||
|
// software follows. See https://no-color.org/ for more information.
|
||
|
if strings.HasPrefix(key, "NO_COLOR=") {
|
||
|
noColorResult = true
|
||
|
}
|
||
|
}
|
||
|
})
|
||
|
return noColorResult
|
||
|
}
|
||
|
|
||
|
// This has a custom implementation instead of using "filepath.Dir/Base/Ext"
|
||
|
// because it should work the same on Unix and Windows. These names end up in
|
||
|
// the generated output and the generated output should not depend on the OS.
|
||
|
func PlatformIndependentPathDirBaseExt(path string) (dir string, base string, ext string) {
|
||
|
for {
|
||
|
i := strings.LastIndexAny(path, "/\\")
|
||
|
|
||
|
// Stop if there are no more slashes
|
||
|
if i < 0 {
|
||
|
base = path
|
||
|
break
|
||
|
}
|
||
|
|
||
|
// Stop if we found a non-trailing slash
|
||
|
if i+1 != len(path) {
|
||
|
dir, base = path[:i], path[i+1:]
|
||
|
break
|
||
|
}
|
||
|
|
||
|
// Ignore trailing slashes
|
||
|
path = path[:i]
|
||
|
}
|
||
|
|
||
|
// Strip off the extension
|
||
|
if dot := strings.LastIndexByte(base, '.'); dot >= 0 {
|
||
|
base, ext = base[:dot], base[dot:]
|
||
|
}
|
||
|
|
||
|
return
|
||
|
}
|
||
|
|
||
|
type Source struct {
|
||
|
Index uint32
|
||
|
|
||
|
// This is used as a unique key to identify this source file. It should never
|
||
|
// be shown to the user (e.g. never print this to the terminal).
|
||
|
//
|
||
|
// If it's marked as an absolute path, it's a platform-dependent path that
|
||
|
// includes environment-specific things such as Windows backslash path
|
||
|
// separators and potentially the user's home directory. Only use this for
|
||
|
// passing to syscalls for reading and writing to the file system. Do not
|
||
|
// include this in any output data.
|
||
|
//
|
||
|
// If it's marked as not an absolute path, it's an opaque string that is used
|
||
|
// to refer to an automatically-generated module.
|
||
|
KeyPath Path
|
||
|
|
||
|
// This is used for error messages and the metadata JSON file.
|
||
|
//
|
||
|
// This is a mostly platform-independent path. It's relative to the current
|
||
|
// working directory and always uses standard path separators. Use this for
|
||
|
// referencing a file in all output data. These paths still use the original
|
||
|
// case of the path so they may still work differently on file systems that
|
||
|
// are case-insensitive vs. case-sensitive.
|
||
|
PrettyPath string
|
||
|
|
||
|
// An identifier that is mixed in to automatically-generated symbol names to
|
||
|
// improve readability. For example, if the identifier is "util" then the
|
||
|
// symbol for an "export default" statement will be called "util_default".
|
||
|
IdentifierName string
|
||
|
|
||
|
Contents string
|
||
|
}
|
||
|
|
||
|
func (s *Source) TextForRange(r Range) string {
|
||
|
return s.Contents[r.Loc.Start : r.Loc.Start+r.Len]
|
||
|
}
|
||
|
|
||
|
func (s *Source) LocBeforeWhitespace(loc Loc) Loc {
|
||
|
for loc.Start > 0 {
|
||
|
c, width := utf8.DecodeLastRuneInString(s.Contents[:loc.Start])
|
||
|
if c != ' ' && c != '\t' && c != '\r' && c != '\n' {
|
||
|
break
|
||
|
}
|
||
|
loc.Start -= int32(width)
|
||
|
}
|
||
|
return loc
|
||
|
}
|
||
|
|
||
|
func (s *Source) RangeOfOperatorBefore(loc Loc, op string) Range {
|
||
|
text := s.Contents[:loc.Start]
|
||
|
index := strings.LastIndex(text, op)
|
||
|
if index >= 0 {
|
||
|
return Range{Loc: Loc{Start: int32(index)}, Len: int32(len(op))}
|
||
|
}
|
||
|
return Range{Loc: loc}
|
||
|
}
|
||
|
|
||
|
func (s *Source) RangeOfOperatorAfter(loc Loc, op string) Range {
|
||
|
text := s.Contents[loc.Start:]
|
||
|
index := strings.Index(text, op)
|
||
|
if index >= 0 {
|
||
|
return Range{Loc: Loc{Start: loc.Start + int32(index)}, Len: int32(len(op))}
|
||
|
}
|
||
|
return Range{Loc: loc}
|
||
|
}
|
||
|
|
||
|
func (s *Source) RangeOfString(loc Loc) Range {
|
||
|
text := s.Contents[loc.Start:]
|
||
|
if len(text) == 0 {
|
||
|
return Range{Loc: loc, Len: 0}
|
||
|
}
|
||
|
|
||
|
quote := text[0]
|
||
|
if quote == '"' || quote == '\'' {
|
||
|
// Search for the matching quote character
|
||
|
for i := 1; i < len(text); i++ {
|
||
|
c := text[i]
|
||
|
if c == quote {
|
||
|
return Range{Loc: loc, Len: int32(i + 1)}
|
||
|
} else if c == '\\' {
|
||
|
i += 1
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return Range{Loc: loc, Len: 0}
|
||
|
}
|
||
|
|
||
|
func (s *Source) RangeOfNumber(loc Loc) (r Range) {
|
||
|
text := s.Contents[loc.Start:]
|
||
|
r = Range{Loc: loc, Len: 0}
|
||
|
|
||
|
if len(text) > 0 {
|
||
|
if c := text[0]; c >= '0' && c <= '9' {
|
||
|
r.Len = 1
|
||
|
for int(r.Len) < len(text) {
|
||
|
c := text[r.Len]
|
||
|
if (c < '0' || c > '9') && (c < 'a' || c > 'z') && (c < 'A' || c > 'Z') && c != '.' && c != '_' {
|
||
|
break
|
||
|
}
|
||
|
r.Len++
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
return
|
||
|
}
|
||
|
|
||
|
func (s *Source) RangeOfLegacyOctalEscape(loc Loc) (r Range) {
|
||
|
text := s.Contents[loc.Start:]
|
||
|
r = Range{Loc: loc, Len: 0}
|
||
|
|
||
|
if len(text) >= 2 && text[0] == '\\' {
|
||
|
r.Len = 2
|
||
|
for r.Len < 4 && int(r.Len) < len(text) {
|
||
|
c := text[r.Len]
|
||
|
if c < '0' || c > '9' {
|
||
|
break
|
||
|
}
|
||
|
r.Len++
|
||
|
}
|
||
|
}
|
||
|
return
|
||
|
}
|
||
|
|
||
|
func plural(prefix string, count int, shown int, someAreMissing bool) string {
|
||
|
var text string
|
||
|
if count == 1 {
|
||
|
text = fmt.Sprintf("%d %s", count, prefix)
|
||
|
} else {
|
||
|
text = fmt.Sprintf("%d %ss", count, prefix)
|
||
|
}
|
||
|
if shown < count {
|
||
|
text = fmt.Sprintf("%d of %s", shown, text)
|
||
|
} else if someAreMissing && count > 1 {
|
||
|
text = "all " + text
|
||
|
}
|
||
|
return text
|
||
|
}
|
||
|
|
||
|
func errorAndWarningSummary(errors int, warnings int, shownErrors int, shownWarnings int) string {
|
||
|
someAreMissing := shownWarnings < warnings || shownErrors < errors
|
||
|
switch {
|
||
|
case errors == 0:
|
||
|
return plural("warning", warnings, shownWarnings, someAreMissing)
|
||
|
case warnings == 0:
|
||
|
return plural("error", errors, shownErrors, someAreMissing)
|
||
|
default:
|
||
|
return fmt.Sprintf("%s and %s",
|
||
|
plural("warning", warnings, shownWarnings, someAreMissing),
|
||
|
plural("error", errors, shownErrors, someAreMissing))
|
||
|
}
|
||
|
}
|
||
|
|
||
|
type APIKind uint8
|
||
|
|
||
|
const (
|
||
|
GoAPI APIKind = iota
|
||
|
CLIAPI
|
||
|
JSAPI
|
||
|
)
|
||
|
|
||
|
// This can be used to customize error messages for the current API kind
|
||
|
var API APIKind
|
||
|
|
||
|
type TerminalInfo struct {
|
||
|
IsTTY bool
|
||
|
UseColorEscapes bool
|
||
|
Width int
|
||
|
Height int
|
||
|
}
|
||
|
|
||
|
func NewStderrLog(options OutputOptions) Log {
|
||
|
var mutex sync.Mutex
|
||
|
var msgs SortableMsgs
|
||
|
terminalInfo := GetTerminalInfo(os.Stderr)
|
||
|
errors := 0
|
||
|
warnings := 0
|
||
|
shownErrors := 0
|
||
|
shownWarnings := 0
|
||
|
hasErrors := false
|
||
|
remainingMessagesBeforeLimit := options.MessageLimit
|
||
|
if remainingMessagesBeforeLimit == 0 {
|
||
|
remainingMessagesBeforeLimit = 0x7FFFFFFF
|
||
|
}
|
||
|
var deferredWarnings []Msg
|
||
|
didFinalizeLog := false
|
||
|
|
||
|
finalizeLog := func() {
|
||
|
if didFinalizeLog {
|
||
|
return
|
||
|
}
|
||
|
didFinalizeLog = true
|
||
|
|
||
|
// Print the deferred warning now if there was no error after all
|
||
|
for remainingMessagesBeforeLimit > 0 && len(deferredWarnings) > 0 {
|
||
|
shownWarnings++
|
||
|
writeStringWithColor(os.Stderr, deferredWarnings[0].String(options, terminalInfo))
|
||
|
deferredWarnings = deferredWarnings[1:]
|
||
|
remainingMessagesBeforeLimit--
|
||
|
}
|
||
|
|
||
|
// Print out a summary
|
||
|
if options.MessageLimit > 0 && errors+warnings > options.MessageLimit {
|
||
|
writeStringWithColor(os.Stderr, fmt.Sprintf("%s shown (disable the message limit with --log-limit=0)\n",
|
||
|
errorAndWarningSummary(errors, warnings, shownErrors, shownWarnings)))
|
||
|
} else if options.LogLevel <= LevelInfo && (warnings != 0 || errors != 0) {
|
||
|
writeStringWithColor(os.Stderr, fmt.Sprintf("%s\n",
|
||
|
errorAndWarningSummary(errors, warnings, shownErrors, shownWarnings)))
|
||
|
}
|
||
|
}
|
||
|
|
||
|
switch options.Color {
|
||
|
case ColorNever:
|
||
|
terminalInfo.UseColorEscapes = false
|
||
|
case ColorAlways:
|
||
|
terminalInfo.UseColorEscapes = SupportsColorEscapes
|
||
|
}
|
||
|
|
||
|
return Log{
|
||
|
Level: options.LogLevel,
|
||
|
|
||
|
AddMsg: func(msg Msg) {
|
||
|
mutex.Lock()
|
||
|
defer mutex.Unlock()
|
||
|
msgs = append(msgs, msg)
|
||
|
|
||
|
switch msg.Kind {
|
||
|
case Verbose:
|
||
|
if options.LogLevel <= LevelVerbose {
|
||
|
writeStringWithColor(os.Stderr, msg.String(options, terminalInfo))
|
||
|
}
|
||
|
|
||
|
case Debug:
|
||
|
if options.LogLevel <= LevelDebug {
|
||
|
writeStringWithColor(os.Stderr, msg.String(options, terminalInfo))
|
||
|
}
|
||
|
|
||
|
case Info:
|
||
|
if options.LogLevel <= LevelInfo {
|
||
|
writeStringWithColor(os.Stderr, msg.String(options, terminalInfo))
|
||
|
}
|
||
|
|
||
|
case Error:
|
||
|
hasErrors = true
|
||
|
if options.LogLevel <= LevelError {
|
||
|
errors++
|
||
|
}
|
||
|
|
||
|
case Warning:
|
||
|
if options.LogLevel <= LevelWarning {
|
||
|
warnings++
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Be silent if we're past the limit so we don't flood the terminal
|
||
|
if remainingMessagesBeforeLimit == 0 {
|
||
|
return
|
||
|
}
|
||
|
|
||
|
switch msg.Kind {
|
||
|
case Error:
|
||
|
if options.LogLevel <= LevelError {
|
||
|
shownErrors++
|
||
|
writeStringWithColor(os.Stderr, msg.String(options, terminalInfo))
|
||
|
remainingMessagesBeforeLimit--
|
||
|
}
|
||
|
|
||
|
case Warning:
|
||
|
if options.LogLevel <= LevelWarning {
|
||
|
if remainingMessagesBeforeLimit > (options.MessageLimit+1)/2 {
|
||
|
shownWarnings++
|
||
|
writeStringWithColor(os.Stderr, msg.String(options, terminalInfo))
|
||
|
remainingMessagesBeforeLimit--
|
||
|
} else {
|
||
|
// If we have less than half of the slots left, wait for potential
|
||
|
// future errors instead of using up all of the slots with warnings.
|
||
|
// We want the log for a failed build to always have at least one
|
||
|
// error in it.
|
||
|
deferredWarnings = append(deferredWarnings, msg)
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
},
|
||
|
|
||
|
HasErrors: func() bool {
|
||
|
mutex.Lock()
|
||
|
defer mutex.Unlock()
|
||
|
return hasErrors
|
||
|
},
|
||
|
|
||
|
AlmostDone: func() {
|
||
|
mutex.Lock()
|
||
|
defer mutex.Unlock()
|
||
|
|
||
|
finalizeLog()
|
||
|
},
|
||
|
|
||
|
Done: func() []Msg {
|
||
|
mutex.Lock()
|
||
|
defer mutex.Unlock()
|
||
|
|
||
|
finalizeLog()
|
||
|
sort.Stable(msgs)
|
||
|
return msgs
|
||
|
},
|
||
|
}
|
||
|
}
|
||
|
|
||
|
func PrintErrorToStderr(osArgs []string, text string) {
|
||
|
PrintMessageToStderr(osArgs, Msg{Kind: Error, Data: MsgData{Text: text}})
|
||
|
}
|
||
|
|
||
|
func OutputOptionsForArgs(osArgs []string) OutputOptions {
|
||
|
options := OutputOptions{IncludeSource: true}
|
||
|
|
||
|
// Implement a mini argument parser so these options always work even if we
|
||
|
// haven't yet gotten to the general-purpose argument parsing code
|
||
|
for _, arg := range osArgs {
|
||
|
switch arg {
|
||
|
case "--color=false":
|
||
|
options.Color = ColorNever
|
||
|
case "--color=true":
|
||
|
options.Color = ColorAlways
|
||
|
case "--log-level=info":
|
||
|
options.LogLevel = LevelInfo
|
||
|
case "--log-level=warning":
|
||
|
options.LogLevel = LevelWarning
|
||
|
case "--log-level=error":
|
||
|
options.LogLevel = LevelError
|
||
|
case "--log-level=silent":
|
||
|
options.LogLevel = LevelSilent
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return options
|
||
|
}
|
||
|
|
||
|
func PrintMessageToStderr(osArgs []string, msg Msg) {
|
||
|
log := NewStderrLog(OutputOptionsForArgs(osArgs))
|
||
|
log.AddMsg(msg)
|
||
|
log.Done()
|
||
|
}
|
||
|
|
||
|
type Colors struct {
|
||
|
Reset string
|
||
|
Bold string
|
||
|
Dim string
|
||
|
Underline string
|
||
|
|
||
|
Red string
|
||
|
Green string
|
||
|
Blue string
|
||
|
|
||
|
Cyan string
|
||
|
Magenta string
|
||
|
Yellow string
|
||
|
|
||
|
RedBgRed string
|
||
|
RedBgWhite string
|
||
|
GreenBgGreen string
|
||
|
GreenBgWhite string
|
||
|
BlueBgBlue string
|
||
|
BlueBgWhite string
|
||
|
|
||
|
CyanBgCyan string
|
||
|
CyanBgBlack string
|
||
|
MagentaBgMagenta string
|
||
|
MagentaBgBlack string
|
||
|
YellowBgYellow string
|
||
|
YellowBgBlack string
|
||
|
}
|
||
|
|
||
|
var TerminalColors = Colors{
|
||
|
Reset: "\033[0m",
|
||
|
Bold: "\033[1m",
|
||
|
Dim: "\033[37m",
|
||
|
Underline: "\033[4m",
|
||
|
|
||
|
Red: "\033[31m",
|
||
|
Green: "\033[32m",
|
||
|
Blue: "\033[34m",
|
||
|
|
||
|
Cyan: "\033[36m",
|
||
|
Magenta: "\033[35m",
|
||
|
Yellow: "\033[33m",
|
||
|
|
||
|
RedBgRed: "\033[41;31m",
|
||
|
RedBgWhite: "\033[41;97m",
|
||
|
GreenBgGreen: "\033[42;32m",
|
||
|
GreenBgWhite: "\033[42;97m",
|
||
|
BlueBgBlue: "\033[44;34m",
|
||
|
BlueBgWhite: "\033[44;97m",
|
||
|
|
||
|
CyanBgCyan: "\033[46;36m",
|
||
|
CyanBgBlack: "\033[46;30m",
|
||
|
MagentaBgMagenta: "\033[45;35m",
|
||
|
MagentaBgBlack: "\033[45;30m",
|
||
|
YellowBgYellow: "\033[43;33m",
|
||
|
YellowBgBlack: "\033[43;30m",
|
||
|
}
|
||
|
|
||
|
func PrintText(file *os.File, level LogLevel, osArgs []string, callback func(Colors) string) {
|
||
|
options := OutputOptionsForArgs(osArgs)
|
||
|
|
||
|
// Skip logging these if these logs are disabled
|
||
|
if options.LogLevel > level {
|
||
|
return
|
||
|
}
|
||
|
|
||
|
PrintTextWithColor(file, options.Color, callback)
|
||
|
}
|
||
|
|
||
|
func PrintTextWithColor(file *os.File, useColor UseColor, callback func(Colors) string) {
|
||
|
var useColorEscapes bool
|
||
|
switch useColor {
|
||
|
case ColorNever:
|
||
|
useColorEscapes = false
|
||
|
case ColorAlways:
|
||
|
useColorEscapes = SupportsColorEscapes
|
||
|
case ColorIfTerminal:
|
||
|
useColorEscapes = GetTerminalInfo(file).UseColorEscapes
|
||
|
}
|
||
|
|
||
|
var colors Colors
|
||
|
if useColorEscapes {
|
||
|
colors = TerminalColors
|
||
|
}
|
||
|
writeStringWithColor(file, callback(colors))
|
||
|
}
|
||
|
|
||
|
type SummaryTableEntry struct {
|
||
|
Dir string
|
||
|
Base string
|
||
|
Size string
|
||
|
Bytes int
|
||
|
IsSourceMap bool
|
||
|
}
|
||
|
|
||
|
// This type is just so we can use Go's native sort function
|
||
|
type SummaryTable []SummaryTableEntry
|
||
|
|
||
|
func (t SummaryTable) Len() int { return len(t) }
|
||
|
func (t SummaryTable) Swap(i int, j int) { t[i], t[j] = t[j], t[i] }
|
||
|
|
||
|
func (t SummaryTable) Less(i int, j int) bool {
|
||
|
ti := t[i]
|
||
|
tj := t[j]
|
||
|
|
||
|
// Sort source maps last
|
||
|
if !ti.IsSourceMap && tj.IsSourceMap {
|
||
|
return true
|
||
|
}
|
||
|
if ti.IsSourceMap && !tj.IsSourceMap {
|
||
|
return false
|
||
|
}
|
||
|
|
||
|
// Sort by size first
|
||
|
if ti.Bytes > tj.Bytes {
|
||
|
return true
|
||
|
}
|
||
|
if ti.Bytes < tj.Bytes {
|
||
|
return false
|
||
|
}
|
||
|
|
||
|
// Sort alphabetically by directory first
|
||
|
if ti.Dir < tj.Dir {
|
||
|
return true
|
||
|
}
|
||
|
if ti.Dir > tj.Dir {
|
||
|
return false
|
||
|
}
|
||
|
|
||
|
// Then sort alphabetically by file name
|
||
|
return ti.Base < tj.Base
|
||
|
}
|
||
|
|
||
|
// Show a warning icon next to output files that are 1mb or larger
|
||
|
const sizeWarningThreshold = 1024 * 1024
|
||
|
|
||
|
func PrintSummary(useColor UseColor, table SummaryTable, start *time.Time) {
|
||
|
PrintTextWithColor(os.Stderr, useColor, func(colors Colors) string {
|
||
|
isProbablyWindowsCommandPrompt := isProbablyWindowsCommandPrompt()
|
||
|
sb := strings.Builder{}
|
||
|
|
||
|
if len(table) > 0 {
|
||
|
info := GetTerminalInfo(os.Stderr)
|
||
|
|
||
|
// Truncate the table in case it's really long
|
||
|
maxLength := info.Height / 2
|
||
|
if info.Height == 0 {
|
||
|
maxLength = 20
|
||
|
} else if maxLength < 5 {
|
||
|
maxLength = 5
|
||
|
}
|
||
|
length := len(table)
|
||
|
sort.Sort(table)
|
||
|
if length > maxLength {
|
||
|
table = table[:maxLength]
|
||
|
}
|
||
|
|
||
|
// Compute the maximum width of the size column
|
||
|
spacingBetweenColumns := 2
|
||
|
hasSizeWarning := false
|
||
|
maxPath := 0
|
||
|
maxSize := 0
|
||
|
for _, entry := range table {
|
||
|
path := len(entry.Dir) + len(entry.Base)
|
||
|
size := len(entry.Size) + spacingBetweenColumns
|
||
|
if path > maxPath {
|
||
|
maxPath = path
|
||
|
}
|
||
|
if size > maxSize {
|
||
|
maxSize = size
|
||
|
}
|
||
|
if !entry.IsSourceMap && entry.Bytes >= sizeWarningThreshold {
|
||
|
hasSizeWarning = true
|
||
|
}
|
||
|
}
|
||
|
|
||
|
margin := " "
|
||
|
layoutWidth := info.Width
|
||
|
if layoutWidth < 1 {
|
||
|
layoutWidth = defaultTerminalWidth
|
||
|
}
|
||
|
layoutWidth -= 2 * len(margin)
|
||
|
if hasSizeWarning {
|
||
|
// Add space for the warning icon
|
||
|
layoutWidth -= 2
|
||
|
}
|
||
|
if layoutWidth > maxPath+maxSize {
|
||
|
layoutWidth = maxPath + maxSize
|
||
|
}
|
||
|
sb.WriteByte('\n')
|
||
|
|
||
|
for _, entry := range table {
|
||
|
dir, base := entry.Dir, entry.Base
|
||
|
pathWidth := layoutWidth - maxSize
|
||
|
|
||
|
// Truncate the path with "..." to fit on one line
|
||
|
if len(dir)+len(base) > pathWidth {
|
||
|
// Trim the directory from the front, leaving the trailing slash
|
||
|
if len(dir) > 0 {
|
||
|
n := pathWidth - len(base) - 3
|
||
|
if n < 1 {
|
||
|
n = 1
|
||
|
}
|
||
|
dir = "..." + dir[len(dir)-n:]
|
||
|
}
|
||
|
|
||
|
// Trim the file name from the back
|
||
|
if len(dir)+len(base) > pathWidth {
|
||
|
n := pathWidth - len(dir) - 3
|
||
|
if n < 0 {
|
||
|
n = 0
|
||
|
}
|
||
|
base = base[:n] + "..."
|
||
|
}
|
||
|
}
|
||
|
|
||
|
spacer := layoutWidth - len(entry.Size) - len(dir) - len(base)
|
||
|
if spacer < 0 {
|
||
|
spacer = 0
|
||
|
}
|
||
|
|
||
|
// Put a warning next to the size if it's above a certain threshold
|
||
|
sizeColor := colors.Cyan
|
||
|
sizeWarning := ""
|
||
|
if !entry.IsSourceMap && entry.Bytes >= sizeWarningThreshold {
|
||
|
sizeColor = colors.Yellow
|
||
|
|
||
|
// Emoji don't work in Windows Command Prompt
|
||
|
if !isProbablyWindowsCommandPrompt {
|
||
|
sizeWarning = " ⚠️"
|
||
|
}
|
||
|
}
|
||
|
|
||
|
sb.WriteString(fmt.Sprintf("%s%s%s%s%s%s%s%s%s%s%s%s\n",
|
||
|
margin,
|
||
|
colors.Dim,
|
||
|
dir,
|
||
|
colors.Reset,
|
||
|
colors.Bold,
|
||
|
base,
|
||
|
colors.Reset,
|
||
|
strings.Repeat(" ", spacer),
|
||
|
sizeColor,
|
||
|
entry.Size,
|
||
|
sizeWarning,
|
||
|
colors.Reset,
|
||
|
))
|
||
|
}
|
||
|
|
||
|
// Say how many remaining files are not shown
|
||
|
if length > maxLength {
|
||
|
plural := "s"
|
||
|
if length == maxLength+1 {
|
||
|
plural = ""
|
||
|
}
|
||
|
sb.WriteString(fmt.Sprintf("%s%s...and %d more output file%s...%s\n", margin, colors.Dim, length-maxLength, plural, colors.Reset))
|
||
|
}
|
||
|
}
|
||
|
sb.WriteByte('\n')
|
||
|
|
||
|
lightningSymbol := "⚡ "
|
||
|
|
||
|
// Emoji don't work in Windows Command Prompt
|
||
|
if isProbablyWindowsCommandPrompt {
|
||
|
lightningSymbol = ""
|
||
|
}
|
||
|
|
||
|
// Printing the time taken is optional
|
||
|
if start != nil {
|
||
|
sb.WriteString(fmt.Sprintf("%s%sDone in %dms%s\n",
|
||
|
lightningSymbol,
|
||
|
colors.Green,
|
||
|
time.Since(*start).Milliseconds(),
|
||
|
colors.Reset,
|
||
|
))
|
||
|
}
|
||
|
|
||
|
return sb.String()
|
||
|
})
|
||
|
}
|
||
|
|
||
|
type DeferLogKind uint8
|
||
|
|
||
|
const (
|
||
|
DeferLogAll DeferLogKind = iota
|
||
|
DeferLogNoVerboseOrDebug
|
||
|
)
|
||
|
|
||
|
func NewDeferLog(kind DeferLogKind) Log {
|
||
|
var msgs SortableMsgs
|
||
|
var mutex sync.Mutex
|
||
|
var hasErrors bool
|
||
|
|
||
|
return Log{
|
||
|
Level: LevelInfo,
|
||
|
|
||
|
AddMsg: func(msg Msg) {
|
||
|
if kind == DeferLogNoVerboseOrDebug && (msg.Kind == Verbose || msg.Kind == Debug) {
|
||
|
return
|
||
|
}
|
||
|
mutex.Lock()
|
||
|
defer mutex.Unlock()
|
||
|
if msg.Kind == Error {
|
||
|
hasErrors = true
|
||
|
}
|
||
|
msgs = append(msgs, msg)
|
||
|
},
|
||
|
|
||
|
HasErrors: func() bool {
|
||
|
mutex.Lock()
|
||
|
defer mutex.Unlock()
|
||
|
return hasErrors
|
||
|
},
|
||
|
|
||
|
AlmostDone: func() {
|
||
|
},
|
||
|
|
||
|
Done: func() []Msg {
|
||
|
mutex.Lock()
|
||
|
defer mutex.Unlock()
|
||
|
sort.Stable(msgs)
|
||
|
return msgs
|
||
|
},
|
||
|
}
|
||
|
}
|
||
|
|
||
|
type UseColor uint8
|
||
|
|
||
|
const (
|
||
|
ColorIfTerminal UseColor = iota
|
||
|
ColorNever
|
||
|
ColorAlways
|
||
|
)
|
||
|
|
||
|
type OutputOptions struct {
|
||
|
IncludeSource bool
|
||
|
MessageLimit int
|
||
|
Color UseColor
|
||
|
LogLevel LogLevel
|
||
|
}
|
||
|
|
||
|
func (msg Msg) String(options OutputOptions, terminalInfo TerminalInfo) string {
|
||
|
// Format the message
|
||
|
text := msgString(options.IncludeSource, terminalInfo, msg.Kind, msg.Data, msg.PluginName)
|
||
|
|
||
|
// Format the notes
|
||
|
var oldData MsgData
|
||
|
for i, note := range msg.Notes {
|
||
|
if options.IncludeSource && (i == 0 || strings.IndexByte(oldData.Text, '\n') >= 0 || oldData.Location != nil) {
|
||
|
text += "\n"
|
||
|
}
|
||
|
text += msgString(options.IncludeSource, terminalInfo, Note, note, "")
|
||
|
oldData = note
|
||
|
}
|
||
|
|
||
|
// Add extra spacing between messages if source code is present
|
||
|
if options.IncludeSource {
|
||
|
text += "\n"
|
||
|
}
|
||
|
return text
|
||
|
}
|
||
|
|
||
|
// The number of margin characters in addition to the line number
|
||
|
const extraMarginChars = 9
|
||
|
|
||
|
func marginWithLineText(maxMargin int, line int) string {
|
||
|
number := fmt.Sprintf("%d", line)
|
||
|
return fmt.Sprintf(" %s%s │ ", strings.Repeat(" ", maxMargin-len(number)), number)
|
||
|
}
|
||
|
|
||
|
func emptyMarginText(maxMargin int, isLast bool) string {
|
||
|
space := strings.Repeat(" ", maxMargin)
|
||
|
if isLast {
|
||
|
return fmt.Sprintf(" %s ╵ ", space)
|
||
|
}
|
||
|
return fmt.Sprintf(" %s │ ", space)
|
||
|
}
|
||
|
|
||
|
func msgString(includeSource bool, terminalInfo TerminalInfo, kind MsgKind, data MsgData, pluginName string) string {
|
||
|
if !includeSource {
|
||
|
if loc := data.Location; loc != nil {
|
||
|
return fmt.Sprintf("%s: %s: %s\n", loc.File, kind.String(), data.Text)
|
||
|
}
|
||
|
return fmt.Sprintf("%s: %s\n", kind.String(), data.Text)
|
||
|
}
|
||
|
|
||
|
var colors Colors
|
||
|
if terminalInfo.UseColorEscapes {
|
||
|
colors = TerminalColors
|
||
|
}
|
||
|
|
||
|
var iconColor string
|
||
|
var kindColorBrackets string
|
||
|
var kindColorText string
|
||
|
|
||
|
location := ""
|
||
|
|
||
|
if data.Location != nil {
|
||
|
maxMargin := len(fmt.Sprintf("%d", data.Location.Line))
|
||
|
d := detailStruct(data, terminalInfo, maxMargin)
|
||
|
|
||
|
if d.Suggestion != "" {
|
||
|
location = fmt.Sprintf("\n %s:%d:%d:\n%s%s%s%s%s%s\n%s%s%s%s%s\n%s%s%s%s%s\n%s",
|
||
|
d.Path, d.Line, d.Column,
|
||
|
colors.Dim, d.SourceBefore, colors.Green, d.SourceMarked, colors.Dim, d.SourceAfter,
|
||
|
emptyMarginText(maxMargin, false), d.Indent, colors.Green, d.Marker, colors.Dim,
|
||
|
emptyMarginText(maxMargin, true), d.Indent, colors.Green, d.Suggestion, colors.Reset,
|
||
|
d.ContentAfter,
|
||
|
)
|
||
|
} else {
|
||
|
location = fmt.Sprintf("\n %s:%d:%d:\n%s%s%s%s%s%s\n%s%s%s%s%s\n%s",
|
||
|
d.Path, d.Line, d.Column,
|
||
|
colors.Dim, d.SourceBefore, colors.Green, d.SourceMarked, colors.Dim, d.SourceAfter,
|
||
|
emptyMarginText(maxMargin, true), d.Indent, colors.Green, d.Marker, colors.Reset,
|
||
|
d.ContentAfter,
|
||
|
)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
switch kind {
|
||
|
case Verbose:
|
||
|
iconColor = colors.Cyan
|
||
|
kindColorBrackets = colors.CyanBgCyan
|
||
|
kindColorText = colors.CyanBgBlack
|
||
|
|
||
|
case Debug:
|
||
|
iconColor = colors.Green
|
||
|
kindColorBrackets = colors.GreenBgGreen
|
||
|
kindColorText = colors.GreenBgWhite
|
||
|
|
||
|
case Info:
|
||
|
iconColor = colors.Blue
|
||
|
kindColorBrackets = colors.BlueBgBlue
|
||
|
kindColorText = colors.BlueBgWhite
|
||
|
|
||
|
case Error:
|
||
|
iconColor = colors.Red
|
||
|
kindColorBrackets = colors.RedBgRed
|
||
|
kindColorText = colors.RedBgWhite
|
||
|
|
||
|
case Warning:
|
||
|
iconColor = colors.Yellow
|
||
|
kindColorBrackets = colors.YellowBgYellow
|
||
|
kindColorText = colors.YellowBgBlack
|
||
|
|
||
|
case Note:
|
||
|
sb := strings.Builder{}
|
||
|
|
||
|
for _, line := range strings.Split(data.Text, "\n") {
|
||
|
// Special-case word wrapping
|
||
|
if wrapWidth := terminalInfo.Width; wrapWidth > 2 {
|
||
|
if wrapWidth > 100 {
|
||
|
wrapWidth = 100 // Enforce a maximum paragraph width for readability
|
||
|
}
|
||
|
for _, run := range wrapWordsInString(line, wrapWidth-2) {
|
||
|
sb.WriteString(" ")
|
||
|
sb.WriteString(linkifyText(run, colors.Underline, colors.Reset))
|
||
|
sb.WriteByte('\n')
|
||
|
}
|
||
|
continue
|
||
|
}
|
||
|
|
||
|
// Otherwise, just write an indented line
|
||
|
sb.WriteString(" ")
|
||
|
sb.WriteString(linkifyText(line, colors.Underline, colors.Reset))
|
||
|
sb.WriteByte('\n')
|
||
|
}
|
||
|
|
||
|
sb.WriteString(location)
|
||
|
return sb.String()
|
||
|
}
|
||
|
|
||
|
if pluginName != "" {
|
||
|
pluginName = fmt.Sprintf("%s%s[plugin %s]%s ", colors.Bold, colors.Magenta, pluginName, colors.Reset)
|
||
|
}
|
||
|
|
||
|
return fmt.Sprintf("%s%s %s[%s%s%s]%s %s%s%s%s\n%s",
|
||
|
iconColor, kind.Icon(),
|
||
|
kindColorBrackets, kindColorText, kind.String(), kindColorBrackets, colors.Reset,
|
||
|
pluginName,
|
||
|
colors.Bold, data.Text, colors.Reset,
|
||
|
location,
|
||
|
)
|
||
|
}
|
||
|
|
||
|
func linkifyText(text string, underline string, reset string) string {
|
||
|
if underline == "" {
|
||
|
return text
|
||
|
}
|
||
|
|
||
|
https := strings.Index(text, "https://")
|
||
|
if https == -1 {
|
||
|
return text
|
||
|
}
|
||
|
|
||
|
sb := strings.Builder{}
|
||
|
for {
|
||
|
https := strings.Index(text, "https://")
|
||
|
if https == -1 {
|
||
|
break
|
||
|
}
|
||
|
|
||
|
end := strings.IndexByte(text[https:], ' ')
|
||
|
if end == -1 {
|
||
|
end = len(text)
|
||
|
} else {
|
||
|
end += https
|
||
|
}
|
||
|
|
||
|
// Remove trailing punctuation
|
||
|
if end > https {
|
||
|
switch text[end-1] {
|
||
|
case '.', ',', '?', '!', ')', ']', '}':
|
||
|
end--
|
||
|
}
|
||
|
}
|
||
|
|
||
|
sb.WriteString(text[:https])
|
||
|
sb.WriteString(underline)
|
||
|
sb.WriteString(text[https:end])
|
||
|
sb.WriteString(reset)
|
||
|
text = text[end:]
|
||
|
}
|
||
|
|
||
|
sb.WriteString(text)
|
||
|
return sb.String()
|
||
|
}
|
||
|
|
||
|
func wrapWordsInString(text string, width int) []string {
|
||
|
runs := []string{}
|
||
|
|
||
|
outer:
|
||
|
for text != "" {
|
||
|
i := 0
|
||
|
x := 0
|
||
|
wordEndI := 0
|
||
|
|
||
|
// Skip over any leading spaces
|
||
|
for i < len(text) && text[i] == ' ' {
|
||
|
i++
|
||
|
x++
|
||
|
}
|
||
|
|
||
|
// Find out how many words will fit in this run
|
||
|
for i < len(text) {
|
||
|
oldWordEndI := wordEndI
|
||
|
wordStartI := i
|
||
|
|
||
|
// Find the end of the word
|
||
|
for i < len(text) {
|
||
|
c, width := utf8.DecodeRuneInString(text[i:])
|
||
|
if c == ' ' {
|
||
|
break
|
||
|
}
|
||
|
i += width
|
||
|
x += 1 // Naively assume that each unicode code point is a single column
|
||
|
}
|
||
|
wordEndI = i
|
||
|
|
||
|
// Split into a new run if this isn't the first word in the run and the end is past the width
|
||
|
if wordStartI > 0 && x > width {
|
||
|
runs = append(runs, text[:oldWordEndI])
|
||
|
text = text[wordStartI:]
|
||
|
continue outer
|
||
|
}
|
||
|
|
||
|
// Skip over any spaces after the word
|
||
|
for i < len(text) && text[i] == ' ' {
|
||
|
i++
|
||
|
x++
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// If we get here, this is the last run (i.e. everything fits)
|
||
|
break
|
||
|
}
|
||
|
|
||
|
// Remove any trailing spaces on the last run
|
||
|
for len(text) > 0 && text[len(text)-1] == ' ' {
|
||
|
text = text[:len(text)-1]
|
||
|
}
|
||
|
runs = append(runs, text)
|
||
|
return runs
|
||
|
}
|
||
|
|
||
|
type MsgDetail struct {
|
||
|
Path string
|
||
|
Line int
|
||
|
Column int
|
||
|
|
||
|
SourceBefore string
|
||
|
SourceMarked string
|
||
|
SourceAfter string
|
||
|
|
||
|
Indent string
|
||
|
Marker string
|
||
|
Suggestion string
|
||
|
|
||
|
ContentAfter string
|
||
|
}
|
||
|
|
||
|
// It's not common for large files to have many warnings. But when it happens,
|
||
|
// we want to make sure that it's not too slow. Source code locations are
|
||
|
// represented as byte offsets for compactness but transforming these to
|
||
|
// line/column locations for warning messages requires scanning through the
|
||
|
// file. A naive approach for this would cause O(n^2) scanning time for n
|
||
|
// warnings distributed throughout the file.
|
||
|
//
|
||
|
// Warnings are typically generated sequentially as the file is scanned. So
|
||
|
// one way of optimizing this is to just start scanning from where we left
|
||
|
// off last time instead of always starting from the beginning of the file.
|
||
|
// That's what this object does.
|
||
|
//
|
||
|
// Another option could be to eagerly populate an array of line/column offsets
|
||
|
// and then use binary search for each query. This might slow down the common
|
||
|
// case of a file with only at most a few warnings though, so think before
|
||
|
// optimizing too much. Performance in the zero or one warning case is by far
|
||
|
// the most important.
|
||
|
type LineColumnTracker struct {
|
||
|
contents string
|
||
|
prettyPath string
|
||
|
offset int32
|
||
|
line int32
|
||
|
lineStart int32
|
||
|
lineEnd int32
|
||
|
hasLineStart bool
|
||
|
hasLineEnd bool
|
||
|
hasSource bool
|
||
|
}
|
||
|
|
||
|
func MakeLineColumnTracker(source *Source) LineColumnTracker {
|
||
|
if source == nil {
|
||
|
return LineColumnTracker{
|
||
|
hasSource: false,
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return LineColumnTracker{
|
||
|
contents: source.Contents,
|
||
|
prettyPath: source.PrettyPath,
|
||
|
hasLineStart: true,
|
||
|
hasSource: true,
|
||
|
}
|
||
|
}
|
||
|
|
||
|
func (tracker *LineColumnTracker) MsgData(r Range, text string) MsgData {
|
||
|
return MsgData{
|
||
|
Text: text,
|
||
|
Location: tracker.MsgLocationOrNil(r),
|
||
|
}
|
||
|
}
|
||
|
|
||
|
func (t *LineColumnTracker) scanTo(offset int32) {
|
||
|
contents := t.contents
|
||
|
i := t.offset
|
||
|
|
||
|
// Scan forward
|
||
|
if i < offset {
|
||
|
for {
|
||
|
r, size := utf8.DecodeRuneInString(contents[i:])
|
||
|
i += int32(size)
|
||
|
|
||
|
switch r {
|
||
|
case '\n':
|
||
|
t.hasLineStart = true
|
||
|
t.hasLineEnd = false
|
||
|
t.lineStart = i
|
||
|
if i == int32(size) || contents[i-int32(size)-1] != '\r' {
|
||
|
t.line++
|
||
|
}
|
||
|
|
||
|
case '\r', '\u2028', '\u2029':
|
||
|
t.hasLineStart = true
|
||
|
t.hasLineEnd = false
|
||
|
t.lineStart = i
|
||
|
t.line++
|
||
|
}
|
||
|
|
||
|
if i >= offset {
|
||
|
t.offset = i
|
||
|
return
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Scan backward
|
||
|
if i > offset {
|
||
|
for {
|
||
|
r, size := utf8.DecodeLastRuneInString(contents[:i])
|
||
|
i -= int32(size)
|
||
|
|
||
|
switch r {
|
||
|
case '\n':
|
||
|
t.hasLineStart = false
|
||
|
t.hasLineEnd = true
|
||
|
t.lineEnd = i
|
||
|
if i == 0 || contents[i-1] != '\r' {
|
||
|
t.line--
|
||
|
}
|
||
|
|
||
|
case '\r', '\u2028', '\u2029':
|
||
|
t.hasLineStart = false
|
||
|
t.hasLineEnd = true
|
||
|
t.lineEnd = i
|
||
|
t.line--
|
||
|
}
|
||
|
|
||
|
if i <= offset {
|
||
|
t.offset = i
|
||
|
return
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
func (t *LineColumnTracker) computeLineAndColumn(offset int) (lineCount int, columnCount int, lineStart int, lineEnd int) {
|
||
|
t.scanTo(int32(offset))
|
||
|
|
||
|
// Scan for the start of the line
|
||
|
if !t.hasLineStart {
|
||
|
contents := t.contents
|
||
|
i := t.offset
|
||
|
for i > 0 {
|
||
|
r, size := utf8.DecodeLastRuneInString(contents[:i])
|
||
|
if r == '\n' || r == '\r' || r == '\u2028' || r == '\u2029' {
|
||
|
break
|
||
|
}
|
||
|
i -= int32(size)
|
||
|
}
|
||
|
t.hasLineStart = true
|
||
|
t.lineStart = i
|
||
|
}
|
||
|
|
||
|
// Scan for the end of the line
|
||
|
if !t.hasLineEnd {
|
||
|
contents := t.contents
|
||
|
i := t.offset
|
||
|
n := int32(len(contents))
|
||
|
for i < n {
|
||
|
r, size := utf8.DecodeRuneInString(contents[i:])
|
||
|
if r == '\n' || r == '\r' || r == '\u2028' || r == '\u2029' {
|
||
|
break
|
||
|
}
|
||
|
i += int32(size)
|
||
|
}
|
||
|
t.hasLineEnd = true
|
||
|
t.lineEnd = i
|
||
|
}
|
||
|
|
||
|
return int(t.line), offset - int(t.lineStart), int(t.lineStart), int(t.lineEnd)
|
||
|
}
|
||
|
|
||
|
func (tracker *LineColumnTracker) MsgLocationOrNil(r Range) *MsgLocation {
|
||
|
if tracker == nil || !tracker.hasSource {
|
||
|
return nil
|
||
|
}
|
||
|
|
||
|
// Convert the index into a line and column number
|
||
|
lineCount, columnCount, lineStart, lineEnd := tracker.computeLineAndColumn(int(r.Loc.Start))
|
||
|
|
||
|
return &MsgLocation{
|
||
|
File: tracker.prettyPath,
|
||
|
Line: lineCount + 1, // 0-based to 1-based
|
||
|
Column: columnCount,
|
||
|
Length: int(r.Len),
|
||
|
LineText: tracker.contents[lineStart:lineEnd],
|
||
|
}
|
||
|
}
|
||
|
|
||
|
func detailStruct(data MsgData, terminalInfo TerminalInfo, maxMargin int) MsgDetail {
|
||
|
// Only highlight the first line of the line text
|
||
|
loc := *data.Location
|
||
|
endOfFirstLine := len(loc.LineText)
|
||
|
for i, c := range loc.LineText {
|
||
|
if c == '\r' || c == '\n' || c == '\u2028' || c == '\u2029' {
|
||
|
endOfFirstLine = i
|
||
|
break
|
||
|
}
|
||
|
}
|
||
|
firstLine := loc.LineText[:endOfFirstLine]
|
||
|
afterFirstLine := loc.LineText[endOfFirstLine:]
|
||
|
if afterFirstLine != "" && !strings.HasSuffix(afterFirstLine, "\n") {
|
||
|
afterFirstLine += "\n"
|
||
|
}
|
||
|
|
||
|
// Clamp values in range
|
||
|
if loc.Line < 0 {
|
||
|
loc.Line = 0
|
||
|
}
|
||
|
if loc.Column < 0 {
|
||
|
loc.Column = 0
|
||
|
}
|
||
|
if loc.Length < 0 {
|
||
|
loc.Length = 0
|
||
|
}
|
||
|
if loc.Column > endOfFirstLine {
|
||
|
loc.Column = endOfFirstLine
|
||
|
}
|
||
|
if loc.Length > endOfFirstLine-loc.Column {
|
||
|
loc.Length = endOfFirstLine - loc.Column
|
||
|
}
|
||
|
|
||
|
spacesPerTab := 2
|
||
|
lineText := renderTabStops(firstLine, spacesPerTab)
|
||
|
textUpToLoc := renderTabStops(firstLine[:loc.Column], spacesPerTab)
|
||
|
markerStart := len(textUpToLoc)
|
||
|
markerEnd := markerStart
|
||
|
indent := strings.Repeat(" ", estimateWidthInTerminal(textUpToLoc))
|
||
|
marker := "^"
|
||
|
|
||
|
// Extend markers to cover the full range of the error
|
||
|
if loc.Length > 0 {
|
||
|
markerEnd = len(renderTabStops(firstLine[:loc.Column+loc.Length], spacesPerTab))
|
||
|
}
|
||
|
|
||
|
// Clip the marker to the bounds of the line
|
||
|
if markerStart > len(lineText) {
|
||
|
markerStart = len(lineText)
|
||
|
}
|
||
|
if markerEnd > len(lineText) {
|
||
|
markerEnd = len(lineText)
|
||
|
}
|
||
|
if markerEnd < markerStart {
|
||
|
markerEnd = markerStart
|
||
|
}
|
||
|
|
||
|
// Trim the line to fit the terminal width
|
||
|
width := terminalInfo.Width
|
||
|
if width < 1 {
|
||
|
width = defaultTerminalWidth
|
||
|
}
|
||
|
width -= maxMargin + extraMarginChars
|
||
|
if width < 1 {
|
||
|
width = 1
|
||
|
}
|
||
|
if loc.Column == endOfFirstLine {
|
||
|
// If the marker is at the very end of the line, the marker will be a "^"
|
||
|
// character that extends one column past the end of the line. In this case
|
||
|
// we should reserve a column at the end so the marker doesn't wrap.
|
||
|
width -= 1
|
||
|
}
|
||
|
if len(lineText) > width {
|
||
|
// Try to center the error
|
||
|
sliceStart := (markerStart + markerEnd - width) / 2
|
||
|
if sliceStart > markerStart-width/5 {
|
||
|
sliceStart = markerStart - width/5
|
||
|
}
|
||
|
if sliceStart < 0 {
|
||
|
sliceStart = 0
|
||
|
}
|
||
|
if sliceStart > len(lineText)-width {
|
||
|
sliceStart = len(lineText) - width
|
||
|
}
|
||
|
sliceEnd := sliceStart + width
|
||
|
|
||
|
// Slice the line
|
||
|
slicedLine := lineText[sliceStart:sliceEnd]
|
||
|
markerStart -= sliceStart
|
||
|
markerEnd -= sliceStart
|
||
|
if markerStart < 0 {
|
||
|
markerStart = 0
|
||
|
}
|
||
|
if markerEnd > len(slicedLine) {
|
||
|
markerEnd = len(slicedLine)
|
||
|
}
|
||
|
|
||
|
// Truncate the ends with "..."
|
||
|
if len(slicedLine) > 3 && sliceStart > 0 {
|
||
|
slicedLine = "..." + slicedLine[3:]
|
||
|
if markerStart < 3 {
|
||
|
markerStart = 3
|
||
|
}
|
||
|
}
|
||
|
if len(slicedLine) > 3 && sliceEnd < len(lineText) {
|
||
|
slicedLine = slicedLine[:len(slicedLine)-3] + "..."
|
||
|
if markerEnd > len(slicedLine)-3 {
|
||
|
markerEnd = len(slicedLine) - 3
|
||
|
}
|
||
|
if markerEnd < markerStart {
|
||
|
markerEnd = markerStart
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Now we can compute the indent
|
||
|
lineText = slicedLine
|
||
|
indent = strings.Repeat(" ", estimateWidthInTerminal(lineText[:markerStart]))
|
||
|
}
|
||
|
|
||
|
// If marker is still multi-character after clipping, make the marker wider
|
||
|
if markerEnd-markerStart > 1 {
|
||
|
marker = strings.Repeat("~", estimateWidthInTerminal(lineText[markerStart:markerEnd]))
|
||
|
}
|
||
|
|
||
|
// Put a margin before the marker indent
|
||
|
margin := marginWithLineText(maxMargin, loc.Line)
|
||
|
|
||
|
return MsgDetail{
|
||
|
Path: loc.File,
|
||
|
Line: loc.Line,
|
||
|
Column: loc.Column,
|
||
|
|
||
|
SourceBefore: margin + lineText[:markerStart],
|
||
|
SourceMarked: lineText[markerStart:markerEnd],
|
||
|
SourceAfter: lineText[markerEnd:],
|
||
|
|
||
|
Indent: indent,
|
||
|
Marker: marker,
|
||
|
Suggestion: loc.Suggestion,
|
||
|
|
||
|
ContentAfter: afterFirstLine,
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Estimate the number of columns this string will take when printed
|
||
|
func estimateWidthInTerminal(text string) int {
|
||
|
// For now just assume each code point is one column. This is wrong but is
|
||
|
// less wrong than assuming each code unit is one column.
|
||
|
width := 0
|
||
|
for text != "" {
|
||
|
c, size := utf8.DecodeRuneInString(text)
|
||
|
text = text[size:]
|
||
|
|
||
|
// Ignore the Zero Width No-Break Space character (UTF-8 BOM)
|
||
|
if c != 0xFEFF {
|
||
|
width++
|
||
|
}
|
||
|
}
|
||
|
return width
|
||
|
}
|
||
|
|
||
|
func renderTabStops(withTabs string, spacesPerTab int) string {
|
||
|
if !strings.ContainsRune(withTabs, '\t') {
|
||
|
return withTabs
|
||
|
}
|
||
|
|
||
|
withoutTabs := strings.Builder{}
|
||
|
count := 0
|
||
|
|
||
|
for _, c := range withTabs {
|
||
|
if c == '\t' {
|
||
|
spaces := spacesPerTab - count%spacesPerTab
|
||
|
for i := 0; i < spaces; i++ {
|
||
|
withoutTabs.WriteRune(' ')
|
||
|
count++
|
||
|
}
|
||
|
} else {
|
||
|
withoutTabs.WriteRune(c)
|
||
|
count++
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return withoutTabs.String()
|
||
|
}
|
||
|
|
||
|
func (log Log) Add(kind MsgKind, tracker *LineColumnTracker, r Range, text string) {
|
||
|
log.AddMsg(Msg{
|
||
|
Kind: kind,
|
||
|
Data: tracker.MsgData(r, text),
|
||
|
})
|
||
|
}
|
||
|
|
||
|
func (log Log) AddWithNotes(kind MsgKind, tracker *LineColumnTracker, r Range, text string, notes []MsgData) {
|
||
|
log.AddMsg(Msg{
|
||
|
Kind: kind,
|
||
|
Data: tracker.MsgData(r, text),
|
||
|
Notes: notes,
|
||
|
})
|
||
|
}
|