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, }) }