1package logger
2
3// Logging is currently designed to look and feel like clang's error format.
4// Errors are streamed asynchronously as they happen, each error contains the
5// contents of the line with the error, and the error count is limited by
6// default.
7
8import (
9	"fmt"
10	"os"
11	"runtime"
12	"sort"
13	"strings"
14	"sync"
15	"time"
16	"unicode/utf8"
17)
18
19const defaultTerminalWidth = 80
20
21type Log struct {
22	Level LogLevel
23
24	AddMsg    func(Msg)
25	HasErrors func() bool
26
27	// This is called after the build has finished but before writing to stdout.
28	// It exists to ensure that deferred warning messages end up in the terminal
29	// before the data written to stdout.
30	AlmostDone func()
31
32	Done func() []Msg
33}
34
35type LogLevel int8
36
37const (
38	LevelNone LogLevel = iota
39	LevelVerbose
40	LevelDebug
41	LevelInfo
42	LevelWarning
43	LevelError
44	LevelSilent
45)
46
47type MsgKind uint8
48
49const (
50	Error MsgKind = iota
51	Warning
52	Info
53	Note
54	Debug
55	Verbose
56)
57
58func (kind MsgKind) String() string {
59	switch kind {
60	case Error:
61		return "error"
62	case Warning:
63		return "warning"
64	case Info:
65		return "info"
66	case Note:
67		return "note"
68	case Debug:
69		return "debug"
70	case Verbose:
71		return "verbose"
72	default:
73		panic("Internal error")
74	}
75}
76
77type Msg struct {
78	PluginName string
79	Kind       MsgKind
80	Data       MsgData
81	Notes      []MsgData
82}
83
84type MsgData struct {
85	Text     string
86	Location *MsgLocation
87
88	// Optional user-specified data that is passed through unmodified
89	UserDetail interface{}
90}
91
92type MsgLocation struct {
93	File       string
94	Namespace  string
95	Line       int // 1-based
96	Column     int // 0-based, in bytes
97	Length     int // in bytes
98	LineText   string
99	Suggestion string
100}
101
102type Loc struct {
103	// This is the 0-based index of this location from the start of the file, in bytes
104	Start int32
105}
106
107type Range struct {
108	Loc Loc
109	Len int32
110}
111
112func (r Range) End() int32 {
113	return r.Loc.Start + r.Len
114}
115
116type Span struct {
117	Text  string
118	Range Range
119}
120
121// This type is just so we can use Go's native sort function
122type SortableMsgs []Msg
123
124func (a SortableMsgs) Len() int          { return len(a) }
125func (a SortableMsgs) Swap(i int, j int) { a[i], a[j] = a[j], a[i] }
126
127func (a SortableMsgs) Less(i int, j int) bool {
128	ai := a[i]
129	aj := a[j]
130	aiLoc := ai.Data.Location
131	ajLoc := aj.Data.Location
132	if aiLoc == nil || ajLoc == nil {
133		return aiLoc == nil && ajLoc != nil
134	}
135	if aiLoc.File != ajLoc.File {
136		return aiLoc.File < ajLoc.File
137	}
138	if aiLoc.Line != ajLoc.Line {
139		return aiLoc.Line < ajLoc.Line
140	}
141	if aiLoc.Column != ajLoc.Column {
142		return aiLoc.Column < ajLoc.Column
143	}
144	if ai.Kind != aj.Kind {
145		return ai.Kind < aj.Kind
146	}
147	return ai.Data.Text < aj.Data.Text
148}
149
150// This is used to represent both file system paths (Namespace == "file") and
151// abstract module paths (Namespace != "file"). Abstract module paths represent
152// "virtual modules" when used for an input file and "package paths" when used
153// to represent an external module.
154type Path struct {
155	Text      string
156	Namespace string
157
158	// This feature was added to support ancient CSS libraries that append things
159	// like "?#iefix" and "#icons" to some of their import paths as a hack for IE6.
160	// The intent is for these suffix parts to be ignored but passed through to
161	// the output. This is supported by other bundlers, so we also support this.
162	IgnoredSuffix string
163
164	Flags PathFlags
165}
166
167type PathFlags uint8
168
169const (
170	// This corresponds to a value of "false' in the "browser" package.json field
171	PathDisabled PathFlags = 1 << iota
172)
173
174func (p Path) IsDisabled() bool {
175	return (p.Flags & PathDisabled) != 0
176}
177
178func (a Path) ComesBeforeInSortedOrder(b Path) bool {
179	return a.Namespace > b.Namespace ||
180		(a.Namespace == b.Namespace && (a.Text < b.Text ||
181			(a.Text == b.Text && (a.Flags < b.Flags ||
182				(a.Flags == b.Flags && a.IgnoredSuffix < b.IgnoredSuffix)))))
183}
184
185// This has a custom implementation instead of using "filepath.Dir/Base/Ext"
186// because it should work the same on Unix and Windows. These names end up in
187// the generated output and the generated output should not depend on the OS.
188func PlatformIndependentPathDirBaseExt(path string) (dir string, base string, ext string) {
189	for {
190		i := strings.LastIndexAny(path, "/\\")
191
192		// Stop if there are no more slashes
193		if i < 0 {
194			base = path
195			break
196		}
197
198		// Stop if we found a non-trailing slash
199		if i+1 != len(path) {
200			dir, base = path[:i], path[i+1:]
201			break
202		}
203
204		// Ignore trailing slashes
205		path = path[:i]
206	}
207
208	// Strip off the extension
209	if dot := strings.LastIndexByte(base, '.'); dot >= 0 {
210		base, ext = base[:dot], base[dot:]
211	}
212
213	return
214}
215
216type Source struct {
217	Index uint32
218
219	// This is used as a unique key to identify this source file. It should never
220	// be shown to the user (e.g. never print this to the terminal).
221	//
222	// If it's marked as an absolute path, it's a platform-dependent path that
223	// includes environment-specific things such as Windows backslash path
224	// separators and potentially the user's home directory. Only use this for
225	// passing to syscalls for reading and writing to the file system. Do not
226	// include this in any output data.
227	//
228	// If it's marked as not an absolute path, it's an opaque string that is used
229	// to refer to an automatically-generated module.
230	KeyPath Path
231
232	// This is used for error messages and the metadata JSON file.
233	//
234	// This is a mostly platform-independent path. It's relative to the current
235	// working directory and always uses standard path separators. Use this for
236	// referencing a file in all output data. These paths still use the original
237	// case of the path so they may still work differently on file systems that
238	// are case-insensitive vs. case-sensitive.
239	PrettyPath string
240
241	// An identifier that is mixed in to automatically-generated symbol names to
242	// improve readability. For example, if the identifier is "util" then the
243	// symbol for an "export default" statement will be called "util_default".
244	IdentifierName string
245
246	Contents string
247}
248
249func (s *Source) TextForRange(r Range) string {
250	return s.Contents[r.Loc.Start : r.Loc.Start+r.Len]
251}
252
253func (s *Source) RangeOfOperatorBefore(loc Loc, op string) Range {
254	text := s.Contents[:loc.Start]
255	index := strings.LastIndex(text, op)
256	if index >= 0 {
257		return Range{Loc: Loc{Start: int32(index)}, Len: int32(len(op))}
258	}
259	return Range{Loc: loc}
260}
261
262func (s *Source) RangeOfOperatorAfter(loc Loc, op string) Range {
263	text := s.Contents[loc.Start:]
264	index := strings.Index(text, op)
265	if index >= 0 {
266		return Range{Loc: Loc{Start: loc.Start + int32(index)}, Len: int32(len(op))}
267	}
268	return Range{Loc: loc}
269}
270
271func (s *Source) RangeOfString(loc Loc) Range {
272	text := s.Contents[loc.Start:]
273	if len(text) == 0 {
274		return Range{Loc: loc, Len: 0}
275	}
276
277	quote := text[0]
278	if quote == '"' || quote == '\'' {
279		// Search for the matching quote character
280		for i := 1; i < len(text); i++ {
281			c := text[i]
282			if c == quote {
283				return Range{Loc: loc, Len: int32(i + 1)}
284			} else if c == '\\' {
285				i += 1
286			}
287		}
288	}
289
290	return Range{Loc: loc, Len: 0}
291}
292
293func (s *Source) RangeOfNumber(loc Loc) (r Range) {
294	text := s.Contents[loc.Start:]
295	r = Range{Loc: loc, Len: 0}
296
297	if len(text) > 0 {
298		if c := text[0]; c >= '0' && c <= '9' {
299			r.Len = 1
300			for int(r.Len) < len(text) {
301				c := text[r.Len]
302				if (c < '0' || c > '9') && (c < 'a' || c > 'z') && (c < 'A' || c > 'Z') && c != '.' && c != '_' {
303					break
304				}
305				r.Len++
306			}
307		}
308	}
309	return
310}
311
312func (s *Source) RangeOfLegacyOctalEscape(loc Loc) (r Range) {
313	text := s.Contents[loc.Start:]
314	r = Range{Loc: loc, Len: 0}
315
316	if len(text) >= 2 && text[0] == '\\' {
317		r.Len = 2
318		for r.Len < 4 && int(r.Len) < len(text) {
319			c := text[r.Len]
320			if c < '0' || c > '9' {
321				break
322			}
323			r.Len++
324		}
325	}
326	return
327}
328
329func plural(prefix string, count int, shown int, someAreMissing bool) string {
330	var text string
331	if count == 1 {
332		text = fmt.Sprintf("%d %s", count, prefix)
333	} else {
334		text = fmt.Sprintf("%d %ss", count, prefix)
335	}
336	if shown < count {
337		text = fmt.Sprintf("%d of %s", shown, text)
338	} else if someAreMissing && count > 1 {
339		text = "all " + text
340	}
341	return text
342}
343
344func errorAndWarningSummary(errors int, warnings int, shownErrors int, shownWarnings int) string {
345	someAreMissing := shownWarnings < warnings || shownErrors < errors
346	switch {
347	case errors == 0:
348		return plural("warning", warnings, shownWarnings, someAreMissing)
349	case warnings == 0:
350		return plural("error", errors, shownErrors, someAreMissing)
351	default:
352		return fmt.Sprintf("%s and %s",
353			plural("warning", warnings, shownWarnings, someAreMissing),
354			plural("error", errors, shownErrors, someAreMissing))
355	}
356}
357
358type APIKind uint8
359
360const (
361	GoAPI APIKind = iota
362	CLIAPI
363	JSAPI
364)
365
366// This can be used to customize error messages for the current API kind
367var API APIKind
368
369type TerminalInfo struct {
370	IsTTY           bool
371	UseColorEscapes bool
372	Width           int
373	Height          int
374}
375
376func NewStderrLog(options OutputOptions) Log {
377	var mutex sync.Mutex
378	var msgs SortableMsgs
379	terminalInfo := GetTerminalInfo(os.Stderr)
380	errors := 0
381	warnings := 0
382	shownErrors := 0
383	shownWarnings := 0
384	hasErrors := false
385	remainingMessagesBeforeLimit := options.MessageLimit
386	if remainingMessagesBeforeLimit == 0 {
387		remainingMessagesBeforeLimit = 0x7FFFFFFF
388	}
389	var deferredWarnings []Msg
390	didFinalizeLog := false
391
392	finalizeLog := func() {
393		if didFinalizeLog {
394			return
395		}
396		didFinalizeLog = true
397
398		// Print the deferred warning now if there was no error after all
399		for remainingMessagesBeforeLimit > 0 && len(deferredWarnings) > 0 {
400			shownWarnings++
401			writeStringWithColor(os.Stderr, deferredWarnings[0].String(options, terminalInfo))
402			deferredWarnings = deferredWarnings[1:]
403			remainingMessagesBeforeLimit--
404		}
405
406		// Print out a summary
407		if options.MessageLimit > 0 && errors+warnings > options.MessageLimit {
408			writeStringWithColor(os.Stderr, fmt.Sprintf("%s shown (disable the message limit with --log-limit=0)\n",
409				errorAndWarningSummary(errors, warnings, shownErrors, shownWarnings)))
410		} else if options.LogLevel <= LevelInfo && (warnings != 0 || errors != 0) {
411			writeStringWithColor(os.Stderr, fmt.Sprintf("%s\n",
412				errorAndWarningSummary(errors, warnings, shownErrors, shownWarnings)))
413		}
414	}
415
416	switch options.Color {
417	case ColorNever:
418		terminalInfo.UseColorEscapes = false
419	case ColorAlways:
420		terminalInfo.UseColorEscapes = SupportsColorEscapes
421	}
422
423	return Log{
424		Level: options.LogLevel,
425
426		AddMsg: func(msg Msg) {
427			mutex.Lock()
428			defer mutex.Unlock()
429			msgs = append(msgs, msg)
430
431			switch msg.Kind {
432			case Verbose:
433				if options.LogLevel <= LevelVerbose {
434					writeStringWithColor(os.Stderr, msg.String(options, terminalInfo))
435				}
436
437			case Debug:
438				if options.LogLevel <= LevelDebug {
439					writeStringWithColor(os.Stderr, msg.String(options, terminalInfo))
440				}
441
442			case Info:
443				if options.LogLevel <= LevelInfo {
444					writeStringWithColor(os.Stderr, msg.String(options, terminalInfo))
445				}
446
447			case Error:
448				hasErrors = true
449				if options.LogLevel <= LevelError {
450					errors++
451				}
452
453			case Warning:
454				if options.LogLevel <= LevelWarning {
455					warnings++
456				}
457			}
458
459			// Be silent if we're past the limit so we don't flood the terminal
460			if remainingMessagesBeforeLimit == 0 {
461				return
462			}
463
464			switch msg.Kind {
465			case Error:
466				if options.LogLevel <= LevelError {
467					shownErrors++
468					writeStringWithColor(os.Stderr, msg.String(options, terminalInfo))
469					remainingMessagesBeforeLimit--
470				}
471
472			case Warning:
473				if options.LogLevel <= LevelWarning {
474					if remainingMessagesBeforeLimit > (options.MessageLimit+1)/2 {
475						shownWarnings++
476						writeStringWithColor(os.Stderr, msg.String(options, terminalInfo))
477						remainingMessagesBeforeLimit--
478					} else {
479						// If we have less than half of the slots left, wait for potential
480						// future errors instead of using up all of the slots with warnings.
481						// We want the log for a failed build to always have at least one
482						// error in it.
483						deferredWarnings = append(deferredWarnings, msg)
484					}
485				}
486			}
487		},
488
489		HasErrors: func() bool {
490			mutex.Lock()
491			defer mutex.Unlock()
492			return hasErrors
493		},
494
495		AlmostDone: func() {
496			mutex.Lock()
497			defer mutex.Unlock()
498
499			finalizeLog()
500		},
501
502		Done: func() []Msg {
503			mutex.Lock()
504			defer mutex.Unlock()
505
506			finalizeLog()
507			sort.Stable(msgs)
508			return msgs
509		},
510	}
511}
512
513func PrintErrorToStderr(osArgs []string, text string) {
514	PrintMessageToStderr(osArgs, Msg{Kind: Error, Data: MsgData{Text: text}})
515}
516
517func OutputOptionsForArgs(osArgs []string) OutputOptions {
518	options := OutputOptions{IncludeSource: true}
519
520	// Implement a mini argument parser so these options always work even if we
521	// haven't yet gotten to the general-purpose argument parsing code
522	for _, arg := range osArgs {
523		switch arg {
524		case "--color=false":
525			options.Color = ColorNever
526		case "--color=true":
527			options.Color = ColorAlways
528		case "--log-level=info":
529			options.LogLevel = LevelInfo
530		case "--log-level=warning":
531			options.LogLevel = LevelWarning
532		case "--log-level=error":
533			options.LogLevel = LevelError
534		case "--log-level=silent":
535			options.LogLevel = LevelSilent
536		}
537	}
538
539	return options
540}
541
542func PrintMessageToStderr(osArgs []string, msg Msg) {
543	log := NewStderrLog(OutputOptionsForArgs(osArgs))
544	log.AddMsg(msg)
545	log.Done()
546}
547
548type Colors struct {
549	Reset     string
550	Bold      string
551	Dim       string
552	Underline string
553
554	Red   string
555	Green string
556	Blue  string
557
558	Cyan    string
559	Magenta string
560	Yellow  string
561}
562
563var TerminalColors = Colors{
564	Reset:     "\033[0m",
565	Bold:      "\033[1m",
566	Dim:       "\033[37m",
567	Underline: "\033[4m",
568
569	Red:   "\033[31m",
570	Green: "\033[32m",
571	Blue:  "\033[34m",
572
573	Cyan:    "\033[36m",
574	Magenta: "\033[35m",
575	Yellow:  "\033[33m",
576}
577
578func PrintText(file *os.File, level LogLevel, osArgs []string, callback func(Colors) string) {
579	options := OutputOptionsForArgs(osArgs)
580
581	// Skip logging these if these logs are disabled
582	if options.LogLevel > level {
583		return
584	}
585
586	PrintTextWithColor(file, options.Color, callback)
587}
588
589func PrintTextWithColor(file *os.File, useColor UseColor, callback func(Colors) string) {
590	var useColorEscapes bool
591	switch useColor {
592	case ColorNever:
593		useColorEscapes = false
594	case ColorAlways:
595		useColorEscapes = SupportsColorEscapes
596	case ColorIfTerminal:
597		useColorEscapes = GetTerminalInfo(file).UseColorEscapes
598	}
599
600	var colors Colors
601	if useColorEscapes {
602		colors = TerminalColors
603	}
604	writeStringWithColor(file, callback(colors))
605}
606
607type SummaryTableEntry struct {
608	Dir         string
609	Base        string
610	Size        string
611	Bytes       int
612	IsSourceMap bool
613}
614
615// This type is just so we can use Go's native sort function
616type SummaryTable []SummaryTableEntry
617
618func (t SummaryTable) Len() int          { return len(t) }
619func (t SummaryTable) Swap(i int, j int) { t[i], t[j] = t[j], t[i] }
620
621func (t SummaryTable) Less(i int, j int) bool {
622	ti := t[i]
623	tj := t[j]
624
625	// Sort source maps last
626	if !ti.IsSourceMap && tj.IsSourceMap {
627		return true
628	}
629	if ti.IsSourceMap && !tj.IsSourceMap {
630		return false
631	}
632
633	// Sort by size first
634	if ti.Bytes > tj.Bytes {
635		return true
636	}
637	if ti.Bytes < tj.Bytes {
638		return false
639	}
640
641	// Sort alphabetically by directory first
642	if ti.Dir < tj.Dir {
643		return true
644	}
645	if ti.Dir > tj.Dir {
646		return false
647	}
648
649	// Then sort alphabetically by file name
650	return ti.Base < tj.Base
651}
652
653// Show a warning icon next to output files that are 1mb or larger
654const sizeWarningThreshold = 1024 * 1024
655
656func PrintSummary(useColor UseColor, table SummaryTable, start *time.Time) {
657	PrintTextWithColor(os.Stderr, useColor, func(colors Colors) string {
658		isProbablyWindowsCommandPrompt := false
659		sb := strings.Builder{}
660
661		// Assume we are running in Windows Command Prompt if we're on Windows. If
662		// so, we can't use emoji because it won't be supported. Except we can
663		// still use emoji if the WT_SESSION environment variable is present
664		// because that means we're running in the new Windows Terminal instead.
665		if runtime.GOOS == "windows" {
666			isProbablyWindowsCommandPrompt = true
667			for _, env := range os.Environ() {
668				if strings.HasPrefix(env, "WT_SESSION=") {
669					isProbablyWindowsCommandPrompt = false
670					break
671				}
672			}
673		}
674
675		if len(table) > 0 {
676			info := GetTerminalInfo(os.Stderr)
677
678			// Truncate the table in case it's really long
679			maxLength := info.Height / 2
680			if info.Height == 0 {
681				maxLength = 20
682			} else if maxLength < 5 {
683				maxLength = 5
684			}
685			length := len(table)
686			sort.Sort(table)
687			if length > maxLength {
688				table = table[:maxLength]
689			}
690
691			// Compute the maximum width of the size column
692			spacingBetweenColumns := 2
693			hasSizeWarning := false
694			maxPath := 0
695			maxSize := 0
696			for _, entry := range table {
697				path := len(entry.Dir) + len(entry.Base)
698				size := len(entry.Size) + spacingBetweenColumns
699				if path > maxPath {
700					maxPath = path
701				}
702				if size > maxSize {
703					maxSize = size
704				}
705				if !entry.IsSourceMap && entry.Bytes >= sizeWarningThreshold {
706					hasSizeWarning = true
707				}
708			}
709
710			margin := "  "
711			layoutWidth := info.Width
712			if layoutWidth < 1 {
713				layoutWidth = defaultTerminalWidth
714			}
715			layoutWidth -= 2 * len(margin)
716			if hasSizeWarning {
717				// Add space for the warning icon
718				layoutWidth -= 2
719			}
720			if layoutWidth > maxPath+maxSize {
721				layoutWidth = maxPath + maxSize
722			}
723			sb.WriteByte('\n')
724
725			for _, entry := range table {
726				dir, base := entry.Dir, entry.Base
727				pathWidth := layoutWidth - maxSize
728
729				// Truncate the path with "..." to fit on one line
730				if len(dir)+len(base) > pathWidth {
731					// Trim the directory from the front, leaving the trailing slash
732					if len(dir) > 0 {
733						n := pathWidth - len(base) - 3
734						if n < 1 {
735							n = 1
736						}
737						dir = "..." + dir[len(dir)-n:]
738					}
739
740					// Trim the file name from the back
741					if len(dir)+len(base) > pathWidth {
742						n := pathWidth - len(dir) - 3
743						if n < 0 {
744							n = 0
745						}
746						base = base[:n] + "..."
747					}
748				}
749
750				spacer := layoutWidth - len(entry.Size) - len(dir) - len(base)
751				if spacer < 0 {
752					spacer = 0
753				}
754
755				// Put a warning next to the size if it's above a certain threshold
756				sizeColor := colors.Cyan
757				sizeWarning := ""
758				if !entry.IsSourceMap && entry.Bytes >= sizeWarningThreshold {
759					sizeColor = colors.Yellow
760
761					// Emoji don't work in Windows Command Prompt
762					if !isProbablyWindowsCommandPrompt {
763						sizeWarning = " ⚠️"
764					}
765				}
766
767				sb.WriteString(fmt.Sprintf("%s%s%s%s%s%s%s%s%s%s%s%s\n",
768					margin,
769					colors.Dim,
770					dir,
771					colors.Reset,
772					colors.Bold,
773					base,
774					colors.Reset,
775					strings.Repeat(" ", spacer),
776					sizeColor,
777					entry.Size,
778					sizeWarning,
779					colors.Reset,
780				))
781			}
782
783			// Say how many remaining files are not shown
784			if length > maxLength {
785				plural := "s"
786				if length == maxLength+1 {
787					plural = ""
788				}
789				sb.WriteString(fmt.Sprintf("%s%s...and %d more output file%s...%s\n", margin, colors.Dim, length-maxLength, plural, colors.Reset))
790			}
791		}
792		sb.WriteByte('\n')
793
794		lightningSymbol := "⚡ "
795
796		// Emoji don't work in Windows Command Prompt
797		if isProbablyWindowsCommandPrompt {
798			lightningSymbol = ""
799		}
800
801		// Printing the time taken is optional
802		if start != nil {
803			sb.WriteString(fmt.Sprintf("%s%sDone in %dms%s\n",
804				lightningSymbol,
805				colors.Green,
806				time.Since(*start).Milliseconds(),
807				colors.Reset,
808			))
809		}
810
811		return sb.String()
812	})
813}
814
815type DeferLogKind uint8
816
817const (
818	DeferLogAll DeferLogKind = iota
819	DeferLogNoVerboseOrDebug
820)
821
822func NewDeferLog(kind DeferLogKind) Log {
823	var msgs SortableMsgs
824	var mutex sync.Mutex
825	var hasErrors bool
826
827	return Log{
828		Level: LevelInfo,
829
830		AddMsg: func(msg Msg) {
831			if kind == DeferLogNoVerboseOrDebug && (msg.Kind == Verbose || msg.Kind == Debug) {
832				return
833			}
834			mutex.Lock()
835			defer mutex.Unlock()
836			if msg.Kind == Error {
837				hasErrors = true
838			}
839			msgs = append(msgs, msg)
840		},
841
842		HasErrors: func() bool {
843			mutex.Lock()
844			defer mutex.Unlock()
845			return hasErrors
846		},
847
848		AlmostDone: func() {
849		},
850
851		Done: func() []Msg {
852			mutex.Lock()
853			defer mutex.Unlock()
854			sort.Stable(msgs)
855			return msgs
856		},
857	}
858}
859
860type UseColor uint8
861
862const (
863	ColorIfTerminal UseColor = iota
864	ColorNever
865	ColorAlways
866)
867
868type OutputOptions struct {
869	IncludeSource bool
870	MessageLimit  int
871	Color         UseColor
872	LogLevel      LogLevel
873}
874
875func (msg Msg) String(options OutputOptions, terminalInfo TerminalInfo) string {
876	// Compute the maximum margin
877	maxMargin := 0
878	if options.IncludeSource {
879		if msg.Data.Location != nil {
880			maxMargin = len(fmt.Sprintf("%d", msg.Data.Location.Line))
881		}
882		for _, note := range msg.Notes {
883			if note.Location != nil {
884				margin := len(fmt.Sprintf("%d", note.Location.Line))
885				if margin > maxMargin {
886					maxMargin = margin
887				}
888			}
889		}
890	}
891
892	// Format the message
893	text := msgString(options.IncludeSource, terminalInfo, msg.Kind, msg.Data, maxMargin, msg.PluginName)
894
895	// Put a blank line between the message and the notes if the message has a stack trace
896	gap := ""
897	if loc := msg.Data.Location; loc != nil && strings.ContainsRune(loc.LineText, '\n') {
898		gap = "\n"
899	}
900
901	// Format the notes
902	for _, note := range msg.Notes {
903		text += gap
904		text += msgString(options.IncludeSource, terminalInfo, Note, note, maxMargin, "")
905	}
906
907	// Add extra spacing between messages if source code is present
908	if options.IncludeSource {
909		text += "\n"
910	}
911	return text
912}
913
914// The number of margin characters in addition to the line number
915const extraMarginChars = 7
916
917func marginWithLineText(maxMargin int, line int) string {
918	number := fmt.Sprintf("%d", line)
919	return fmt.Sprintf("    %s%s │ ", strings.Repeat(" ", maxMargin-len(number)), number)
920}
921
922func emptyMarginText(maxMargin int, isLast bool) string {
923	space := strings.Repeat(" ", maxMargin)
924	if isLast {
925		return fmt.Sprintf("    %s ╵ ", space)
926	}
927	return fmt.Sprintf("    %s │ ", space)
928}
929
930func msgString(includeSource bool, terminalInfo TerminalInfo, kind MsgKind, data MsgData, maxMargin int, pluginName string) string {
931	var colors Colors
932	if terminalInfo.UseColorEscapes {
933		colors = TerminalColors
934	}
935
936	var kindColor string
937	prefixColor := colors.Bold
938	messageColor := colors.Bold
939	textIndent := ""
940
941	if includeSource {
942		textIndent = " > "
943	}
944
945	switch kind {
946	case Verbose:
947		kindColor = colors.Cyan
948
949	case Debug:
950		kindColor = colors.Blue
951
952	case Info:
953		kindColor = colors.Green
954
955	case Error:
956		kindColor = colors.Red
957
958	case Warning:
959		kindColor = colors.Magenta
960
961	case Note:
962		prefixColor = colors.Reset
963		kindColor = colors.Bold
964		messageColor = ""
965		if includeSource {
966			textIndent = "   "
967		}
968
969	default:
970		panic("Internal error")
971	}
972
973	var pluginText string
974	if pluginName != "" {
975		pluginText = fmt.Sprintf("%s[plugin: %s] ", colors.Yellow, pluginName)
976	}
977
978	if data.Location == nil {
979		return fmt.Sprintf("%s%s%s%s: %s%s%s%s\n%s",
980			prefixColor, textIndent, kindColor, kind.String(),
981			pluginText, colors.Reset, messageColor, data.Text,
982			colors.Reset)
983	}
984
985	if !includeSource {
986		return fmt.Sprintf("%s%s%s: %s%s: %s%s%s%s\n%s",
987			prefixColor, textIndent, data.Location.File,
988			kindColor, kind.String(),
989			pluginText, colors.Reset, messageColor, data.Text,
990			colors.Reset)
991	}
992
993	d := detailStruct(data, terminalInfo, maxMargin)
994
995	callout := d.Marker
996	calloutPrefix := ""
997
998	if d.Suggestion != "" {
999		callout = d.Suggestion
1000		calloutPrefix = fmt.Sprintf("%s%s%s%s%s\n",
1001			emptyMarginText(maxMargin, false), d.Indent, colors.Green, d.Marker, colors.Dim)
1002	}
1003
1004	return fmt.Sprintf("%s%s%s:%d:%d: %s%s: %s%s%s%s\n%s%s%s%s%s%s%s\n%s%s%s%s%s%s%s\n%s",
1005		prefixColor, textIndent, d.Path, d.Line, d.Column,
1006		kindColor, kind.String(),
1007		pluginText, colors.Reset, messageColor, d.Message,
1008		colors.Reset, colors.Dim, d.SourceBefore, colors.Green, d.SourceMarked, colors.Dim, d.SourceAfter,
1009		calloutPrefix, emptyMarginText(maxMargin, true), d.Indent, colors.Green, callout, colors.Dim, d.ContentAfter,
1010		colors.Reset)
1011}
1012
1013type MsgDetail struct {
1014	Path    string
1015	Line    int
1016	Column  int
1017	Message string
1018
1019	SourceBefore string
1020	SourceMarked string
1021	SourceAfter  string
1022
1023	Indent     string
1024	Marker     string
1025	Suggestion string
1026
1027	ContentAfter string
1028}
1029
1030// It's not common for large files to have many warnings. But when it happens,
1031// we want to make sure that it's not too slow. Source code locations are
1032// represented as byte offsets for compactness but transforming these to
1033// line/column locations for warning messages requires scanning through the
1034// file. A naive approach for this would cause O(n^2) scanning time for n
1035// warnings distributed throughout the file.
1036//
1037// Warnings are typically generated sequentially as the file is scanned. So
1038// one way of optimizing this is to just start scanning from where we left
1039// off last time instead of always starting from the beginning of the file.
1040// That's what this object does.
1041//
1042// Another option could be to eagerly populate an array of line/column offsets
1043// and then use binary search for each query. This might slow down the common
1044// case of a file with only at most a few warnings though, so think before
1045// optimizing too much. Performance in the zero or one warning case is by far
1046// the most important.
1047type LineColumnTracker struct {
1048	contents     string
1049	prettyPath   string
1050	offset       int32
1051	line         int32
1052	lineStart    int32
1053	lineEnd      int32
1054	hasLineStart bool
1055	hasLineEnd   bool
1056	hasSource    bool
1057}
1058
1059func MakeLineColumnTracker(source *Source) LineColumnTracker {
1060	if source == nil {
1061		return LineColumnTracker{
1062			hasSource: false,
1063		}
1064	}
1065
1066	return LineColumnTracker{
1067		contents:     source.Contents,
1068		prettyPath:   source.PrettyPath,
1069		hasLineStart: true,
1070		hasSource:    true,
1071	}
1072}
1073
1074func (t *LineColumnTracker) scanTo(offset int32) {
1075	contents := t.contents
1076	i := t.offset
1077
1078	// Scan forward
1079	if i < offset {
1080		for {
1081			r, size := utf8.DecodeRuneInString(contents[i:])
1082			i += int32(size)
1083
1084			switch r {
1085			case '\n':
1086				t.hasLineStart = true
1087				t.hasLineEnd = false
1088				t.lineStart = i
1089				if i == int32(size) || contents[i-int32(size)-1] != '\r' {
1090					t.line++
1091				}
1092
1093			case '\r', '\u2028', '\u2029':
1094				t.hasLineStart = true
1095				t.hasLineEnd = false
1096				t.lineStart = i
1097				t.line++
1098			}
1099
1100			if i >= offset {
1101				t.offset = i
1102				return
1103			}
1104		}
1105	}
1106
1107	// Scan backward
1108	if i > offset {
1109		for {
1110			r, size := utf8.DecodeLastRuneInString(contents[:i])
1111			i -= int32(size)
1112
1113			switch r {
1114			case '\n':
1115				t.hasLineStart = false
1116				t.hasLineEnd = true
1117				t.lineEnd = i
1118				if i == 0 || contents[i-1] != '\r' {
1119					t.line--
1120				}
1121
1122			case '\r', '\u2028', '\u2029':
1123				t.hasLineStart = false
1124				t.hasLineEnd = true
1125				t.lineEnd = i
1126				t.line--
1127			}
1128
1129			if i <= offset {
1130				t.offset = i
1131				return
1132			}
1133		}
1134	}
1135}
1136
1137func (t *LineColumnTracker) computeLineAndColumn(offset int) (lineCount int, columnCount int, lineStart int, lineEnd int) {
1138	t.scanTo(int32(offset))
1139
1140	// Scan for the start of the line
1141	if !t.hasLineStart {
1142		contents := t.contents
1143		i := t.offset
1144		for i > 0 {
1145			r, size := utf8.DecodeLastRuneInString(contents[:i])
1146			if r == '\n' || r == '\r' || r == '\u2028' || r == '\u2029' {
1147				break
1148			}
1149			i -= int32(size)
1150		}
1151		t.hasLineStart = true
1152		t.lineStart = i
1153	}
1154
1155	// Scan for the end of the line
1156	if !t.hasLineEnd {
1157		contents := t.contents
1158		i := t.offset
1159		n := int32(len(contents))
1160		for i < n {
1161			r, size := utf8.DecodeRuneInString(contents[i:])
1162			if r == '\n' || r == '\r' || r == '\u2028' || r == '\u2029' {
1163				break
1164			}
1165			i += int32(size)
1166		}
1167		t.hasLineEnd = true
1168		t.lineEnd = i
1169	}
1170
1171	return int(t.line), offset - int(t.lineStart), int(t.lineStart), int(t.lineEnd)
1172}
1173
1174func LocationOrNil(tracker *LineColumnTracker, r Range) *MsgLocation {
1175	if tracker == nil || !tracker.hasSource {
1176		return nil
1177	}
1178
1179	// Convert the index into a line and column number
1180	lineCount, columnCount, lineStart, lineEnd := tracker.computeLineAndColumn(int(r.Loc.Start))
1181
1182	return &MsgLocation{
1183		File:     tracker.prettyPath,
1184		Line:     lineCount + 1, // 0-based to 1-based
1185		Column:   columnCount,
1186		Length:   int(r.Len),
1187		LineText: tracker.contents[lineStart:lineEnd],
1188	}
1189}
1190
1191func detailStruct(data MsgData, terminalInfo TerminalInfo, maxMargin int) MsgDetail {
1192	// Only highlight the first line of the line text
1193	loc := *data.Location
1194	endOfFirstLine := len(loc.LineText)
1195	for i, c := range loc.LineText {
1196		if c == '\r' || c == '\n' || c == '\u2028' || c == '\u2029' {
1197			endOfFirstLine = i
1198			break
1199		}
1200	}
1201	firstLine := loc.LineText[:endOfFirstLine]
1202	afterFirstLine := loc.LineText[endOfFirstLine:]
1203
1204	// Clamp values in range
1205	if loc.Line < 0 {
1206		loc.Line = 0
1207	}
1208	if loc.Column < 0 {
1209		loc.Column = 0
1210	}
1211	if loc.Length < 0 {
1212		loc.Length = 0
1213	}
1214	if loc.Column > endOfFirstLine {
1215		loc.Column = endOfFirstLine
1216	}
1217	if loc.Length > endOfFirstLine-loc.Column {
1218		loc.Length = endOfFirstLine - loc.Column
1219	}
1220
1221	spacesPerTab := 2
1222	lineText := renderTabStops(firstLine, spacesPerTab)
1223	textUpToLoc := renderTabStops(firstLine[:loc.Column], spacesPerTab)
1224	markerStart := len(textUpToLoc)
1225	markerEnd := markerStart
1226	indent := strings.Repeat(" ", estimateWidthInTerminal(textUpToLoc))
1227	marker := "^"
1228
1229	// Extend markers to cover the full range of the error
1230	if loc.Length > 0 {
1231		markerEnd = len(renderTabStops(firstLine[:loc.Column+loc.Length], spacesPerTab))
1232	}
1233
1234	// Clip the marker to the bounds of the line
1235	if markerStart > len(lineText) {
1236		markerStart = len(lineText)
1237	}
1238	if markerEnd > len(lineText) {
1239		markerEnd = len(lineText)
1240	}
1241	if markerEnd < markerStart {
1242		markerEnd = markerStart
1243	}
1244
1245	// Trim the line to fit the terminal width
1246	width := terminalInfo.Width
1247	if width < 1 {
1248		width = defaultTerminalWidth
1249	}
1250	width -= maxMargin + extraMarginChars
1251	if width < 1 {
1252		width = 1
1253	}
1254	if loc.Column == endOfFirstLine {
1255		// If the marker is at the very end of the line, the marker will be a "^"
1256		// character that extends one column past the end of the line. In this case
1257		// we should reserve a column at the end so the marker doesn't wrap.
1258		width -= 1
1259	}
1260	if len(lineText) > width {
1261		// Try to center the error
1262		sliceStart := (markerStart + markerEnd - width) / 2
1263		if sliceStart > markerStart-width/5 {
1264			sliceStart = markerStart - width/5
1265		}
1266		if sliceStart < 0 {
1267			sliceStart = 0
1268		}
1269		if sliceStart > len(lineText)-width {
1270			sliceStart = len(lineText) - width
1271		}
1272		sliceEnd := sliceStart + width
1273
1274		// Slice the line
1275		slicedLine := lineText[sliceStart:sliceEnd]
1276		markerStart -= sliceStart
1277		markerEnd -= sliceStart
1278		if markerStart < 0 {
1279			markerStart = 0
1280		}
1281		if markerEnd > len(slicedLine) {
1282			markerEnd = len(slicedLine)
1283		}
1284
1285		// Truncate the ends with "..."
1286		if len(slicedLine) > 3 && sliceStart > 0 {
1287			slicedLine = "..." + slicedLine[3:]
1288			if markerStart < 3 {
1289				markerStart = 3
1290			}
1291		}
1292		if len(slicedLine) > 3 && sliceEnd < len(lineText) {
1293			slicedLine = slicedLine[:len(slicedLine)-3] + "..."
1294			if markerEnd > len(slicedLine)-3 {
1295				markerEnd = len(slicedLine) - 3
1296			}
1297			if markerEnd < markerStart {
1298				markerEnd = markerStart
1299			}
1300		}
1301
1302		// Now we can compute the indent
1303		lineText = slicedLine
1304		indent = strings.Repeat(" ", estimateWidthInTerminal(lineText[:markerStart]))
1305	}
1306
1307	// If marker is still multi-character after clipping, make the marker wider
1308	if markerEnd-markerStart > 1 {
1309		marker = strings.Repeat("~", estimateWidthInTerminal(lineText[markerStart:markerEnd]))
1310	}
1311
1312	// Put a margin before the marker indent
1313	margin := marginWithLineText(maxMargin, loc.Line)
1314
1315	return MsgDetail{
1316		Path:    loc.File,
1317		Line:    loc.Line,
1318		Column:  loc.Column,
1319		Message: data.Text,
1320
1321		SourceBefore: margin + lineText[:markerStart],
1322		SourceMarked: lineText[markerStart:markerEnd],
1323		SourceAfter:  lineText[markerEnd:],
1324
1325		Indent:     indent,
1326		Marker:     marker,
1327		Suggestion: loc.Suggestion,
1328
1329		ContentAfter: afterFirstLine,
1330	}
1331}
1332
1333// Estimate the number of columns this string will take when printed
1334func estimateWidthInTerminal(text string) int {
1335	// For now just assume each code point is one column. This is wrong but is
1336	// less wrong than assuming each code unit is one column.
1337	width := 0
1338	for text != "" {
1339		c, size := utf8.DecodeRuneInString(text)
1340		text = text[size:]
1341
1342		// Ignore the Zero Width No-Break Space character (UTF-8 BOM)
1343		if c != 0xFEFF {
1344			width++
1345		}
1346	}
1347	return width
1348}
1349
1350func renderTabStops(withTabs string, spacesPerTab int) string {
1351	if !strings.ContainsRune(withTabs, '\t') {
1352		return withTabs
1353	}
1354
1355	withoutTabs := strings.Builder{}
1356	count := 0
1357
1358	for _, c := range withTabs {
1359		if c == '\t' {
1360			spaces := spacesPerTab - count%spacesPerTab
1361			for i := 0; i < spaces; i++ {
1362				withoutTabs.WriteRune(' ')
1363				count++
1364			}
1365		} else {
1366			withoutTabs.WriteRune(c)
1367			count++
1368		}
1369	}
1370
1371	return withoutTabs.String()
1372}
1373
1374func (log Log) AddError(tracker *LineColumnTracker, loc Loc, text string) {
1375	log.AddMsg(Msg{
1376		Kind: Error,
1377		Data: RangeData(tracker, Range{Loc: loc}, text),
1378	})
1379}
1380
1381func (log Log) AddErrorWithNotes(tracker *LineColumnTracker, loc Loc, text string, notes []MsgData) {
1382	log.AddMsg(Msg{
1383		Kind:  Error,
1384		Data:  RangeData(tracker, Range{Loc: loc}, text),
1385		Notes: notes,
1386	})
1387}
1388
1389func (log Log) AddRangeError(tracker *LineColumnTracker, r Range, text string) {
1390	log.AddMsg(Msg{
1391		Kind: Error,
1392		Data: RangeData(tracker, r, text),
1393	})
1394}
1395
1396func (log Log) AddRangeErrorWithNotes(tracker *LineColumnTracker, r Range, text string, notes []MsgData) {
1397	log.AddMsg(Msg{
1398		Kind:  Error,
1399		Data:  RangeData(tracker, r, text),
1400		Notes: notes,
1401	})
1402}
1403
1404func (log Log) AddWarning(tracker *LineColumnTracker, loc Loc, text string) {
1405	log.AddMsg(Msg{
1406		Kind: Warning,
1407		Data: RangeData(tracker, Range{Loc: loc}, text),
1408	})
1409}
1410
1411func (log Log) AddRangeWarning(tracker *LineColumnTracker, r Range, text string) {
1412	log.AddMsg(Msg{
1413		Kind: Warning,
1414		Data: RangeData(tracker, r, text),
1415	})
1416}
1417
1418func (log Log) AddRangeWarningWithNotes(tracker *LineColumnTracker, r Range, text string, notes []MsgData) {
1419	log.AddMsg(Msg{
1420		Kind:  Warning,
1421		Data:  RangeData(tracker, r, text),
1422		Notes: notes,
1423	})
1424}
1425
1426func (log Log) AddDebug(tracker *LineColumnTracker, loc Loc, text string) {
1427	log.AddMsg(Msg{
1428		Kind: Debug,
1429		Data: RangeData(tracker, Range{Loc: loc}, text),
1430	})
1431}
1432
1433func (log Log) AddDebugWithNotes(tracker *LineColumnTracker, loc Loc, text string, notes []MsgData) {
1434	log.AddMsg(Msg{
1435		Kind:  Debug,
1436		Data:  RangeData(tracker, Range{Loc: loc}, text),
1437		Notes: notes,
1438	})
1439}
1440
1441func (log Log) AddRangeDebug(tracker *LineColumnTracker, r Range, text string) {
1442	log.AddMsg(Msg{
1443		Kind: Debug,
1444		Data: RangeData(tracker, r, text),
1445	})
1446}
1447
1448func (log Log) AddRangeDebugWithNotes(tracker *LineColumnTracker, r Range, text string, notes []MsgData) {
1449	log.AddMsg(Msg{
1450		Kind:  Debug,
1451		Data:  RangeData(tracker, r, text),
1452		Notes: notes,
1453	})
1454}
1455
1456func (log Log) AddVerbose(tracker *LineColumnTracker, loc Loc, text string) {
1457	log.AddMsg(Msg{
1458		Kind: Verbose,
1459		Data: RangeData(tracker, Range{Loc: loc}, text),
1460	})
1461}
1462
1463func (log Log) AddVerboseWithNotes(tracker *LineColumnTracker, loc Loc, text string, notes []MsgData) {
1464	log.AddMsg(Msg{
1465		Kind:  Verbose,
1466		Data:  RangeData(tracker, Range{Loc: loc}, text),
1467		Notes: notes,
1468	})
1469}
1470
1471func RangeData(tracker *LineColumnTracker, r Range, text string) MsgData {
1472	return MsgData{
1473		Text:     text,
1474		Location: LocationOrNil(tracker, r),
1475	}
1476}
1477