kittenipc/kitcom/internal/tsgo/testrunner/test_case_parser.go
2025-10-15 10:12:44 +03:00

228 lines
7.6 KiB
Go

package testrunner
import (
"regexp"
"slices"
"strings"
"efprojects.com/kitten-ipc/kitcom/internal/tsgo/ast"
"efprojects.com/kitten-ipc/kitcom/internal/tsgo/core"
"efprojects.com/kitten-ipc/kitcom/internal/tsgo/parser"
"efprojects.com/kitten-ipc/kitcom/internal/tsgo/scanner"
"efprojects.com/kitten-ipc/kitcom/internal/tsgo/testutil/harnessutil"
"efprojects.com/kitten-ipc/kitcom/internal/tsgo/tsoptions"
"efprojects.com/kitten-ipc/kitcom/internal/tsgo/tsoptions/tsoptionstest"
"efprojects.com/kitten-ipc/kitcom/internal/tsgo/tspath"
)
var lineDelimiter = regexp.MustCompile("\r?\n")
// This maps a compiler setting to its value as written in the test file. For example, if a test file contains:
//
// // @target: esnext, es2015
//
// Then the map will map "target" to "esnext, es2015"
type rawCompilerSettings map[string]string
// All the necessary information to turn a multi file test into useful units for later compilation
type testUnit struct {
content string
name string
}
type testCaseContent struct {
testUnitData []*testUnit
tsConfig *tsoptions.ParsedCommandLine
tsConfigFileUnitData *testUnit
symlinks map[string]string
}
// Regex for parsing options in the format "@Alpha: Value of any sort"
var optionRegex = regexp.MustCompile(`(?m)^\/{2}\s*@(\w+)\s*:\s*([^\r\n]*)`)
// Regex for parsing @link option
var linkRegex = regexp.MustCompile(`(?m)^\/{2}\s*@link\s*:\s*([^\r\n]*)\s*->\s*([^\r\n]*)`)
// File-specific directives used by fourslash tests
var fourslashDirectives = []string{"emitthisfile"}
// Given a test file containing // @FileName directives,
// return an array of named units of code to be added to an existing compiler instance.
func makeUnitsFromTest(code string, fileName string) testCaseContent {
testUnits, symlinks, currentDirectory, _, _ := ParseTestFilesAndSymlinks(
code,
fileName,
func(filename string, content string, fileOptions map[string]string) (*testUnit, error) {
return &testUnit{content: content, name: filename}, nil
},
)
if currentDirectory == "" {
currentDirectory = srcFolder
}
// unit tests always list files explicitly
allFiles := make(map[string]string)
for _, data := range testUnits {
allFiles[tspath.GetNormalizedAbsolutePath(data.name, currentDirectory)] = data.content
}
parseConfigHost := tsoptionstest.NewVFSParseConfigHost(allFiles, currentDirectory, true /*useCaseSensitiveFileNames*/)
// check if project has tsconfig.json in the list of files
var tsConfig *tsoptions.ParsedCommandLine
var tsConfigFileUnitData *testUnit
for i, data := range testUnits {
if harnessutil.GetConfigNameFromFileName(data.name) != "" {
configFileName := tspath.GetNormalizedAbsolutePath(data.name, currentDirectory)
path := tspath.ToPath(data.name, parseConfigHost.GetCurrentDirectory(), parseConfigHost.Vfs.UseCaseSensitiveFileNames())
configJson := parser.ParseSourceFile(ast.SourceFileParseOptions{
FileName: configFileName,
Path: path,
}, data.content, core.ScriptKindJSON)
tsConfigSourceFile := &tsoptions.TsConfigSourceFile{
SourceFile: configJson,
}
configDir := tspath.GetDirectoryPath(configFileName)
tsConfig = tsoptions.ParseJsonSourceFileConfigFileContent(
tsConfigSourceFile,
parseConfigHost,
configDir,
nil, /*existingOptions*/
configFileName,
nil, /*resolutionStack*/
nil, /*extraFileExtensions*/
nil /*extendedConfigCache*/)
tsConfigFileUnitData = data
// delete tsconfig file entry from the list
testUnits = slices.Delete(testUnits, i, i+1)
break
}
}
return testCaseContent{
testUnitData: testUnits,
tsConfig: tsConfig,
tsConfigFileUnitData: tsConfigFileUnitData,
symlinks: symlinks,
}
}
// Given a test file containing // @FileName and // @symlink directives,
// return an array of named units of code to be added to an existing compiler instance,
// along with a map of symlinks and the current directory.
func ParseTestFilesAndSymlinks[T any](
code string,
fileName string,
parseFile func(filename string, content string, fileOptions map[string]string) (T, error),
) (units []T, symlinks map[string]string, currentDir string, globalOptions map[string]string, e error) {
// List of all the subfiles we've parsed out
var testUnits []T
lines := lineDelimiter.Split(code, -1)
// Stuff related to the subfile we're parsing
var currentFileContent strings.Builder
var currentFileName string
var currentDirectory string
var parseError error
currentFileOptions := make(map[string]string)
symlinks = make(map[string]string)
globalOptions = make(map[string]string)
for _, line := range lines {
ok := parseSymlinkFromTest(line, symlinks)
if ok {
continue
}
if testMetaData := optionRegex.FindStringSubmatch(line); testMetaData != nil {
// Comment line, check for global/file @options and record them
metaDataName := strings.ToLower(testMetaData[1])
metaDataValue := strings.TrimSpace(testMetaData[2])
if metaDataName == "currentdirectory" {
currentDirectory = metaDataValue
}
if metaDataName != "filename" {
if slices.Contains(fourslashDirectives, metaDataName) {
// File-specific option
currentFileOptions[metaDataName] = metaDataValue
} else {
// Global option
if existingValue, ok := globalOptions[metaDataName]; ok && existingValue != metaDataValue {
// !!! This would break existing submodule tests
// panic("Duplicate global option: " + metaDataName)
}
globalOptions[metaDataName] = metaDataValue
}
continue
}
// New metadata statement after having collected some code to go with the previous metadata
if currentFileName != "" {
// Store result file
newTestFile, e := parseFile(currentFileName, currentFileContent.String(), currentFileOptions)
if e != nil {
parseError = e
break
}
testUnits = append(testUnits, newTestFile)
// Reset local data
currentFileContent.Reset()
currentFileName = metaDataValue
currentFileOptions = make(map[string]string)
} else {
// First metadata marker in the file
currentFileName = strings.TrimSpace(testMetaData[2])
if currentFileContent.Len() != 0 && scanner.SkipTrivia(currentFileContent.String(), 0) != currentFileContent.Len() {
panic("Non-comment test content appears before the first '// @Filename' directive")
}
currentFileContent.Reset()
}
} else {
// Subfile content line
// Append to the current subfile content, inserting a newline if needed
if currentFileContent.Len() != 0 {
// End-of-line
currentFileContent.WriteRune('\n')
}
currentFileContent.WriteString(line)
}
}
// normalize the fileName for the single file case
if len(testUnits) == 0 && len(currentFileName) == 0 {
currentFileName = tspath.GetBaseFileName(fileName)
}
// if there are no parse errors so far, parse the rest of the file
if parseError == nil {
// EOF, push whatever remains
newTestFile2, e := parseFile(currentFileName, currentFileContent.String(), currentFileOptions)
parseError = e
testUnits = append(testUnits, newTestFile2)
}
return testUnits, symlinks, currentDirectory, globalOptions, parseError
}
func extractCompilerSettings(content string) rawCompilerSettings {
opts := make(map[string]string)
for _, match := range optionRegex.FindAllStringSubmatch(content, -1) {
opts[strings.ToLower(match[1])] = strings.TrimSuffix(strings.TrimSpace(match[2]), ";")
}
return opts
}
func parseSymlinkFromTest(line string, symlinks map[string]string) bool {
linkMetaData := linkRegex.FindStringSubmatch(line)
if len(linkMetaData) == 0 {
return false
}
symlinks[strings.TrimSpace(linkMetaData[2])] = strings.TrimSpace(linkMetaData[1])
return true
}