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

448 lines
12 KiB
Go

package fourslash
import (
"fmt"
"slices"
"strings"
"testing"
"unicode/utf8"
"efprojects.com/kitten-ipc/kitcom/internal/tsgo/core"
"efprojects.com/kitten-ipc/kitcom/internal/tsgo/ls"
"efprojects.com/kitten-ipc/kitcom/internal/tsgo/lsp/lsproto"
"efprojects.com/kitten-ipc/kitcom/internal/tsgo/stringutil"
"efprojects.com/kitten-ipc/kitcom/internal/tsgo/testrunner"
"efprojects.com/kitten-ipc/kitcom/internal/tsgo/tspath"
"github.com/go-json-experiment/json"
)
// Inserted in source files by surrounding desired text
// in a range with `[|` and `|]`. For example,
//
// [|text in range|]
//
// is a range with `text in range` "selected".
type RangeMarker struct {
fileName string
Range core.TextRange
LSRange lsproto.Range
Marker *Marker
}
func (r *RangeMarker) LSPos() lsproto.Position {
return r.LSRange.Start
}
func (r *RangeMarker) FileName() string {
return r.fileName
}
func (r *RangeMarker) GetName() *string {
if r.Marker == nil {
return nil
}
return r.Marker.Name
}
type Marker struct {
fileName string
Position int
LSPosition lsproto.Position
Name *string // `nil` for anonymous markers such as `{| "foo": "bar" |}`
Data map[string]any
}
func (m *Marker) LSPos() lsproto.Position {
return m.LSPosition
}
func (m *Marker) FileName() string {
return m.fileName
}
func (m *Marker) GetName() *string {
return m.Name
}
type MarkerOrRange interface {
FileName() string
LSPos() lsproto.Position
GetName() *string
}
type TestData struct {
Files []*TestFileInfo
MarkerPositions map[string]*Marker
Markers []*Marker
Symlinks map[string]string
GlobalOptions map[string]string
Ranges []*RangeMarker
}
type testFileWithMarkers struct {
file *TestFileInfo
markers []*Marker
ranges []*RangeMarker
}
func ParseTestData(t *testing.T, contents string, fileName string) TestData {
// List of all the subfiles we've parsed out
var files []*TestFileInfo
markerPositions := make(map[string]*Marker)
var markers []*Marker
var ranges []*RangeMarker
filesWithMarker, symlinks, _, globalOptions, e := testrunner.ParseTestFilesAndSymlinks(
contents,
fileName,
parseFileContent,
)
if e != nil {
t.Fatalf("Error parsing fourslash data: %s", e.Error())
}
hasTSConfig := false
for _, file := range filesWithMarker {
files = append(files, file.file)
hasTSConfig = hasTSConfig || isConfigFile(file.file.fileName)
markers = append(markers, file.markers...)
ranges = append(ranges, file.ranges...)
for _, marker := range file.markers {
if marker.Name == nil {
if marker.Data != nil {
// The marker is an anonymous object marker, which does not need a name. Markers are only set into markerPositions if they have a name
continue
}
t.Fatalf(`Marker at position %v is unnamed`, marker.Position)
}
if existing, ok := markerPositions[*marker.Name]; ok {
t.Fatalf(`Duplicate marker name: "%s" at %v and %v`, *marker.Name, marker.Position, existing.Position)
}
markerPositions[*marker.Name] = marker
}
}
if hasTSConfig && len(globalOptions) > 0 {
t.Fatalf("It is not allowed to use global options along with config files.")
}
return TestData{
Files: files,
MarkerPositions: markerPositions,
Markers: markers,
Symlinks: symlinks,
GlobalOptions: globalOptions,
Ranges: ranges,
}
}
func isConfigFile(fileName string) bool {
fileName = strings.ToLower(fileName)
return strings.HasSuffix(fileName, "tsconfig.json") || strings.HasSuffix(fileName, "jsconfig.json")
}
type locationInformation struct {
position int
sourcePosition int
sourceLine int
sourceColumn int
}
type rangeLocationInformation struct {
locationInformation
marker *Marker
}
type TestFileInfo struct {
fileName string
// The contents of the file (with markers, etc stripped out)
Content string
emit bool
}
// FileName implements ls.Script.
func (t *TestFileInfo) FileName() string {
return t.fileName
}
// Text implements ls.Script.
func (t *TestFileInfo) Text() string {
return t.Content
}
var _ ls.Script = (*TestFileInfo)(nil)
const emitThisFileOption = "emitthisfile"
type parserState int
const (
stateNone parserState = iota
stateInSlashStarMarker
stateInObjectMarker
)
func parseFileContent(fileName string, content string, fileOptions map[string]string) (*testFileWithMarkers, error) {
fileName = tspath.GetNormalizedAbsolutePath(fileName, "/")
// The file content (minus metacharacters) so far
var output strings.Builder
var markers []*Marker
/// A stack of the open range markers that are still unclosed
openRanges := []rangeLocationInformation{}
/// A list of closed ranges we've collected so far
rangeMarkers := []*RangeMarker{}
// The total number of metacharacters removed from the file (so far)
difference := 0
// One-based current position data
line := 1
column := 1
// The current marker (or maybe multi-line comment?) we're parsing, possibly
var openMarker *locationInformation
// The latest position of the start of an unflushed plain text area
lastNormalCharPosition := 0
flush := func(lastSafeCharIndex int) {
if lastSafeCharIndex != -1 {
output.WriteString(content[lastNormalCharPosition:lastSafeCharIndex])
} else {
output.WriteString(content[lastNormalCharPosition:])
}
}
state := stateNone
previousCharacter, i := utf8.DecodeRuneInString(content)
var size int
var currentCharacter rune
for ; i < len(content); i = i + size {
currentCharacter, size = utf8.DecodeRuneInString(content[i:])
switch state {
case stateNone:
if previousCharacter == '[' && currentCharacter == '|' {
// found a range start
openRanges = append(openRanges, rangeLocationInformation{
locationInformation: locationInformation{
position: (i - 1) - difference,
sourcePosition: i - 1,
sourceLine: line,
sourceColumn: column,
},
})
// copy all text up to marker position
flush(i - 1)
lastNormalCharPosition = i + 1
difference += 2
} else if previousCharacter == '|' && currentCharacter == ']' {
// found a range end
if len(openRanges) == 0 {
return nil, reportError(fileName, line, column, "Found range end with no matching start.")
}
rangeStart := openRanges[len(openRanges)-1]
openRanges = openRanges[:len(openRanges)-1]
closedRange := &RangeMarker{
fileName: fileName,
Range: core.NewTextRange(rangeStart.position, (i-1)-difference),
Marker: rangeStart.marker,
}
rangeMarkers = append(rangeMarkers, closedRange)
// copy all text up to range marker position
flush(i - 1)
lastNormalCharPosition = i + 1
difference += 2
} else if previousCharacter == '/' && currentCharacter == '*' {
// found a possible marker start
state = stateInSlashStarMarker
openMarker = &locationInformation{
position: (i - 1) - difference,
sourcePosition: i - 1,
sourceLine: line,
sourceColumn: column - 1,
}
} else if previousCharacter == '{' && currentCharacter == '|' {
// found an object marker start
state = stateInObjectMarker
openMarker = &locationInformation{
position: (i - 1) - difference,
sourcePosition: i - 1,
sourceLine: line,
sourceColumn: column,
}
flush(i - 1)
}
case stateInObjectMarker:
// Object markers are only ever terminated by |} and have no content restrictions
if previousCharacter == '|' && currentCharacter == '}' {
objectMarkerData := strings.TrimSpace(content[openMarker.sourcePosition+2 : i-1])
marker, e := getObjectMarker(fileName, openMarker, objectMarkerData)
if e != nil {
return nil, e
}
if len(openRanges) > 0 {
openRanges[len(openRanges)-1].marker = marker
}
markers = append(markers, marker)
// Set the current start to point to the end of the current marker to ignore its text
lastNormalCharPosition = i + 1
difference += i + 1 - openMarker.sourcePosition
// Reset the state
openMarker = nil
state = stateNone
}
case stateInSlashStarMarker:
if previousCharacter == '*' && currentCharacter == '/' {
// Record the marker
// start + 2 to ignore the */, -1 on the end to ignore the * (/ is next)
markerNameText := strings.TrimSpace(content[openMarker.sourcePosition+2 : i-1])
marker := &Marker{
fileName: fileName,
Position: openMarker.position,
Name: &markerNameText,
}
if len(openRanges) > 0 {
openRanges[len(openRanges)-1].marker = marker
}
markers = append(markers, marker)
// Set the current start to point to the end of the current marker to ignore its text
flush(openMarker.sourcePosition)
lastNormalCharPosition = i + 1
difference += i + 1 - openMarker.sourcePosition
// Reset the state
openMarker = nil
state = stateNone
} else if !(stringutil.IsDigit(currentCharacter) ||
stringutil.IsASCIILetter(currentCharacter) ||
currentCharacter == '$' ||
currentCharacter == '_') { // Invalid marker character
if currentCharacter == '*' && i < len(content)-1 && content[i+1] == '/' {
// The marker is about to be closed, ignore the 'invalid' char
} else {
// We've hit a non-valid marker character, so we were actually in a block comment
// Bail out the text we've gathered so far back into the output
flush(i)
lastNormalCharPosition = i
openMarker = nil
state = stateNone
}
}
}
if currentCharacter == '\n' && previousCharacter == '\r' {
// Ignore trailing \n after \r
continue
} else if currentCharacter == '\n' || currentCharacter == '\r' {
line++
column = 1
continue
}
column++
previousCharacter = currentCharacter
}
// Add the remaining text
flush(-1)
if len(openRanges) > 0 {
openRange := openRanges[0]
return nil, reportError(fileName, openRange.sourceLine, openRange.sourceColumn, "Unterminated range.")
}
if openMarker != nil {
return nil, reportError(fileName, openMarker.sourceLine, openMarker.sourceColumn, "Unterminated marker.")
}
outputString := output.String()
// Set LS positions for markers
lineMap := ls.ComputeLSPLineStarts(outputString)
converters := ls.NewConverters(lsproto.PositionEncodingKindUTF8, func(_ string) *ls.LSPLineMap {
return lineMap
})
emit := fileOptions[emitThisFileOption] == "true"
testFileInfo := &TestFileInfo{
fileName: fileName,
Content: outputString,
emit: emit,
}
slices.SortStableFunc(rangeMarkers, func(a, b *RangeMarker) int {
if a.Range.Pos() != b.Range.Pos() {
return a.Range.Pos() - b.Range.Pos()
}
return b.Range.End() - a.Range.End()
})
for _, marker := range markers {
marker.LSPosition = converters.PositionToLineAndCharacter(testFileInfo, core.TextPos(marker.Position))
}
for _, rangeMarker := range rangeMarkers {
rangeMarker.LSRange = lsproto.Range{
Start: converters.PositionToLineAndCharacter(testFileInfo, core.TextPos(rangeMarker.Range.Pos())),
End: converters.PositionToLineAndCharacter(testFileInfo, core.TextPos(rangeMarker.Range.End())),
}
}
return &testFileWithMarkers{
file: testFileInfo,
markers: markers,
ranges: rangeMarkers,
}, nil
}
func getObjectMarker(fileName string, location *locationInformation, text string) (*Marker, error) {
// Attempt to parse the marker value as JSON
var v interface{}
e := json.Unmarshal([]byte("{ "+text+" }"), &v)
if e != nil {
return nil, reportError(fileName, location.sourceLine, location.sourceColumn, "Unable to parse marker text "+text)
}
markerValue, ok := v.(map[string]interface{})
if !ok || len(markerValue) == 0 {
return nil, reportError(fileName, location.sourceLine, location.sourceColumn, "Object markers can not be empty")
}
marker := &Marker{
fileName: fileName,
Position: location.position,
Data: markerValue,
}
// Object markers can be anonymous
if markerValue["name"] != nil {
if name, ok := markerValue["name"].(string); ok && name != "" {
marker.Name = &name
}
}
return marker, nil
}
func reportError(fileName string, line int, col int, message string) error {
return &fourslashError{fmt.Sprintf("%v (%v,%v): %v", fileName, line, col, message)}
}
type fourslashError struct {
err string
}
func (e *fourslashError) Error() string {
return e.err
}