package fourslash import ( "cmp" "errors" "fmt" "io/fs" "regexp" "slices" "strings" "testing" "efprojects.com/kitten-ipc/kitcom/internal/tsgo/collections" "efprojects.com/kitten-ipc/kitcom/internal/tsgo/core" "efprojects.com/kitten-ipc/kitcom/internal/tsgo/debug" "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/testutil/baseline" "efprojects.com/kitten-ipc/kitcom/internal/tsgo/vfs" ) func (f *FourslashTest) addResultToBaseline(t *testing.T, command string, actual string) { b, ok := f.baselines[command] if !ok { f.baselines[command] = &strings.Builder{} b = f.baselines[command] } if b.Len() != 0 { b.WriteString("\n\n\n\n") } b.WriteString(`// === ` + command + " ===\n" + actual) } func (f *FourslashTest) writeToBaseline(command string, content string) { b, ok := f.baselines[command] if !ok { f.baselines[command] = &strings.Builder{} b = f.baselines[command] } b.WriteString(content) } func getBaselineFileName(t *testing.T, command string) string { return getBaseFileNameFromTest(t) + "." + getBaselineExtension(command) } func getBaselineExtension(command string) string { switch command { case "QuickInfo", "SignatureHelp": return "baseline" case "Auto Imports": return "baseline.md" case "findAllReferences", "goToDefinition", "findRenameLocations": return "baseline.jsonc" default: return "baseline.jsonc" } } func getBaselineOptions(command string) baseline.Options { subfolder := "fourslash/" + normalizeCommandName(command) switch command { case "findRenameLocations": return baseline.Options{ Subfolder: subfolder, IsSubmodule: true, DiffFixupOld: func(s string) string { var commandLines []string commandPrefix := regexp.MustCompile(`^// === ([a-z\sA-Z]*) ===`) testFilePrefix := "/tests/cases/fourslash" serverTestFilePrefix := "/server" contextSpanOpening := "<|" contextSpanClosing := "|>" oldPreference := "providePrefixAndSuffixTextForRename" newPreference := "useAliasesForRename" replacer := strings.NewReplacer( contextSpanOpening, "", contextSpanClosing, "", testFilePrefix, "", serverTestFilePrefix, "", oldPreference, newPreference, ) lines := strings.Split(s, "\n") var isInCommand bool for _, line := range lines { if strings.HasPrefix(line, "// @findInStrings: ") || strings.HasPrefix(line, "// @findInComments: ") { continue } matches := commandPrefix.FindStringSubmatch(line) if len(matches) > 0 { commandName := matches[1] if commandName == command { isInCommand = true } else { isInCommand = false } } if isInCommand { fixedLine := replacer.Replace(line) commandLines = append(commandLines, fixedLine) } } return strings.Join(commandLines, "\n") }, } default: return baseline.Options{ Subfolder: subfolder, } } } func normalizeCommandName(command string) string { words := strings.Fields(command) command = strings.Join(words, "") return stringutil.LowerFirstChar(command) } type baselineFourslashLocationsOptions struct { // markerInfo marker MarkerOrRange // location markerName string // name of the marker to be printed in baseline endMarker string startMarkerPrefix func(span lsproto.Location) *string endMarkerSuffix func(span lsproto.Location) *string } func (f *FourslashTest) getBaselineForLocationsWithFileContents(spans []lsproto.Location, options baselineFourslashLocationsOptions) string { locationsByFile := collections.GroupBy(spans, func(span lsproto.Location) lsproto.DocumentUri { return span.Uri }) rangesByFile := collections.MultiMap[lsproto.DocumentUri, lsproto.Range]{} for file, locs := range locationsByFile.M { for _, loc := range locs { rangesByFile.Add(file, loc.Range) } } return f.getBaselineForGroupedLocationsWithFileContents( &rangesByFile, options, ) } func (f *FourslashTest) getBaselineForGroupedLocationsWithFileContents(groupedRanges *collections.MultiMap[lsproto.DocumentUri, lsproto.Range], options baselineFourslashLocationsOptions) string { // We must always print the file containing the marker, // but don't want to print it twice at the end if it already // found in a file with ranges. foundMarker := false baselineEntries := []string{} err := f.vfs.WalkDir("/", func(path string, d vfs.DirEntry, e error) error { if e != nil { return e } if !d.Type().IsRegular() { return nil } fileName := ls.FileNameToDocumentURI(path) ranges := groupedRanges.Get(fileName) if len(ranges) == 0 { return nil } content, ok := f.vfs.ReadFile(path) if !ok { // !!! error? return nil } if options.marker != nil && options.marker.FileName() == path { foundMarker = true } baselineEntries = append(baselineEntries, f.getBaselineContentForFile(path, content, ranges, nil, options)) return nil }) if err != nil && !errors.Is(err, fs.ErrNotExist) { panic("walkdir error during fourslash baseline: " + err.Error()) } if !foundMarker && options.marker != nil { // If we didn't find the marker in any file, we need to add it. markerFileName := options.marker.FileName() if content, ok := f.vfs.ReadFile(markerFileName); ok { baselineEntries = append(baselineEntries, f.getBaselineContentForFile(markerFileName, content, nil, nil, options)) } } // !!! foundAdditionalSpan // !!! skipDocumentContainingOnlyMarker return strings.Join(baselineEntries, "\n\n") } type baselineDetail struct { pos lsproto.Position positionMarker string span *lsproto.Range kind string } func (f *FourslashTest) getBaselineContentForFile( fileName string, content string, spansInFile []lsproto.Range, spanToContextId map[lsproto.Range]int, options baselineFourslashLocationsOptions, ) string { details := []*baselineDetail{} detailPrefixes := map[*baselineDetail]string{} detailSuffixes := map[*baselineDetail]string{} canDetermineContextIdInline := true uri := ls.FileNameToDocumentURI(fileName) if options.marker != nil && options.marker.FileName() == fileName { details = append(details, &baselineDetail{pos: options.marker.LSPos(), positionMarker: options.markerName}) } for _, span := range spansInFile { textSpanIndex := len(details) details = append(details, &baselineDetail{pos: span.Start, positionMarker: "[|", span: &span, kind: "textStart"}, &baselineDetail{pos: span.End, positionMarker: core.OrElse(options.endMarker, "|]"), span: &span, kind: "textEnd"}, ) if options.startMarkerPrefix != nil { startPrefix := options.startMarkerPrefix(lsproto.Location{Uri: uri, Range: span}) if startPrefix != nil { // Special case: if this span starts at the same position as the provided marker, // we want the span's prefix to appear before the marker name. // i.e. We want `/*START PREFIX*/A: /*RENAME*/[|ARENAME|]`, // not `/*RENAME*//*START PREFIX*/A: [|ARENAME|]` if options.marker != nil && fileName == options.marker.FileName() && span.Start == options.marker.LSPos() { _, ok := detailPrefixes[details[0]] debug.Assert(!ok, "Expected only single prefix at marker location") detailPrefixes[details[0]] = *startPrefix } else { detailPrefixes[details[textSpanIndex]] = *startPrefix } } } if options.endMarkerSuffix != nil { endSuffix := options.endMarkerSuffix(lsproto.Location{Uri: uri, Range: span}) if endSuffix != nil { detailSuffixes[details[textSpanIndex+1]] = *endSuffix } } } slices.SortStableFunc(details, func(d1, d2 *baselineDetail) int { return ls.ComparePositions(d1.pos, d2.pos) }) // !!! if canDetermineContextIdInline textWithContext := newTextWithContext(fileName, content) // Our preferred way to write marker is // /*MARKER*/[| some text |] // [| some /*MARKER*/ text |] // [| some text |]/*MARKER*/ // Stable sort should handle first two cases but with that marker will be before rangeEnd if locations match // So we will defer writing marker in this case by checking and finding index of rangeEnd if same var deferredMarkerIndex *int for index, detail := range details { if detail.span == nil && deferredMarkerIndex == nil { // If this is marker position and its same as textEnd and/or contextEnd we want to write marker after those for matchingEndPosIndex := index + 1; matchingEndPosIndex < len(details); matchingEndPosIndex++ { // Defer after the location if its same as rangeEnd if details[matchingEndPosIndex].pos == detail.pos && strings.HasSuffix(details[matchingEndPosIndex].kind, "End") { deferredMarkerIndex = ptrTo(matchingEndPosIndex) } // Dont defer further than already determined break } // Defer writing marker position to deffered marker index if deferredMarkerIndex != nil { continue } } textWithContext.add(detail) textWithContext.pos = detail.pos // Prefix prefix := detailPrefixes[detail] if prefix != "" { textWithContext.newContent.WriteString(prefix) } textWithContext.newContent.WriteString(detail.positionMarker) if detail.span != nil { switch detail.kind { case "textStart": var text string if contextId, ok := spanToContextId[*detail.span]; ok { isAfterContextStart := false for textStartIndex := index - 1; textStartIndex >= 0; textStartIndex-- { textStartDetail := details[textStartIndex] if textStartDetail.kind == "contextStart" && textStartDetail.span == detail.span { isAfterContextStart = true break } // Marker is ok to skip over if textStartDetail.span != nil { break } } // Skip contextId on span thats surrounded by context span immediately if !isAfterContextStart { if text == "" { text = fmt.Sprintf(`contextId: %v`, contextId) } else { text = fmt.Sprintf(`contextId: %v`, contextId) + `, ` + text } } } if text != "" { textWithContext.newContent.WriteString(`{ ` + text + ` |}`) } case "contextStart": if canDetermineContextIdInline { spanToContextId[*detail.span] = len(spanToContextId) } } if deferredMarkerIndex != nil && *deferredMarkerIndex == index { // Write the marker textWithContext.newContent.WriteString(options.markerName) deferredMarkerIndex = nil detail = details[0] // Marker detail } } if suffix, ok := detailSuffixes[detail]; ok { textWithContext.newContent.WriteString(suffix) } } textWithContext.add(nil) if textWithContext.newContent.Len() != 0 { textWithContext.readableContents.WriteString("\n") textWithContext.readableJsoncBaseline(textWithContext.newContent.String()) } return textWithContext.readableContents.String() } var lineSplitter = regexp.MustCompile(`\r?\n`) type textWithContext struct { nLinesContext int // number of context lines to write to baseline readableContents *strings.Builder // builds what will be returned to be written to baseline newContent *strings.Builder // helper; the part of the original file content to write between details pos lsproto.Position isLibFile bool fileName string content string // content of the original file lineStarts *ls.LSPLineMap converters *ls.Converters // posLineInfo posInfo *lsproto.Position lineInfo int } // implements ls.Script func (t *textWithContext) FileName() string { return t.fileName } // implements ls.Script func (t *textWithContext) Text() string { return t.content } func newTextWithContext(fileName string, content string) *textWithContext { t := &textWithContext{ nLinesContext: 4, readableContents: &strings.Builder{}, isLibFile: regexp.MustCompile(`lib.*\.d\.ts$`).MatchString(fileName), newContent: &strings.Builder{}, pos: lsproto.Position{Line: 0, Character: 0}, fileName: fileName, content: content, lineStarts: ls.ComputeLSPLineStarts(content), } t.converters = ls.NewConverters(lsproto.PositionEncodingKindUTF8, func(_ string) *ls.LSPLineMap { return t.lineStarts }) t.readableContents.WriteString("// === " + fileName + " ===") return t } func (t *textWithContext) add(detail *baselineDetail) { if t.content == "" && detail == nil { panic("Unsupported") } if detail == nil || (detail.kind != "textEnd" && detail.kind != "contextEnd") { // Calculate pos to location number of lines posLineIndex := t.lineInfo if t.posInfo == nil || *t.posInfo != t.pos { posLineIndex = t.lineStarts.ComputeIndexOfLineStart(t.converters.LineAndCharacterToPosition(t, t.pos)) } locationLineIndex := len(t.lineStarts.LineStarts) - 1 if detail != nil { locationLineIndex = t.lineStarts.ComputeIndexOfLineStart(t.converters.LineAndCharacterToPosition(t, detail.pos)) t.posInfo = &detail.pos t.lineInfo = locationLineIndex } nLines := 0 if t.newContent.Len() != 0 { nLines += t.nLinesContext + 1 } if detail != nil { nLines += t.nLinesContext + 1 } // first nLinesContext and last nLinesContext if locationLineIndex-posLineIndex > nLines { if t.newContent.Len() != 0 { var skippedString string if t.isLibFile { skippedString = "--- (line: --) skipped ---\n" } else { skippedString = fmt.Sprintf(`// --- (line: %v) skipped ---`, posLineIndex+t.nLinesContext+1) } t.readableContents.WriteString("\n") t.readableJsoncBaseline(t.newContent.String() + t.sliceOfContent( t.getIndex(t.pos), t.getIndex(t.lineStarts.LineStarts[posLineIndex+t.nLinesContext]), ) + skippedString) if detail != nil { t.readableContents.WriteString("\n") } t.newContent.Reset() } if detail != nil { if t.isLibFile { t.newContent.WriteString("--- (line: --) skipped ---\n") } else { t.newContent.WriteString(fmt.Sprintf("--- (line: %v) skipped ---\n", locationLineIndex-t.nLinesContext+1)) } t.newContent.WriteString(t.sliceOfContent( t.getIndex(t.lineStarts.LineStarts[locationLineIndex-t.nLinesContext+1]), t.getIndex(detail.pos), )) } return } } if detail == nil { t.newContent.WriteString(t.sliceOfContent(t.getIndex(t.pos), nil)) } else { t.newContent.WriteString(t.sliceOfContent(t.getIndex(t.pos), t.getIndex(detail.pos))) } } func (t *textWithContext) readableJsoncBaseline(text string) { for i, line := range lineSplitter.Split(text, -1) { if i > 0 { t.readableContents.WriteString("\n") } t.readableContents.WriteString(`// ` + line) } } type markerAndItem[T any] struct { Marker *Marker `json:"marker"` Item T `json:"item"` } func annotateContentWithTooltips[T comparable]( t *testing.T, f *FourslashTest, markersAndItems []markerAndItem[T], opName string, getRange func(item T) *lsproto.Range, getTooltipLines func(item T, prev T) []string, ) string { barWithGutter := "| " + strings.Repeat("-", 70) // sort by file, then *backwards* by position in the file // so we can insert multiple times on a line without counting sorted := slices.Clone(markersAndItems) slices.SortFunc(sorted, func(a, b markerAndItem[T]) int { if c := cmp.Compare(a.Marker.FileName(), b.Marker.FileName()); c != 0 { return c } return -cmp.Compare(a.Marker.Position, b.Marker.Position) }) filesToLines := collections.NewOrderedMapWithSizeHint[string, []string](1) var previous T for _, itemAndMarker := range sorted { marker := itemAndMarker.Marker item := itemAndMarker.Item textRange := getRange(item) if textRange == nil { start := marker.LSPosition end := start end.Character = end.Character + 1 textRange = &lsproto.Range{Start: start, End: end} } if textRange.Start.Line != textRange.End.Line { t.Fatalf("Expected text range to be on a single line, got %v", textRange) } underline := strings.Repeat(" ", int(textRange.Start.Character)) + strings.Repeat("^", int(textRange.End.Character-textRange.Start.Character)) fileName := marker.FileName() lines, ok := filesToLines.Get(fileName) if !ok { lines = lineSplitter.Split(f.getScriptInfo(fileName).content, -1) } var tooltipLines []string if item != *new(T) { tooltipLines = getTooltipLines(item, previous) } if len(tooltipLines) == 0 { tooltipLines = []string{fmt.Sprintf("No %s at /*%s*/.", opName, *marker.Name)} } tooltipLines = core.Map(tooltipLines, func(line string) string { return "| " + line }) linesToInsert := make([]string, len(tooltipLines)+3) linesToInsert[0] = underline linesToInsert[1] = barWithGutter copy(linesToInsert[2:], tooltipLines) linesToInsert[len(linesToInsert)-1] = barWithGutter lines = slices.Insert( lines, int(textRange.Start.Line+1), linesToInsert..., ) filesToLines.Set(fileName, lines) previous = item } builder := strings.Builder{} seenFirst := false for fileName, lines := range filesToLines.Entries() { builder.WriteString(fmt.Sprintf("=== %s ===\n", fileName)) for _, line := range lines { builder.WriteString("// ") builder.WriteString(line) builder.WriteByte('\n') } if seenFirst { builder.WriteString("\n\n") } else { seenFirst = true } } return builder.String() } func (t *textWithContext) sliceOfContent(start *int, end *int) string { if start == nil || *start < 0 { start = ptrTo(0) } if end == nil || *end > len(t.content) { end = ptrTo(len(t.content)) } if *start > *end { return "" } return t.content[*start:*end] } func (t *textWithContext) getIndex(i interface{}) *int { switch i := i.(type) { case *int: return i case int: return ptrTo(i) case core.TextPos: return ptrTo(int(i)) case *core.TextPos: return ptrTo(int(*i)) case lsproto.Position: return t.getIndex(t.converters.LineAndCharacterToPosition(t, i)) case *lsproto.Position: return t.getIndex(t.converters.LineAndCharacterToPosition(t, *i)) } panic(fmt.Sprintf("getIndex: unsupported type %T", i)) } func codeFence(lang string, code string) string { return "```" + lang + "\n" + code + "\n```" }