2025-10-15 10:12:44 +03:00

256 lines
9.6 KiB
Go

package tsbaseline
import (
"fmt"
"io"
"regexp"
"slices"
"strings"
"testing"
"unicode/utf8"
"efprojects.com/kitten-ipc/kitcom/internal/tsgo/ast"
"efprojects.com/kitten-ipc/kitcom/internal/tsgo/core"
"efprojects.com/kitten-ipc/kitcom/internal/tsgo/diagnosticwriter"
"efprojects.com/kitten-ipc/kitcom/internal/tsgo/testutil/baseline"
"efprojects.com/kitten-ipc/kitcom/internal/tsgo/testutil/harnessutil"
"efprojects.com/kitten-ipc/kitcom/internal/tsgo/tspath"
"gotest.tools/v3/assert"
"gotest.tools/v3/assert/cmp"
)
// IO
const harnessNewLine = "\r\n"
var formatOpts = &diagnosticwriter.FormattingOptions{
NewLine: harnessNewLine,
}
var (
diagnosticsLocationPrefix = regexp.MustCompile(`(?im)^(lib.*\.d\.ts)\(\d+,\d+\)`)
diagnosticsLocationPattern = regexp.MustCompile(`(?i)(lib.*\.d\.ts):\d+:\d+`)
)
func DoErrorBaseline(t *testing.T, baselinePath string, inputFiles []*harnessutil.TestFile, errors []*ast.Diagnostic, pretty bool, opts baseline.Options) {
baselinePath = tsExtension.ReplaceAllString(baselinePath, ".errors.txt")
var errorBaseline string
if len(errors) > 0 {
errorBaseline = getErrorBaseline(t, inputFiles, errors, pretty)
} else {
errorBaseline = baseline.NoContent
}
baseline.Run(t, baselinePath, errorBaseline, opts)
}
func minimalDiagnosticsToString(diagnostics []*ast.Diagnostic, pretty bool) string {
var output strings.Builder
if pretty {
diagnosticwriter.FormatDiagnosticsWithColorAndContext(&output, diagnostics, formatOpts)
} else {
diagnosticwriter.WriteFormatDiagnostics(&output, diagnostics, formatOpts)
}
return output.String()
}
func getErrorBaseline(t *testing.T, inputFiles []*harnessutil.TestFile, diagnostics []*ast.Diagnostic, pretty bool) string {
t.Helper()
outputLines := iterateErrorBaseline(t, inputFiles, diagnostics, pretty)
if pretty {
var summaryBuilder strings.Builder
diagnosticwriter.WriteErrorSummaryText(
&summaryBuilder,
diagnostics,
formatOpts)
summary := removeTestPathPrefixes(summaryBuilder.String(), false)
outputLines = append(outputLines, summary)
}
return strings.Join(outputLines, "")
}
func iterateErrorBaseline(t *testing.T, inputFiles []*harnessutil.TestFile, inputDiagnostics []*ast.Diagnostic, pretty bool) []string {
t.Helper()
diagnostics := slices.Clone(inputDiagnostics)
slices.SortFunc(diagnostics, ast.CompareDiagnostics)
var outputLines strings.Builder
// Count up all errors that were found in files other than lib.d.ts so we don't miss any
totalErrorsReportedInNonLibraryNonTsconfigFiles := 0
errorsReported := 0
firstLine := true
newLine := func() string {
if firstLine {
firstLine = false
return ""
}
return "\r\n"
}
var result []string
outputErrorText := func(diag *ast.Diagnostic) {
message := diagnosticwriter.FlattenDiagnosticMessage(diag, harnessNewLine)
var errLines []string
for _, line := range strings.Split(removeTestPathPrefixes(message, false), "\n") {
line = strings.TrimSuffix(line, "\r")
if len(line) < 0 {
continue
}
out := fmt.Sprintf("!!! %s TS%d: %s", diag.Category().Name(), diag.Code(), line)
errLines = append(errLines, out)
}
for _, info := range diag.RelatedInformation() {
var location string
if info.File() != nil {
location = " " + formatLocation(info.File(), info.Loc().Pos(), formatOpts, func(output io.Writer, text string, formatStyle string) { fmt.Fprint(output, text) })
}
location = removeTestPathPrefixes(location, false)
if len(location) > 0 && isDefaultLibraryFile(info.File().FileName()) {
location = diagnosticsLocationPattern.ReplaceAllString(location, "$1:--:--")
}
errLines = append(errLines, fmt.Sprintf("!!! related TS%d%s: %s", info.Code(), location, diagnosticwriter.FlattenDiagnosticMessage(info, harnessNewLine)))
}
for _, e := range errLines {
outputLines.WriteString(newLine())
outputLines.WriteString(e)
}
errorsReported++
// do not count errors from lib.d.ts here, they are computed separately as numLibraryDiagnostics
// if lib.d.ts is explicitly included in input files and there are some errors in it (i.e. because of duplicate identifiers)
// then they will be added twice thus triggering 'total errors' assertion with condition
// Similarly for tsconfig, which may be in the input files and contain errors.
// 'totalErrorsReportedInNonLibraryNonTsconfigFiles + numLibraryDiagnostics + numTsconfigDiagnostics, diagnostics.length
if diag.File() == nil || !isDefaultLibraryFile(diag.File().FileName()) && !isTsConfigFile(diag.File().FileName()) {
totalErrorsReportedInNonLibraryNonTsconfigFiles++
}
}
topDiagnostics := minimalDiagnosticsToString(diagnostics, pretty)
topDiagnostics = removeTestPathPrefixes(topDiagnostics, false)
topDiagnostics = diagnosticsLocationPrefix.ReplaceAllString(topDiagnostics, "$1(--,--)")
result = append(result, topDiagnostics+harnessNewLine+harnessNewLine)
// Report global errors
for _, error := range diagnostics {
if error.File() == nil {
outputErrorText(error)
}
}
result = append(result, outputLines.String())
outputLines.Reset()
errorsReported = 0
// 'merge' the lines of each input file with any errors associated with it
dupeCase := map[string]int{}
for _, inputFile := range inputFiles {
// Filter down to the errors in the file
fileErrors := core.Filter(diagnostics, func(e *ast.Diagnostic) bool {
return e.File() != nil &&
tspath.ComparePaths(removeTestPathPrefixes(e.File().FileName(), false), removeTestPathPrefixes(inputFile.UnitName, false), tspath.ComparePathsOptions{}) == 0
})
// Header
fmt.Fprintf(&outputLines,
"%s==== %s (%d errors) ====",
newLine(),
removeTestPathPrefixes(inputFile.UnitName, false),
len(fileErrors),
)
// Make sure we emit something for every error
markedErrorCount := 0
// For each line, emit the line followed by any error squiggles matching this line
lineStarts := core.ComputeECMALineStarts(inputFile.Content)
lines := lineDelimiter.Split(inputFile.Content, -1)
for lineIndex, line := range lines {
if len(line) > 0 && line[len(line)-1] == '\r' {
line = line[:len(line)-1]
}
thisLineStart := int(lineStarts[lineIndex])
var nextLineStart int
// On the last line of the file, fake the next line start number so that we handle errors on the last character of the file correctly
if lineIndex == len(lines)-1 {
nextLineStart = len(inputFile.Content)
} else {
nextLineStart = int(lineStarts[lineIndex+1])
}
// Emit this line from the original file
outputLines.WriteString(newLine())
outputLines.WriteString(" ")
outputLines.WriteString(line)
for _, errDiagnostic := range fileErrors {
// Does any error start or continue on to this line? Emit squiggles
errStart := errDiagnostic.Loc().Pos()
end := errDiagnostic.Loc().End()
if end >= thisLineStart && (errStart < nextLineStart || lineIndex == len(lines)-1) {
// How many characters from the start of this line the error starts at (could be positive or negative)
relativeOffset := errStart - thisLineStart
// How many characters of the error are on this line (might be longer than this line in reality)
length := (end - errStart) - max(0, thisLineStart-errStart)
// Calculate the start of the squiggle
squiggleStart := max(0, relativeOffset)
// TODO/REVIEW: this doesn't work quite right in the browser if a multi file test has files whose names are just the right length relative to one another
outputLines.WriteString(newLine())
outputLines.WriteString(" ")
outputLines.WriteString(nonWhitespace.ReplaceAllString(line[:squiggleStart], " "))
// This was `new Array(count).join("~")`; which maps 0 to "", 1 to "", 2 to "~", 3 to "~~", etc.
squiggleEnd := max(squiggleStart, min(squiggleStart+length, len(line)))
outputLines.WriteString(strings.Repeat("~", utf8.RuneCountInString(line[squiggleStart:squiggleEnd])))
// If the error ended here, or we're at the end of the file, emit its message
if lineIndex == len(lines)-1 || nextLineStart > end {
outputErrorText(errDiagnostic)
markedErrorCount++
}
}
}
}
// Verify we didn't miss any errors in this file
assert.Check(t, cmp.Equal(markedErrorCount, len(fileErrors)), "count of errors in "+inputFile.UnitName)
_, isDupe := dupeCase[sanitizeTestFilePath(inputFile.UnitName)]
result = append(result, outputLines.String())
if isDupe {
// Case-duplicated files on a case-insensitive build will have errors reported in both the dupe and the original
// thanks to the canse-insensitive path comparison on the error file path - We only want to count those errors once
// for the assert below, so we subtract them here.
totalErrorsReportedInNonLibraryNonTsconfigFiles -= errorsReported
}
outputLines.Reset()
errorsReported = 0
}
numLibraryDiagnostics := core.CountWhere(
diagnostics,
func(d *ast.Diagnostic) bool {
return d.File() != nil && (isDefaultLibraryFile(d.File().FileName()) || isBuiltFile(d.File().FileName()))
})
numTsconfigDiagnostics := core.CountWhere(
diagnostics,
func(d *ast.Diagnostic) bool {
return d.File() != nil && isTsConfigFile(d.File().FileName())
})
// Verify we didn't miss any errors in total
assert.Check(t, cmp.Equal(totalErrorsReportedInNonLibraryNonTsconfigFiles+numLibraryDiagnostics+numTsconfigDiagnostics, len(diagnostics)), "total number of errors")
return result
}
func formatLocation(file *ast.SourceFile, pos int, formatOpts *diagnosticwriter.FormattingOptions, writeWithStyleAndReset diagnosticwriter.FormattedWriter) string {
var output strings.Builder
diagnosticwriter.WriteLocation(&output, file, pos, formatOpts, writeWithStyleAndReset)
return output.String()
}