fix #4460: add --log-style=visualstudio

This commit is contained in:
Evan Wallace
2026-08-07 22:44:15 -04:00
parent d2d0d847c7
commit 5be5c1401b
12 changed files with 626 additions and 445 deletions
+13
View File
@@ -63,6 +63,19 @@
See [microsoft/TypeScript#63527](https://github.com/microsoft/TypeScript/issues/63527) for more information.
* Add support for formatting errors for Visual Studio ([#4460](https://github.com/evanw/esbuild/issues/4460))
Visual Studio has a specific style that it expects log messages to be in for them to show up in the UI when esbuild is run as a custom build step. The current log style that esbuild uses doesn't conform to this specific style.
With this release, esbuild has a new log style for Visual Studio (and other tools in the MSBuild ecosystem) that can be enabled with `--log-style=visualstudio`. Here is an example log message in this style:
```
$ esbuild example.ts --log-style=visualstudio
/Users/evan/dev/esbuild/example.ts(1,29): warning: Operator "*" should not directly follow a TypeScript type cast after the "+" operator
```
This visual style is also available via the JS and Go APIs, and can now be used with the existing `formatMessages` API.
## 0.28.1
* Disallow ``\`` in local development server HTTP requests ([GHSA-g7r4-m6w7-qqqr](https://github.com/evanw/esbuild/security/advisories/GHSA-g7r4-m6w7-qqqr))
+15
View File
@@ -1245,6 +1245,21 @@ func (service *serviceType) handleFormatMessagesRequest(id uint32, request map[s
if value, ok := request["terminalWidth"].(int); ok {
options.TerminalWidth = value
}
if logStyle, ok := request["logStyle"].(string); ok {
switch logStyle {
case "default":
options.LogStyle = api.LogStyleDefault
case "visualstudio":
options.LogStyle = api.LogStyleVisualStudio
default:
return encodePacket(packet{
id: id,
value: map[string]interface{}{
"error": fmt.Sprintf("Invalid log style: %q", logStyle),
},
})
}
}
result := api.FormatMessages(msgs, options)
+19 -444
View File
@@ -47,6 +47,13 @@ const (
LevelSilent
)
type LogStyle int8
const (
StyleDefault LogStyle = iota
StyleVisualStudio
)
type MsgKind uint8
const (
@@ -839,6 +846,7 @@ func OutputOptionsForArgs(osArgs []string) OutputOptions {
options.Color = ColorNever
case "--color=true", "--color":
options.Color = ColorAlways
case "--log-level=info":
options.LogLevel = LevelInfo
case "--log-level=warning":
@@ -847,6 +855,11 @@ func OutputOptionsForArgs(osArgs []string) OutputOptions {
options.LogLevel = LevelError
case "--log-level=silent":
options.LogLevel = LevelSilent
case "--log-style=default":
options.LogStyle = StyleDefault
case "--log-level=visualstudio":
options.LogStyle = StyleVisualStudio
}
}
@@ -1202,275 +1215,24 @@ type OutputOptions struct {
IncludeSource bool
Color UseColor
LogLevel LogLevel
LogStyle LogStyle
PathStyle PathStyle
Overrides map[MsgID]LogLevel
}
func (msg Msg) String(options OutputOptions, terminalInfo TerminalInfo) string {
// Format the message
var text strings.Builder
text.WriteString(msgString(options.IncludeSource, options.PathStyle, terminalInfo, msg.ID, msg.Kind, msg.Data, msg.PluginName))
switch options.LogStyle {
case StyleVisualStudio:
return msgToStringVisualStudio(msg, options, terminalInfo)
// 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.WriteString("\n")
}
text.WriteString(msgString(options.IncludeSource, options.PathStyle, terminalInfo, MsgID_None, Note, note, ""))
oldData = note
default:
return msgToStringDefault(msg, options, terminalInfo)
}
// Add extra spacing between messages if source code is present
if options.IncludeSource {
text.WriteString("\n")
}
return text.String()
}
// 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, pathStyle PathStyle, terminalInfo TerminalInfo, id MsgID, kind MsgKind, data MsgData, pluginName string) string {
if !includeSource {
if loc := data.Location; loc != nil {
return fmt.Sprintf("%s: %s: %s\n", loc.File.Select(pathStyle), 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, pathStyle, 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 !data.DisableMaximumWidth && 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)
}
msgID := MsgIDToString(id)
if msgID != "" {
msgID = fmt.Sprintf(" [%s]", msgID)
}
return fmt.Sprintf("%s%s %s[%s%s%s]%s %s%s%s%s%s\n%s",
iconColor, kind.Icon(),
kindColorBrackets, kindColorText, kind.String(), kindColorBrackets, colors.Reset,
colors.Bold, data.Text, colors.Reset, pluginName, msgID,
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 {
SourceBefore string
SourceMarked string
SourceAfter string
Indent string
Marker string
Suggestion string
ContentAfter string
Path string
Line int
Column int
}
// 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
@@ -1639,193 +1401,6 @@ func (tracker *LineColumnTracker) MsgLocationOrNil(r Range) *MsgLocation {
}
}
func detailStruct(data MsgData, pathStyle PathStyle, terminalInfo TerminalInfo, maxMargin int) MsgDetail {
// Only highlight the first line of the line text
loc := *data.Location
endOfFirstLine := len(loc.LineText)
// Note: This uses "IndexByte" because Go implements this with SIMD, which
// can matter a lot for really long lines. Some people pass huge >100mb
// minified files as line text for the log message.
if i := strings.IndexByte(loc.LineText, '\n'); i >= 0 {
endOfFirstLine = i
}
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.Select(pathStyle),
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) AddError(tracker *LineColumnTracker, r Range, text string) {
log.AddMsg(Msg{
Kind: Error,
+460
View File
@@ -0,0 +1,460 @@
package logger
import (
"fmt"
"strings"
"unicode/utf8"
)
func msgToStringDefault(msg Msg, options OutputOptions, terminalInfo TerminalInfo) string {
// Format the message
var text strings.Builder
text.WriteString(msgString(options.IncludeSource, options.PathStyle, terminalInfo, msg.ID, 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.WriteString("\n")
}
text.WriteString(msgString(options.IncludeSource, options.PathStyle, terminalInfo, MsgID_None, Note, note, ""))
oldData = note
}
// Add extra spacing between messages if source code is present
if options.IncludeSource {
text.WriteString("\n")
}
return text.String()
}
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, pathStyle PathStyle, terminalInfo TerminalInfo, id MsgID, kind MsgKind, data MsgData, pluginName string) string {
if !includeSource {
if loc := data.Location; loc != nil {
return fmt.Sprintf("%s: %s: %s\n", loc.File.Select(pathStyle), 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, pathStyle, 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 !data.DisableMaximumWidth && 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)
}
msgID := MsgIDToString(id)
if msgID != "" {
msgID = fmt.Sprintf(" [%s]", msgID)
}
return fmt.Sprintf("%s%s %s[%s%s%s]%s %s%s%s%s%s\n%s",
iconColor, kind.Icon(),
kindColorBrackets, kindColorText, kind.String(), kindColorBrackets, colors.Reset,
colors.Bold, data.Text, colors.Reset, pluginName, msgID,
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 {
SourceBefore string
SourceMarked string
SourceAfter string
Indent string
Marker string
Suggestion string
ContentAfter string
Path string
Line int
Column int
}
func detailStruct(data MsgData, pathStyle PathStyle, terminalInfo TerminalInfo, maxMargin int) msgDetail {
// Only highlight the first line of the line text
loc := *data.Location
endOfFirstLine := len(loc.LineText)
// Note: This uses "IndexByte" because Go implements this with SIMD, which
// can matter a lot for really long lines. Some people pass huge >100mb
// minified files as line text for the log message.
if i := strings.IndexByte(loc.LineText, '\n'); i >= 0 {
endOfFirstLine = i
}
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.Select(pathStyle),
// Note: We want to deliberately print the unclamped line and column, as it
// may come from another tool that either didn't set "LineText" at all or
// at least didn't calculate the column number correctly.
Line: data.Location.Line,
Column: data.Location.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()
}
+41
View File
@@ -0,0 +1,41 @@
package logger
import (
"fmt"
"strings"
)
// This log style is intended to help integrate esbuild with Visual Studio's
// built-in problem matcher. More information:
//
// - https://learn.microsoft.com/en-us/cpp/build/formatting-the-output-of-a-custom-build-step-or-build-event
// - https://learn.microsoft.com/en-us/visualstudio/msbuild/msbuild-diagnostic-format-for-tasks
func msgToStringVisualStudio(msg Msg, options OutputOptions, terminalInfo TerminalInfo) string {
var text strings.Builder
// Write the origin
if loc := msg.Data.Location; loc != nil {
// Potentially this needs to always be an absolute path?
text.WriteString(loc.File.Abs)
// Note: Need to adjust the column from 0-based to 1-based
if loc.Line > 0 {
text.WriteString(fmt.Sprintf("(%d,%d)", loc.Line, loc.Column+1))
}
text.WriteString(": ")
} else {
// If there is no file, then we must write a tool name
text.WriteString("esbuild: ")
}
// The only valid options for the category seem to be "error" and "warning"
if msg.Kind == Error {
text.WriteString("error: ")
} else {
text.WriteString("warning: ")
}
text.WriteString(msg.Data.Text)
text.WriteByte('\n')
return text.String()
}
+4
View File
@@ -128,11 +128,13 @@ function pushLogFlags(flags: string[], options: CommonOptions, keys: OptionKeys,
let color = getFlag(options, keys, 'color', mustBeBoolean)
let logLevel = getFlag(options, keys, 'logLevel', mustBeString)
let logLimit = getFlag(options, keys, 'logLimit', mustBeInteger)
let logStyle = getFlag(options, keys, 'logStyle', mustBeString)
if (color !== void 0) flags.push(`--color=${color}`)
else if (isTTY) flags.push(`--color=true`); // This is needed to fix "execFileSync" which buffers stderr
flags.push(`--log-level=${logLevel || logLevelDefault}`)
flags.push(`--log-limit=${logLimit || 0}`)
if (logStyle) flags.push(`--log-style=${logStyle}`)
}
function validateStringValue(value: unknown, what: string, key?: string): string {
@@ -791,6 +793,7 @@ export function createChannel(streamIn: StreamIn): StreamOut {
let kind = getFlag(options, keys, 'kind', mustBeString)
let color = getFlag(options, keys, 'color', mustBeBoolean)
let terminalWidth = getFlag(options, keys, 'terminalWidth', mustBeInteger)
let logStyle = getFlag(options, keys, 'logStyle', mustBeString)
checkForInvalidFlags(options, keys, `in ${callName}() call`)
if (kind === void 0) throw new Error(`Missing "kind" in ${callName}() call`)
if (kind !== 'error' && kind !== 'warning') throw new Error(`Expected "kind" to be "error" or "warning" in ${callName}() call`)
@@ -801,6 +804,7 @@ export function createChannel(streamIn: StreamIn): StreamOut {
}
if (color !== void 0) request.color = color
if (terminalWidth !== void 0) request.terminalWidth = terminalWidth
if (logStyle !== void 0) request.logStyle = logStyle
sendRequest<protocol.FormatMsgsRequest, protocol.FormatMsgsResponse>(refs, request, (error, response) => {
if (error) return callback(new Error(error), null)
callback(null, response!.messages)
+1
View File
@@ -135,6 +135,7 @@ export interface FormatMsgsRequest {
isWarning: boolean
color?: boolean
terminalWidth?: number
logStyle?: string
}
export interface FormatMsgsResponse {
+4
View File
@@ -2,6 +2,7 @@ export type Platform = 'browser' | 'node' | 'neutral'
export type Format = 'iife' | 'cjs' | 'esm'
export type Loader = 'base64' | 'binary' | 'copy' | 'css' | 'dataurl' | 'default' | 'empty' | 'file' | 'js' | 'json' | 'jsx' | 'local-css' | 'text' | 'ts' | 'tsx'
export type LogLevel = 'verbose' | 'debug' | 'info' | 'warning' | 'error' | 'silent'
export type LogStyle = 'default' | 'visualstudio'
export type Charset = 'ascii' | 'utf8'
export type Drop = 'console' | 'debugger'
export type AbsPaths = 'code' | 'log' | 'metafile'
@@ -86,6 +87,8 @@ interface CommonOptions {
logLimit?: number
/** Documentation: https://esbuild.github.io/api/#log-override */
logOverride?: Record<string, LogLevel>
/** Documentation: https://esbuild.github.io/api/#log-style */
logStyle?: LogStyle
/** Documentation: https://esbuild.github.io/api/#tsconfig-raw */
tsconfigRaw?: string | TsconfigRaw
@@ -509,6 +512,7 @@ export interface FormatMessagesOptions {
kind: 'error' | 'warning'
color?: boolean
terminalWidth?: number
logStyle?: LogStyle
}
export interface AnalyzeMetafileOptions {
+10
View File
@@ -236,6 +236,13 @@ const (
LogLevelError
)
type LogStyle uint8
const (
LogStyleDefault LogStyle = iota
LogStyleVisualStudio
)
type Charset uint8
const (
@@ -280,6 +287,7 @@ const (
type BuildOptions struct {
Color StderrColor // Documentation: https://esbuild.github.io/api/#color
LogLevel LogLevel // Documentation: https://esbuild.github.io/api/#log-level
LogStyle LogStyle // Documentation: https://esbuild.github.io/api/#log-style
LogLimit int // Documentation: https://esbuild.github.io/api/#log-limit
LogOverride map[string]LogLevel // Documentation: https://esbuild.github.io/api/#log-override
AbsPaths AbsPaths // Documentation: https://esbuild.github.io/api/#abs-path
@@ -412,6 +420,7 @@ func Build(options BuildOptions) BuildResult {
type TransformOptions struct {
Color StderrColor // Documentation: https://esbuild.github.io/api/#color
LogLevel LogLevel // Documentation: https://esbuild.github.io/api/#log-level
LogStyle LogStyle // Documentation: https://esbuild.github.io/api/#log-style
LogLimit int // Documentation: https://esbuild.github.io/api/#log-limit
LogOverride map[string]LogLevel // Documentation: https://esbuild.github.io/api/#log-override
AbsPaths AbsPaths // Documentation: https://esbuild.github.io/api/#abs-path
@@ -717,6 +726,7 @@ type FormatMessagesOptions struct {
TerminalWidth int
Kind MessageKind
Color bool
LogStyle LogStyle
}
func FormatMessages(msgs []Message, opts FormatMessagesOptions) []string {
+14
View File
@@ -205,6 +205,17 @@ func validateLogLevel(value LogLevel) logger.LogLevel {
}
}
func validateLogStyle(value LogStyle) logger.LogStyle {
switch value {
case LogStyleDefault:
return logger.StyleDefault
case LogStyleVisualStudio:
return logger.StyleVisualStudio
default:
panic("Invalid log style")
}
}
func validateASCIIOnly(value Charset) bool {
switch value {
case CharsetDefault, CharsetASCII:
@@ -889,6 +900,7 @@ func contextImpl(buildOpts BuildOptions) (*internalContext, []Message) {
MessageLimit: buildOpts.LogLimit,
Color: validateColor(buildOpts.Color),
LogLevel: validateLogLevel(buildOpts.LogLevel),
LogStyle: validateLogStyle(buildOpts.LogStyle),
PathStyle: extractPathStyle(buildOpts.AbsPaths, LogAbsPath),
Overrides: validateLogOverrides(buildOpts.LogOverride),
}
@@ -1695,6 +1707,7 @@ func transformImpl(input string, transformOpts TransformOptions) TransformResult
MessageLimit: transformOpts.LogLimit,
Color: validateColor(transformOpts.Color),
LogLevel: validateLogLevel(transformOpts.LogLevel),
LogStyle: validateLogStyle(transformOpts.LogStyle),
PathStyle: extractPathStyle(transformOpts.AbsPaths, LogAbsPath),
Overrides: validateLogOverrides(transformOpts.LogOverride),
})
@@ -2204,6 +2217,7 @@ func formatMsgsImpl(msgs []Message, opts FormatMessagesOptions) []string {
strings[i] = msg.String(
logger.OutputOptions{
IncludeSource: true,
LogStyle: validateLogStyle(opts.LogStyle),
},
logger.TerminalInfo{
UseColorEscapes: opts.Color,
+27
View File
@@ -836,6 +836,19 @@ func parseOptionsImpl(
transformOpts.LogLevel = logLevel
}
// Make sure this stays in sync with "PrintErrorToStderr"
case strings.HasPrefix(arg, "--log-style="):
value := arg[len("--log-style="):]
logStyle, err := parseLogStyle(value, arg)
if err != nil {
return parseOptionsExtras{}, err
}
if buildOpts != nil {
buildOpts.LogStyle = logStyle
} else {
transformOpts.LogStyle = logStyle
}
case strings.HasPrefix(arg, "'--"):
return parseOptionsExtras{}, cli_helpers.MakeErrorWithNote(
fmt.Sprintf("Unexpected single quote character before flag: %s", arg),
@@ -1562,3 +1575,17 @@ func parseLogLevel(value string, arg string) (api.LogLevel, *cli_helpers.ErrorWi
)
}
}
func parseLogStyle(value string, arg string) (api.LogStyle, *cli_helpers.ErrorWithNote) {
switch value {
case "default":
return api.LogStyleDefault, nil
case "visualstudio":
return api.LogStyleVisualStudio, nil
default:
return api.LogStyleDefault, cli_helpers.MakeErrorWithNote(
fmt.Sprintf("Invalid value %q in %q", value, arg),
"Valid values are \"default\" or \"visualstudio\".",
)
}
}
+18 -1
View File
@@ -7590,12 +7590,29 @@ let formatTests = {
const messages = await esbuild.formatMessages([
{ text: 'This is an error' },
{ text: 'Another error', location: { file: 'file.js' } },
{ text: 'Error with line and column', location: { file: 'file.js', line: 3, column: 6 } },
], {
kind: 'error',
})
assert.strictEqual(messages.length, 2)
assert.strictEqual(messages.length, 3)
assert.strictEqual(messages[0], `${errorIcon} [ERROR] This is an error\n\n`)
assert.strictEqual(messages[1], `${errorIcon} [ERROR] Another error\n\n file.js:0:0:\n 0 │ \n ╵ ^\n\n`)
assert.strictEqual(messages[2], `${errorIcon} [ERROR] Error with line and column\n\n file.js:3:6:\n 3 │ \n ╵ ^\n\n`)
},
async formatMessagesVisualStudio({ esbuild }) {
const messages = await esbuild.formatMessages([
{ text: 'This is an error' },
{ text: 'Another error', location: { file: 'file.js' } },
{ text: 'Error with line and column', location: { file: 'file.js', line: 3, column: 6 } },
], {
kind: 'error',
logStyle: 'visualstudio',
})
assert.strictEqual(messages.length, 3)
assert.strictEqual(messages[0], `esbuild: error: This is an error\n`)
assert.strictEqual(messages[1], `file.js: error: Another error\n`)
assert.strictEqual(messages[2], `file.js(3,7): error: Error with line and column\n`)
},
}