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