Skip to content
Merged
6 changes: 3 additions & 3 deletions internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ func (api *API) GetSymbolAtPosition(ctx context.Context, projectId Handle[projec
return nil, errors.New("project not found")
}

languageService := ls.NewLanguageService(project, snapshot.Converters())
languageService := ls.NewLanguageService(project, snapshot.Converters(), snapshot.ReadFile, snapshot.FileExists, snapshot.UseCaseSensitiveFileNames())
symbol, err := languageService.GetSymbolAtPosition(ctx, fileName, position)
if err != nil || symbol == nil {
return nil, err
Expand Down Expand Up @@ -202,7 +202,7 @@ func (api *API) GetSymbolAtLocation(ctx context.Context, projectId Handle[projec
if node == nil {
return nil, fmt.Errorf("node of kind %s not found at position %d in file %q", kind.String(), pos, sourceFile.FileName())
}
languageService := ls.NewLanguageService(project, snapshot.Converters())
languageService := ls.NewLanguageService(project, snapshot.Converters(), snapshot.ReadFile, snapshot.FileExists, snapshot.UseCaseSensitiveFileNames())
symbol := languageService.GetSymbolAtLocation(ctx, node)
if symbol == nil {
return nil, nil
Expand Down Expand Up @@ -232,7 +232,7 @@ func (api *API) GetTypeOfSymbol(ctx context.Context, projectId Handle[project.Pr
if !ok {
return nil, fmt.Errorf("symbol %q not found", symbolHandle)
}
languageService := ls.NewLanguageService(project, snapshot.Converters())
languageService := ls.NewLanguageService(project, snapshot.Converters(), snapshot.ReadFile, snapshot.FileExists, snapshot.UseCaseSensitiveFileNames())
t := languageService.GetTypeOfSymbol(ctx, symbol)
if t == nil {
return nil, nil
Expand Down
19 changes: 19 additions & 0 deletions internal/core/core.go
Original file line number Diff line number Diff line change
Expand Up @@ -648,3 +648,22 @@ func Deduplicate[T comparable](slice []T) []T {
}
return slice
}

func DeduplicateSorted[T any](slice []T, isEqual func(a, b T) bool) []T {
if len(slice) == 0 {
return slice
}
last := slice[0]
deduplicated := slice[:1]
for i := 1; i < len(slice); i++ {
next := slice[i]
if isEqual(last, next) {
continue
}

deduplicated = append(deduplicated, next)
last = next
}

return deduplicated
}
13 changes: 5 additions & 8 deletions internal/ls/definition.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,20 +104,17 @@ func (l *LanguageService) createLocationsFromDeclarations(declarations []*ast.No
for _, decl := range declarations {
file := ast.GetSourceFileOfNode(decl)
name := core.OrElse(ast.GetNameOfDeclaration(decl), decl)
locations = core.AppendIfUnique(locations, lsproto.Location{
Uri: FileNameToDocumentURI(file.FileName()),
Range: *l.createLspRangeFromNode(name, file),
})
nodeRange := createRangeFromNode(name, file)
mappedLocation := l.getMappedLocation(file.FileName(), nodeRange)
locations = core.AppendIfUnique(locations, mappedLocation)
}
return lsproto.LocationOrLocationsOrDefinitionLinksOrNull{Locations: &locations}
}

func (l *LanguageService) createLocationFromFileAndRange(file *ast.SourceFile, textRange core.TextRange) lsproto.DefinitionResponse {
mappedLocation := l.getMappedLocation(file.FileName(), textRange)
return lsproto.LocationOrLocationsOrDefinitionLinksOrNull{
Location: &lsproto.Location{
Uri: FileNameToDocumentURI(file.FileName()),
Range: *l.createLspRangeFromBounds(textRange.Pos(), textRange.End(), file),
},
Location: &mappedLocation,
}
}

Expand Down
54 changes: 49 additions & 5 deletions internal/ls/languageservice.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,32 @@ import (
"github.com/microsoft/typescript-go/internal/ast"
"github.com/microsoft/typescript-go/internal/compiler"
"github.com/microsoft/typescript-go/internal/lsp/lsproto"
"github.com/microsoft/typescript-go/internal/sourcemap"
)

type LanguageService struct {
host Host
converters *Converters
host Host
converters *Converters
documentPositionMappers map[string]*sourcemap.DocumentPositionMapper
useCaseSensitiveFileNames bool
readFile func(path string) (contents string, ok bool)
fileExists func(path string) bool
}

func NewLanguageService(host Host, converters *Converters) *LanguageService {
func NewLanguageService(
host Host,
converters *Converters,
readFile func(path string) (contents string, ok bool),
fileExists func(path string) bool,
useCaseSensitiveFileNames bool,
) *LanguageService {
return &LanguageService{
host: host,
converters: converters,
host: host,
converters: converters,
readFile: readFile,
fileExists: fileExists,
useCaseSensitiveFileNames: useCaseSensitiveFileNames,
documentPositionMappers: map[string]*sourcemap.DocumentPositionMapper{},
}
}

Expand All @@ -36,3 +51,32 @@ func (l *LanguageService) getProgramAndFile(documentURI lsproto.DocumentUri) (*c
}
return program, file
}

func (l *LanguageService) GetDocumentPositionMapper(fileName string) *sourcemap.DocumentPositionMapper {
d, ok := l.documentPositionMappers[fileName]
if !ok {
d = sourcemap.GetDocumentPositionMapper(l, fileName)
l.documentPositionMappers[fileName] = d
}
return d
}

func (l *LanguageService) ReadFile(fileName string) (string, bool) {
return l.readFile(fileName)
}

func (l *LanguageService) UseCaseSensitiveFileNames() bool {
return l.useCaseSensitiveFileNames
}

func (l *LanguageService) GetLineInfo(fileName string) *sourcemap.LineInfo {
text, ok := l.ReadFile(fileName)
if !ok {
return nil
}
lineMap := l.converters.getLineMap(fileName)
if lineMap == nil {
return nil
}
return sourcemap.CreateLineInfo(text, lineMap.LineStarts)
}
81 changes: 81 additions & 0 deletions internal/ls/source_map.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package ls

import (
"github.com/microsoft/typescript-go/internal/core"
"github.com/microsoft/typescript-go/internal/debug"
"github.com/microsoft/typescript-go/internal/lsp/lsproto"
"github.com/microsoft/typescript-go/internal/sourcemap"
"github.com/microsoft/typescript-go/internal/tspath"
)

func (l *LanguageService) getMappedLocation(fileName string, fileRange core.TextRange) lsproto.Location {
startPos := l.tryGetSourcePosition(fileName, core.TextPos(fileRange.Pos()))
if startPos == nil {
lspRange := l.createLspRangeFromRange(fileRange, l.getScript(fileName))
return lsproto.Location{
Uri: FileNameToDocumentURI(fileName),
Range: *lspRange,
}
}
endPos := l.tryGetSourcePosition(fileName, core.TextPos(fileRange.End()))
debug.Assert(endPos.FileName == startPos.FileName, "start and end should be in same file")
newRange := core.NewTextRange(startPos.Pos, endPos.Pos)
lspRange := l.createLspRangeFromRange(newRange, l.getScript(startPos.FileName))
return lsproto.Location{
Uri: FileNameToDocumentURI(startPos.FileName),
Range: *lspRange,
}
}

type script struct {
fileName string
text string
}

func (s *script) FileName() string {
return s.fileName
}

func (s *script) Text() string {
return s.text
}

func (l *LanguageService) getScript(fileName string) *script {
text, ok := l.readFile(fileName)
if !ok {
return nil
}
return &script{fileName: fileName, text: text}
}

func (l *LanguageService) tryGetSourcePosition(
fileName string,
position core.TextPos,
) *sourcemap.DocumentPosition {
newPos := l.tryGetSourcePositionWorker(fileName, position)
if newPos != nil {
if !l.fileExists(newPos.FileName) {
return nil
}
}
return newPos
}

func (l *LanguageService) tryGetSourcePositionWorker(
fileName string,
position core.TextPos,
) *sourcemap.DocumentPosition {
if !tspath.IsDeclarationFileName(fileName) {
return nil
}

positionMapper := l.GetDocumentPositionMapper(fileName)
documentPos := positionMapper.GetSourcePosition(&sourcemap.DocumentPosition{FileName: fileName, Pos: int(position)})
if documentPos == nil {
return nil
}
if newPos := l.tryGetSourcePositionWorker(documentPos.FileName, core.TextPos(documentPos.Pos)); newPos != nil {
return newPos
}
return documentPos
}
9 changes: 9 additions & 0 deletions internal/ls/utilities.go
Original file line number Diff line number Diff line change
Expand Up @@ -433,11 +433,20 @@ func (l *LanguageService) createLspRangeFromNode(node *ast.Node, file *ast.Sourc
return l.createLspRangeFromBounds(scanner.GetTokenPosOfNode(node, file, false /*includeJSDoc*/), node.End(), file)
}

func createRangeFromNode(node *ast.Node, file *ast.SourceFile) core.TextRange {
return core.NewTextRange(scanner.GetTokenPosOfNode(node, file, false /*includeJSDoc*/), node.End())
}

func (l *LanguageService) createLspRangeFromBounds(start, end int, file *ast.SourceFile) *lsproto.Range {
lspRange := l.converters.ToLSPRange(file, core.NewTextRange(start, end))
return &lspRange
}

func (l *LanguageService) createLspRangeFromRange(textRange core.TextRange, script Script) *lsproto.Range {
lspRange := l.converters.ToLSPRange(script, textRange)
return &lspRange
}

func (l *LanguageService) createLspPosition(position int, file *ast.SourceFile) lsproto.Position {
return l.converters.PositionToLineAndCharacter(file, core.TextPos(position))
}
Expand Down
2 changes: 1 addition & 1 deletion internal/project/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,7 @@ func (s *Session) GetLanguageService(ctx context.Context, uri lsproto.DocumentUr
if project == nil {
return nil, fmt.Errorf("no project found for URI %s", uri)
}
return ls.NewLanguageService(project, snapshot.Converters()), nil
return ls.NewLanguageService(project, snapshot.Converters(), snapshot.ReadFile, snapshot.FileExists, snapshot.UseCaseSensitiveFileNames()), nil
}

func (s *Session) UpdateSnapshot(ctx context.Context, overlays map[tspath.Path]*overlay, change SnapshotChange) *Snapshot {
Expand Down
16 changes: 16 additions & 0 deletions internal/project/snapshot.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,22 @@ func (s *Snapshot) ID() uint64 {
return s.id
}

func (s *Snapshot) UseCaseSensitiveFileNames() bool {
return s.fs.fs.UseCaseSensitiveFileNames()
}

func (s *Snapshot) ReadFile(fileName string) (string, bool) {
handle := s.GetFile(fileName)
if handle == nil {
return "", false
}
return handle.Content(), true
}

func (s *Snapshot) FileExists(fileName string) bool {
return s.fs.fs.FileExists(fileName)
}

type APISnapshotRequest struct {
OpenProjects *collections.Set[string]
CloseProjects *collections.Set[tspath.Path]
Expand Down
15 changes: 15 additions & 0 deletions internal/project/snapshotfs.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package project

import (
"sync"

"github.com/microsoft/typescript-go/internal/collections"
"github.com/microsoft/typescript-go/internal/lsp/lsproto"
"github.com/microsoft/typescript-go/internal/project/dirty"
"github.com/microsoft/typescript-go/internal/tspath"
Expand All @@ -24,8 +27,11 @@ type snapshotFS struct {
fs vfs.FS
overlays map[tspath.Path]*overlay
diskFiles map[tspath.Path]*diskFile
readFiles collections.SyncMap[tspath.Path, memoizedDiskFile]
}

type memoizedDiskFile func() *diskFile

func (s *snapshotFS) FS() vfs.FS {
return s.fs
}
Expand All @@ -37,6 +43,15 @@ func (s *snapshotFS) GetFile(fileName string) FileHandle {
if file, ok := s.diskFiles[s.toPath(fileName)]; ok {
return file
}
newEntry := memoizedDiskFile(sync.OnceValue(func() *diskFile {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is probably fine, but I had imagined a change to cachedvfs.FS instead. Was there a reason not to put the cache there? I’m not sure why there wasn’t a cache there to begin with—presumably the result of every ReadFile gets stored somewhere in memory, and my memory is that the underlying memory for the string will be shared when it’s assigned from the ReadFile result to the cache to the diskFile to the SourceFile and so on.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There were other usages of cachedvfs.FS where I wasn't sure caching file reads would make sense or how it would affect those usages, and Sheetal seemed to think we shouldn't cache files at that level.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah caching at cached.vfs level means you need to clear it on "writeFile" otherwise tsc -b will run into issues if we dont handle that well - which would add to cost right? and normally in those scenarios the text gets cached in "sourceFile" so another cache is generally not needed

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for clarifying!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess that makes sense; there are already some weird inconsistencies with cachedvfs (FileExists can be out of sync with Stat can be out of sync with ReadFile) but I guess we can avoid growing those any more.

if contents, ok := s.fs.ReadFile(fileName); ok {
return newDiskFile(fileName, contents)
}
return nil
}))
if entry, ok := s.readFiles.LoadOrStore(s.toPath(fileName), newEntry); ok {
return entry()
}
return nil
}

Expand Down
46 changes: 29 additions & 17 deletions internal/scanner/scanner.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (

"github.com/microsoft/typescript-go/internal/ast"
"github.com/microsoft/typescript-go/internal/core"
"github.com/microsoft/typescript-go/internal/debug"
"github.com/microsoft/typescript-go/internal/diagnostics"
"github.com/microsoft/typescript-go/internal/jsnum"
"github.com/microsoft/typescript-go/internal/stringutil"
Expand Down Expand Up @@ -2451,31 +2452,42 @@ func GetPositionOfLineAndCharacter(sourceFile *ast.SourceFile, line int, charact
}

func ComputePositionOfLineAndCharacter(lineStarts []core.TextPos, line int, character int) int {
/// !!! debugText, allowEdits
return ComputePositionOfLineAndCharacterEx(lineStarts, line, character, nil, false)
}

func ComputePositionOfLineAndCharacterEx(lineStarts []core.TextPos, line int, character int, text *string, allowEdits bool) int {
if line < 0 || line >= len(lineStarts) {
// if (allowEdits) {
// // Clamp line to nearest allowable value
// line = line < 0 ? 0 : line >= lineStarts.length ? lineStarts.length - 1 : line;
// }
panic(fmt.Sprintf("Bad line number. Line: %d, lineStarts.length: %d.", line, len(lineStarts)))
if allowEdits {
Copy link
Member Author

@gabritto gabritto Sep 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a direct port of Strada and allows cases where the source file and source map are out of sync, see test declarationMapsOutOfDateMapping.

// Clamp line to nearest allowable value
if line < 0 {
line = 0
} else if line >= len(lineStarts) {
line = len(lineStarts) - 1
}
} else {
panic(fmt.Sprintf("Bad line number. Line: %d, lineStarts.length: %d.", line, len(lineStarts)))
}
}

res := int(lineStarts[line]) + character

// !!!
// if (allowEdits) {
// // Clamp to nearest allowable values to allow the underlying to be edited without crashing (accuracy is lost, instead)
// // TODO: Somehow track edits between file as it was during the creation of sourcemap we have and the current file and
// // apply them to the computed position to improve accuracy
// return res > lineStarts[line + 1] ? lineStarts[line + 1] : typeof debugText === "string" && res > debugText.length ? debugText.length : res;
// }
if allowEdits {
// Clamp to nearest allowable values to allow the underlying to be edited without crashing (accuracy is lost, instead)
// TODO: Somehow track edits between file as it was during the creation of sourcemap we have and the current file and
// apply them to the computed position to improve accuracy
if line+1 < len(lineStarts) && res > int(lineStarts[line+1]) {
return int(lineStarts[line+1])
}
if text != nil && res > len(*text) {
return len(*text)
}
return res
}
if line < len(lineStarts)-1 && res >= int(lineStarts[line+1]) {
panic("Computed position is beyond that of the following line.")
} else if text != nil {
debug.Assert(res <= len(*text)) // Allow single character overflow for trailing newline
}
// !!!
// else if (debugText !== undefined) {
// Debug.assert(res <= debugText.length); // Allow single character overflow for trailing newline
// }
return res
}

Expand Down
2 changes: 1 addition & 1 deletion internal/sourcemap/lineinfo.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ type LineInfo struct {
lineStarts []core.TextPos
}

func GetLineInfo(text string, lineStarts []core.TextPos) *LineInfo {
func CreateLineInfo(text string, lineStarts []core.TextPos) *LineInfo {
return &LineInfo{
text: text,
lineStarts: lineStarts,
Expand Down
Loading
Loading