diff --git a/internal/ls/converters.go b/internal/ls/converters.go index f41fa5ab4c..3abb8f0c0e 100644 --- a/internal/ls/converters.go +++ b/internal/ls/converters.go @@ -175,6 +175,11 @@ func (c *Converters) PositionToLineAndCharacter(script Script, position core.Tex lineMap := c.getLineMap(script.FileName()) + // If lineMap is nil (file not in cache), create a temporary one from the script text + if lineMap == nil { + lineMap = ComputeLineStarts(script.Text()) + } + line, isLineStart := slices.BinarySearch(lineMap.LineStarts, position) if !isLineStart { line-- diff --git a/internal/ls/sourcemaphost.go b/internal/ls/sourcemaphost.go new file mode 100644 index 0000000000..500d4889cb --- /dev/null +++ b/internal/ls/sourcemaphost.go @@ -0,0 +1,184 @@ +package ls + +import ( + "strings" + + "github.com/microsoft/typescript-go/internal/compiler" + "github.com/microsoft/typescript-go/internal/core" + "github.com/microsoft/typescript-go/internal/lsp/lsproto" + "github.com/microsoft/typescript-go/internal/sourcemap" + "github.com/microsoft/typescript-go/internal/tspath" +) + +type sourcemapHost struct { + program *compiler.Program +} + +func (h *sourcemapHost) GetSource(fileName string) sourcemap.Source { + if content, ok := h.readFileWithFallback(fileName); ok { + return sourcemap.NewSimpleSourceFile(fileName, content) + } + return nil +} + +func (h *sourcemapHost) GetCanonicalFileName(path string) string { + return tspath.GetCanonicalFileName(path, h.program.UseCaseSensitiveFileNames()) +} + +func (h *sourcemapHost) Log(text string) { +} + +func (h *sourcemapHost) UseCaseSensitiveFileNames() bool { + return h.program.UseCaseSensitiveFileNames() +} + +func (h *sourcemapHost) GetCurrentDirectory() string { + return h.program.Host().GetCurrentDirectory() +} + +func (h *sourcemapHost) ReadFile(path string) (string, bool) { + return h.readFileWithFallback(path) +} + +func (h *sourcemapHost) FileExists(path string) bool { + return h.program.Host().FS().FileExists(path) +} + +// Tries to read a file from the program's FS first, and falls back to underlying FS for files not tracked by the program. +func (h *sourcemapHost) readFileWithFallback(fileName string) (string, bool) { + if content, ok := h.program.Host().FS().ReadFile(fileName); ok { + return content, true + } + + if fallbackFS, ok := h.program.Host().FS().(sourcemap.FallbackFileReader); ok { + return fallbackFS.ReadFileWithFallback(fileName) + } + + return "", false +} + +type sourcemapFileReader struct { + host *sourcemapHost +} + +func (r *sourcemapFileReader) ReadFile(path string) (string, bool) { + return r.host.readFileWithFallback(path) +} + +// Creates a SourceMapper for the given program. +func CreateSourceMapperForProgram(program *compiler.Program) sourcemap.SourceMapper { + host := &sourcemapHost{program: program} + fileReader := &sourcemapFileReader{host: host} + return sourcemap.CreateSourceMapper(host, fileReader) +} + +// Maps a single definition location using source maps. +func MapSingleDefinitionLocation(program *compiler.Program, location lsproto.Location, languageService *LanguageService) *lsproto.Location { + fileName := location.Uri.FileName() + + if !strings.HasSuffix(fileName, ".d.ts") { + return nil + } + + host := &sourcemapHost{program: program} + sourceMapper := CreateSourceMapperForProgram(program) + + return tryMapLocation(sourceMapper, host, location, languageService) +} + +func tryMapLocation(sourceMapper sourcemap.SourceMapper, host *sourcemapHost, location lsproto.Location, languageService *LanguageService) *lsproto.Location { + fileName := location.Uri.FileName() + program := host.program + + declFile := program.GetSourceFile(fileName) + var declStartPos, declEndPos core.TextPos + + // Get the script interface for the declaration file + var declScript Script + if declFile != nil { + declScript = declFile + } else { + declContent, ok := host.readFileWithFallback(fileName) + if !ok { + return nil + } + + declScript = &textScript{ + fileName: fileName, + text: declContent, + } + } + + // Convert both positions using the same script interface + declStartPos = languageService.converters.LineAndCharacterToPosition(declScript, location.Range.Start) + declEndPos = languageService.converters.LineAndCharacterToPosition(declScript, location.Range.End) + + startInput := sourcemap.DocumentPosition{ + FileName: fileName, + Pos: declStartPos, + } + + startResult := sourceMapper.TryGetSourcePosition(startInput) + if startResult == nil { + return nil + } + + // Map the end position individually through the source map + endInput := sourcemap.DocumentPosition{ + FileName: fileName, + Pos: declEndPos, + } + + endResult := sourceMapper.TryGetSourcePosition(endInput) + var sourceEndPos core.TextPos + if endResult != nil && endResult.FileName == startResult.FileName { + // Both positions mapped to the same source file + sourceEndPos = endResult.Pos + } else { + // Fallback: use original range length (this shouldn't happen often) + originalRangeLength := declEndPos - declStartPos + sourceEndPos = startResult.Pos + originalRangeLength + } + + // Get the script interface for the source file + sourceFile := program.GetSourceFile(startResult.FileName) + var sourceScript Script + if sourceFile != nil { + sourceScript = sourceFile + } else { + sourceContent, ok := host.readFileWithFallback(startResult.FileName) + if !ok { + return nil + } + + sourceScript = &textScript{ + fileName: startResult.FileName, + text: sourceContent, + } + } + + // Convert both positions using the same script interface + sourceStartLSP := languageService.converters.PositionToLineAndCharacter(sourceScript, startResult.Pos) + sourceEndLSP := languageService.converters.PositionToLineAndCharacter(sourceScript, sourceEndPos) + return &lsproto.Location{ + Uri: FileNameToDocumentURI(startResult.FileName), + Range: lsproto.Range{ + Start: sourceStartLSP, + End: sourceEndLSP, + }, + } +} + +// textScript is a simple wrapper that implements the Script interface for raw text content +type textScript struct { + fileName string + text string +} + +func (t *textScript) FileName() string { + return t.fileName +} + +func (t *textScript) Text() string { + return t.text +} diff --git a/internal/lsp/server.go b/internal/lsp/server.go index 5b6198a885..8db974b7c4 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -10,6 +10,7 @@ import ( "os/signal" "runtime/debug" "slices" + "strings" "sync" "sync/atomic" "syscall" @@ -710,7 +711,61 @@ func (s *Server) handleSignatureHelp(ctx context.Context, languageService *ls.La } func (s *Server) handleDefinition(ctx context.Context, ls *ls.LanguageService, params *lsproto.DefinitionParams) (lsproto.DefinitionResponse, error) { - return ls.ProvideDefinition(ctx, params.TextDocument.Uri, params.Position) + rawResponse, err := ls.ProvideDefinition(ctx, params.TextDocument.Uri, params.Position) + if err != nil { + return rawResponse, err + } + + if locations := rawResponse.Locations; locations != nil { + mappedLocations := s.mapDefinitionLocationsForProject(*locations, params.TextDocument.Uri, ls) + return lsproto.LocationOrLocationsOrDefinitionLinksOrNull{ + Locations: &mappedLocations, + }, nil + } + + return rawResponse, nil +} + +func (s *Server) mapDefinitionLocationsForProject(locations []lsproto.Location, requestingFileUri lsproto.DocumentUri, languageService *ls.LanguageService) []lsproto.Location { + mappedLocations := make([]lsproto.Location, 0, len(locations)) + + snapshot, release := s.session.Snapshot() + defer release() + + requestingProject := snapshot.GetDefaultProject(requestingFileUri) + if requestingProject == nil { + return locations + } + + program := requestingProject.GetProgram() + if program == nil { + return locations + } + + for _, location := range locations { + // Skip bundled library URIs that have invalid URL encoding + // These URIs contain "%3A" which causes panics in FileName() + uriString := string(location.Uri) + if strings.Contains(uriString, "bundled%3A") { + mappedLocations = append(mappedLocations, location) + continue + } + + fileName := location.Uri.FileName() + if !strings.HasSuffix(fileName, ".d.ts") { + mappedLocations = append(mappedLocations, location) + continue + } + + // Only call the source mapping for .d.ts files + if mappedLocation := ls.MapSingleDefinitionLocation(program, location, languageService); mappedLocation != nil { + mappedLocations = append(mappedLocations, *mappedLocation) + } else { + mappedLocations = append(mappedLocations, location) + } + } + + return mappedLocations } func (s *Server) handleTypeDefinition(ctx context.Context, ls *ls.LanguageService, params *lsproto.TypeDefinitionParams) (lsproto.TypeDefinitionResponse, error) { diff --git a/internal/project/compilerhost.go b/internal/project/compilerhost.go index 262e55403f..e4dda868ee 100644 --- a/internal/project/compilerhost.go +++ b/internal/project/compilerhost.go @@ -147,6 +147,15 @@ func (fs *compilerFS) ReadFile(path string) (contents string, ok bool) { return "", false } +// ReadFileWithFallback implements vfs.FS. +// This reads the file from the underlying FS if it's not tracked by the program. +func (fs *compilerFS) ReadFileWithFallback(path string) (contents string, ok bool) { + if fh := fs.source.GetFile(path); fh != nil { + return fh.Content(), true + } + return fs.source.FS().ReadFile(path) +} + // Realpath implements vfs.FS. func (fs *compilerFS) Realpath(path string) string { return fs.source.FS().Realpath(path) diff --git a/internal/sourcemap/mapper.go b/internal/sourcemap/mapper.go new file mode 100644 index 0000000000..dde409af27 --- /dev/null +++ b/internal/sourcemap/mapper.go @@ -0,0 +1,262 @@ +package sourcemap + +import ( + "sort" + + "github.com/go-json-experiment/json" + "github.com/microsoft/typescript-go/internal/core" + "github.com/microsoft/typescript-go/internal/tspath" +) + +// DocumentPosition represents a position in a document +type DocumentPosition struct { + FileName string + Pos core.TextPos +} + +// DocumentPositionMapper maps positions between source and generated files +type DocumentPositionMapper interface { + GetSourcePosition(input DocumentPosition) DocumentPosition + GetGeneratedPosition(input DocumentPosition) DocumentPosition +} + +// DocumentPositionMapperHost provides file system access for position mapping +type DocumentPositionMapperHost interface { + GetSource(fileName string) Source + GetCanonicalFileName(path string) string + Log(text string) +} + +// MappedPosition represents a position mapping with additional metadata +type MappedPosition struct { + GeneratedPosition core.TextPos + SourceIndex int + SourcePosition core.TextPos + SourceFileName string // Resolved source file name +} + +// CreateDocumentPositionMapper creates a document position mapper from a source map +func CreateDocumentPositionMapper(host DocumentPositionMapperHost, mapContent string, mapPath string) DocumentPositionMapper { + var sourceMap RawSourceMap + if err := json.Unmarshal([]byte(mapContent), &sourceMap); err != nil { + return &identityMapper{} + } + + return createDocumentPositionMapperFromRawMap(host, &sourceMap, mapPath) +} + +func createDocumentPositionMapperFromRawMap(host DocumentPositionMapperHost, sourceMap *RawSourceMap, mapPath string) DocumentPositionMapper { + mapDirectory := tspath.GetDirectoryPath(mapPath) + var sourceRoot string + if sourceMap.SourceRoot != "" { + sourceRoot = tspath.GetNormalizedAbsolutePath(sourceMap.SourceRoot, mapDirectory) + } else { + sourceRoot = mapDirectory + } + + generatedAbsoluteFilePath := tspath.GetNormalizedAbsolutePath(sourceMap.File, mapDirectory) + generatedFile := host.GetSource(generatedAbsoluteFilePath) + + sourceFileAbsolutePaths := make([]string, len(sourceMap.Sources)) + for i, source := range sourceMap.Sources { + sourceFileAbsolutePaths[i] = tspath.GetNormalizedAbsolutePath(source, sourceRoot) + } + + return &documentPositionMapper{ + host: host, + sourceMap: sourceMap, + mapPath: mapPath, + generatedFile: generatedFile, + sourceFileAbsolutePaths: sourceFileAbsolutePaths, + sourceToSourceIndexMap: createSourceIndexMap(sourceFileAbsolutePaths, host.GetCanonicalFileName), + } +} + +func createSourceIndexMap(sourceFileAbsolutePaths []string, getCanonicalFileName func(string) string) map[string]int { + result := make(map[string]int, len(sourceFileAbsolutePaths)) + for i, source := range sourceFileAbsolutePaths { + result[getCanonicalFileName(source)] = i + } + return result +} + +type documentPositionMapper struct { + host DocumentPositionMapperHost + sourceMap *RawSourceMap + mapPath string + generatedFile Source + sourceFileAbsolutePaths []string + sourceToSourceIndexMap map[string]int + + // Cached mappings + decodedMappings []MappedPosition + generatedMappings []MappedPosition + sourceMappings [][]MappedPosition + mappingsDecoded bool +} + +func (mapper *documentPositionMapper) GetSourcePosition(input DocumentPosition) DocumentPosition { + if !mapper.ensureMappingsDecoded() { + return input + } + + if len(mapper.generatedMappings) == 0 { + return input + } + + targetIndex := sort.Search(len(mapper.generatedMappings), func(i int) bool { + return mapper.generatedMappings[i].GeneratedPosition > input.Pos + }) - 1 + + if targetIndex < 0 { + targetIndex = 0 + } + if targetIndex >= len(mapper.generatedMappings) { + targetIndex = len(mapper.generatedMappings) - 1 + } + + mapping := mapper.generatedMappings[targetIndex] + if mapping.SourceIndex < 0 || mapping.SourceIndex >= len(mapper.sourceFileAbsolutePaths) { + return input + } + + return DocumentPosition{ + FileName: mapper.sourceFileAbsolutePaths[mapping.SourceIndex], + Pos: mapping.SourcePosition, + } +} + +func (mapper *documentPositionMapper) GetGeneratedPosition(input DocumentPosition) DocumentPosition { + if !mapper.ensureMappingsDecoded() { + return input + } + + sourceIndex, exists := mapper.sourceToSourceIndexMap[mapper.host.GetCanonicalFileName(input.FileName)] + if !exists { + return input + } + + if sourceIndex >= len(mapper.sourceMappings) || len(mapper.sourceMappings[sourceIndex]) == 0 { + return input + } + + sourceMappings := mapper.sourceMappings[sourceIndex] + targetIndex := sort.Search(len(sourceMappings), func(i int) bool { + return sourceMappings[i].SourcePosition > input.Pos + }) - 1 + + if targetIndex < 0 { + targetIndex = 0 + } + if targetIndex >= len(sourceMappings) { + targetIndex = len(sourceMappings) - 1 + } + + mapping := sourceMappings[targetIndex] + if mapping.SourceIndex != sourceIndex { + return input + } + + return DocumentPosition{ + FileName: tspath.GetNormalizedAbsolutePath(mapper.sourceMap.File, tspath.GetDirectoryPath(mapper.mapPath)), + Pos: mapping.GeneratedPosition, + } +} + +func (mapper *documentPositionMapper) ensureMappingsDecoded() bool { + if mapper.mappingsDecoded { + return len(mapper.decodedMappings) > 0 + } + + mapper.mappingsDecoded = true + + if mapper.sourceMap.Mappings == "" { + return false + } + + decoder := DecodeMappings(mapper.sourceMap.Mappings) + var generatedLineStarts []core.TextPos + if mapper.generatedFile != nil { + generatedLineStarts = mapper.generatedFile.LineMap() + } + + for mapping, done := decoder.Next(); !done; mapping, done = decoder.Next() { + if !mapping.IsSourceMapping() { + continue + } + + var generatedPos core.TextPos + if generatedLineStarts != nil && mapping.GeneratedLine < len(generatedLineStarts) { + generatedPos = generatedLineStarts[mapping.GeneratedLine] + core.TextPos(mapping.GeneratedCharacter) + } else { + generatedPos = -1 + } + + sourceFile := mapper.host.GetSource(mapper.sourceFileAbsolutePaths[mapping.SourceIndex]) + var sourcePos core.TextPos + if sourceFile != nil { + sourceLineStarts := sourceFile.LineMap() + if mapping.SourceLine < len(sourceLineStarts) { + sourcePos = sourceLineStarts[mapping.SourceLine] + core.TextPos(mapping.SourceCharacter) + } else { + sourcePos = -1 + } + } else { + sourcePos = -1 + } + + mappedPos := MappedPosition{ + GeneratedPosition: generatedPos, + SourceIndex: int(mapping.SourceIndex), + SourcePosition: sourcePos, + SourceFileName: mapper.sourceFileAbsolutePaths[mapping.SourceIndex], + } + + mapper.decodedMappings = append(mapper.decodedMappings, mappedPos) + } + + if err := decoder.Error(); err != nil { + mapper.host.Log("Error decoding source map mappings: " + err.Error()) + return false + } + + // Sort and create separate arrays for generated and source mappings + mapper.generatedMappings = make([]MappedPosition, len(mapper.decodedMappings)) + copy(mapper.generatedMappings, mapper.decodedMappings) + sort.Slice(mapper.generatedMappings, func(i, j int) bool { + return mapper.generatedMappings[i].GeneratedPosition < mapper.generatedMappings[j].GeneratedPosition + }) + + // Group by source index + mapper.sourceMappings = make([][]MappedPosition, len(mapper.sourceFileAbsolutePaths)) + for _, mapping := range mapper.decodedMappings { + if mapping.SourceIndex >= 0 && mapping.SourceIndex < len(mapper.sourceMappings) { + mapper.sourceMappings[mapping.SourceIndex] = append(mapper.sourceMappings[mapping.SourceIndex], mapping) + } + } + + // Sort each source mapping array + for i := range mapper.sourceMappings { + sort.Slice(mapper.sourceMappings[i], func(j, k int) bool { + return mapper.sourceMappings[i][j].SourcePosition < mapper.sourceMappings[i][k].SourcePosition + }) + } + + return len(mapper.decodedMappings) > 0 +} + +// identityMapper is a no-op mapper that returns the input unchanged +type identityMapper struct{} + +func (m *identityMapper) GetSourcePosition(input DocumentPosition) DocumentPosition { + return input +} + +func (m *identityMapper) GetGeneratedPosition(input DocumentPosition) DocumentPosition { + return input +} + +// IdentityDocumentPositionMapper returns a mapper that performs no transformation +func IdentityDocumentPositionMapper() DocumentPositionMapper { + return &identityMapper{} +} diff --git a/internal/sourcemap/mapper_test.go b/internal/sourcemap/mapper_test.go new file mode 100644 index 0000000000..8b1da5d6f8 --- /dev/null +++ b/internal/sourcemap/mapper_test.go @@ -0,0 +1,261 @@ +package sourcemap + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/core" +) + +// Test data based on TypeScript's declarationMapGoToDefinition test +const ( + // Source map content from the TypeScript test + testSourceMapContent = `{"version":3,"file":"indexdef.d.ts","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"AAAA;IACI,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,CAAC,QAAQ,EAAE,QAAQ,GAAG,IAAI;IACpC,WAAW;;;;;;;CAMd;AAED,MAAM,WAAW,QAAQ;IACrB,MAAM,EAAE,MAAM,CAAC;CAClB"}` + + // Declaration file content + testDeclarationContent = `export declare class Foo { + member: string; + methodName(propName: SomeType): void; + otherMethod(): { + x: number; + y?: undefined; + } | { + y: string; + x?: undefined; + }; +} +export interface SomeType { + member: number; +} +//# sourceMappingURL=indexdef.d.ts.map` + + // Original source file content + testSourceContent = `export class Foo { + member: string; + methodName(propName: SomeType): void {} + otherMethod() { + if (Math.random() > 0.5) { + return {x: 42}; + } + return {y: "yes"}; + } +} + +export interface SomeType { + member: number; +}` +) + +type testHost struct { + files map[string]string +} + +func (h *testHost) GetSource(fileName string) Source { + content, exists := h.files[fileName] + if !exists { + return nil + } + return &testSourceFile{ + fileName: fileName, + text: content, + lineMap: core.ComputeLineStarts(content), + } +} + +func (h *testHost) GetCanonicalFileName(path string) string { + return path // Case-sensitive for test +} + +func (h *testHost) Log(text string) { + // For testing, we can ignore logs or capture them +} + +type testSourceFile struct { + fileName string + text string + lineMap []core.TextPos +} + +func (f *testSourceFile) FileName() string { + return f.fileName +} + +func (f *testSourceFile) Text() string { + return f.text +} + +func (f *testSourceFile) LineMap() []core.TextPos { + return f.lineMap +} + +func TestDocumentPositionMapper_GetSourcePosition(t *testing.T) { + t.Parallel() + host := &testHost{ + files: map[string]string{ + "/indexdef.d.ts": testDeclarationContent, + "/index.ts": testSourceContent, + }, + } + + mapper := CreateDocumentPositionMapper(host, testSourceMapContent, "/indexdef.d.ts.map") + + // Test mapping from declaration file to source file + // Position should be somewhere in the methodName declaration in the .d.ts file + declLineStarts := core.ComputeLineStarts(testDeclarationContent) + + // Find the position of "methodName" in the declaration file + // This is on line 2 (0-indexed), around character 4 + methodNamePosInDecl := int(declLineStarts[2]) + 4 + + input := DocumentPosition{ + FileName: "/indexdef.d.ts", + Pos: core.TextPos(methodNamePosInDecl), + } + + result := mapper.GetSourcePosition(input) + + // Should map to the source file + if result.FileName != "/index.ts" { + t.Errorf("Expected fileName to be '/index.ts', got '%s'", result.FileName) + } + + // Should map to a position in the source file (we don't need exact position matching for this test) + if result.Pos < 0 { + t.Errorf("Expected positive position, got %d", result.Pos) + } + + // Verify it's different from the input (actual mapping occurred) + if result.FileName == input.FileName && result.Pos == input.Pos { + t.Error("Expected mapping to change position, but got same position") + } +} + +func TestDocumentPositionMapper_NoMapping(t *testing.T) { + t.Parallel() + host := &testHost{ + files: map[string]string{}, + } + + // Test with empty source map + mapper := CreateDocumentPositionMapper(host, `{"version":3,"file":"test.d.ts","sources":[],"mappings":""}`, "/test.d.ts.map") + + input := DocumentPosition{ + FileName: "/test.d.ts", + Pos: core.TextPos(10), + } + + result := mapper.GetSourcePosition(input) + + // Should return unchanged input when no mappings exist + if result.FileName != input.FileName || result.Pos != input.Pos { + t.Errorf("Expected unchanged position, got fileName='%s' pos=%d", result.FileName, result.Pos) + } +} + +func TestDocumentPositionMapper_InvalidSourceMap(t *testing.T) { + t.Parallel() + host := &testHost{ + files: map[string]string{}, + } + + // Test with invalid JSON + mapper := CreateDocumentPositionMapper(host, `invalid json`, "/test.d.ts.map") + + input := DocumentPosition{ + FileName: "/test.d.ts", + Pos: core.TextPos(10), + } + + result := mapper.GetSourcePosition(input) + + // Should return unchanged input when source map is invalid + if result.FileName != input.FileName || result.Pos != input.Pos { + t.Errorf("Expected unchanged position with invalid source map, got fileName='%s' pos=%d", result.FileName, result.Pos) + } +} + +func TestCreateSourceMapper(t *testing.T) { + t.Parallel() + // Mock file reader + fileReader := &testFileReader{ + files: map[string]string{ + "/indexdef.d.ts": testDeclarationContent, + "/indexdef.d.ts.map": testSourceMapContent, + "/index.ts": testSourceContent, + }, + } + + // Mock host + host := &testSourceMapperHost{} + + sourceMapper := CreateSourceMapper(host, fileReader) + + // Test mapping from declaration to source + input := DocumentPosition{ + FileName: "/indexdef.d.ts", + Pos: core.TextPos(50), // Some position in the declaration file + } + + result := sourceMapper.TryGetSourcePosition(input) + if result == nil { + t.Error("Expected source position mapping, got nil") + } else if result.FileName != "/index.ts" { + t.Errorf("Expected mapping to '/index.ts', got '%s'", result.FileName) + } +} + +// Test helper types +type testFileReader struct { + files map[string]string +} + +func (r *testFileReader) ReadFile(path string) (string, bool) { + content, exists := r.files[path] + return content, exists +} + +type testSourceMapperHost struct{} + +func (h *testSourceMapperHost) GetSource(fileName string) Source { + // This would normally read from files, but for testing we'll create mock data + switch fileName { + case "/indexdef.d.ts": + return &testSourceFile{ + fileName: fileName, + text: testDeclarationContent, + lineMap: core.ComputeLineStarts(testDeclarationContent), + } + case "/index.ts": + return &testSourceFile{ + fileName: fileName, + text: testSourceContent, + lineMap: core.ComputeLineStarts(testSourceContent), + } + } + return nil +} + +func (h *testSourceMapperHost) GetCanonicalFileName(path string) string { + return path +} + +func (h *testSourceMapperHost) Log(text string) { + // Ignore for testing +} + +func (h *testSourceMapperHost) UseCaseSensitiveFileNames() bool { + return true +} + +func (h *testSourceMapperHost) GetCurrentDirectory() string { + return "/" +} + +func (h *testSourceMapperHost) ReadFile(path string) (string, bool) { + // Not used in this test + return "", false +} + +func (h *testSourceMapperHost) FileExists(path string) bool { + // Not used in this test + return false +} diff --git a/internal/sourcemap/sourcemapper.go b/internal/sourcemap/sourcemapper.go new file mode 100644 index 0000000000..26ed7f4272 --- /dev/null +++ b/internal/sourcemap/sourcemapper.go @@ -0,0 +1,211 @@ +package sourcemap + +import ( + "strings" + + "github.com/microsoft/typescript-go/internal/core" + "github.com/microsoft/typescript-go/internal/tspath" +) + +// SourceMapper provides source mapping functionality for LSP operations +type SourceMapper interface { + TryGetSourcePosition(info DocumentPosition) *DocumentPosition + TryGetGeneratedPosition(info DocumentPosition) *DocumentPosition + ClearCache() +} + +// SourceMapperHost provides the necessary dependencies for source mapping +type SourceMapperHost interface { + DocumentPositionMapperHost + UseCaseSensitiveFileNames() bool + GetCurrentDirectory() string + ReadFile(path string) (string, bool) + FileExists(path string) bool +} + +// FileReader is an interface for reading files, potentially with fallback +type FileReader interface { + ReadFile(path string) (string, bool) +} + +// FallbackFileReader extends FileReader with fallback capability +type FallbackFileReader interface { + FileReader + ReadFileWithFallback(path string) (string, bool) +} + +// sourceMapper implements the SourceMapper interface +type sourceMapper struct { + host SourceMapperHost + fileReader FileReader + getCanonicalFileName func(string) string + currentDirectory string + documentPositionMappers map[string]DocumentPositionMapper +} + +// CreateSourceMapper creates a new SourceMapper instance +func CreateSourceMapper(host SourceMapperHost, fileReader FileReader) SourceMapper { + getCanonicalFileName := createGetCanonicalFileName(host.UseCaseSensitiveFileNames()) + + return &sourceMapper{ + host: host, + fileReader: fileReader, + getCanonicalFileName: getCanonicalFileName, + currentDirectory: host.GetCurrentDirectory(), + documentPositionMappers: make(map[string]DocumentPositionMapper), + } +} + +func (sm *sourceMapper) TryGetSourcePosition(info DocumentPosition) *DocumentPosition { + if !isDeclarationFileName(info.FileName) { + return nil + } + + mapper := sm.getDocumentPositionMapper(info.FileName, "") + if mapper == nil { + return nil + } + + newLoc := mapper.GetSourcePosition(info) + if newLoc.FileName == info.FileName && newLoc.Pos == info.Pos { + return nil // No change + } + + // Recursively try to map further if needed + if mapped := sm.TryGetSourcePosition(newLoc); mapped != nil { + return mapped + } + + return &newLoc +} + +func (sm *sourceMapper) TryGetGeneratedPosition(info DocumentPosition) *DocumentPosition { + if isDeclarationFileName(info.FileName) { + return nil + } + + // For generated position mapping, we'd need to know the declaration file path + // This is more complex and typically handled at a higher level + return nil +} + +func (sm *sourceMapper) ClearCache() { + sm.documentPositionMappers = make(map[string]DocumentPositionMapper) +} + +func (sm *sourceMapper) getDocumentPositionMapper(generatedFileName string, sourceFileName string) DocumentPositionMapper { + path := sm.toPath(generatedFileName) + if mapper, exists := sm.documentPositionMappers[path]; exists { + return mapper + } + + mapper := sm.createDocumentPositionMapper(generatedFileName, sourceFileName) + sm.documentPositionMappers[path] = mapper + return mapper +} + +func (sm *sourceMapper) createDocumentPositionMapper(generatedFileName string, sourceFileName string) DocumentPositionMapper { + // First try to read the generated file to get source mapping URL + content, ok := sm.fileReader.ReadFile(generatedFileName) + if !ok { + return IdentityDocumentPositionMapper() + } + + mapURL := tryGetSourceMappingURL(content) + if mapURL == "" { + return IdentityDocumentPositionMapper() + } + + var mapFileName string + if strings.HasPrefix(mapURL, "/") { + mapFileName = mapURL + } else { + dir := tspath.GetDirectoryPath(generatedFileName) + mapFileName = tspath.CombinePaths(dir, mapURL) + } + + mapContent, ok := sm.fileReader.ReadFile(mapFileName) + if !ok { + return IdentityDocumentPositionMapper() + } + + return CreateDocumentPositionMapper(sm, mapContent, mapFileName) +} + +func (sm *sourceMapper) toPath(fileName string) string { + return string(tspath.ToPath(fileName, sm.currentDirectory, sm.host.UseCaseSensitiveFileNames())) +} + +// DocumentPositionMapperHost implementation +func (sm *sourceMapper) GetSource(fileName string) Source { + content, ok := sm.fileReader.ReadFile(fileName) + if !ok { + return nil + } + + return NewSimpleSourceFile(fileName, content) +} + +func (sm *sourceMapper) GetCanonicalFileName(path string) string { + return sm.getCanonicalFileName(path) +} + +func (sm *sourceMapper) Log(text string) { + // Could be implemented to use host's logging if needed + // For now, we'll keep it simple +} + +// SimpleSourceFile implements Source interface for raw text content +type SimpleSourceFile struct { + fileName string + text string + lineMap []core.TextPos +} + +func (sf *SimpleSourceFile) FileName() string { + return sf.fileName +} + +func (sf *SimpleSourceFile) Text() string { + return sf.text +} + +func (sf *SimpleSourceFile) LineMap() []core.TextPos { + return sf.lineMap +} + +// NewSimpleSourceFile creates a SimpleSourceFile from fileName and text content +func NewSimpleSourceFile(fileName, text string) *SimpleSourceFile { + return &SimpleSourceFile{ + fileName: fileName, + text: text, + lineMap: core.ComputeLineStarts(text), + } +} + +// Helper functions + +func isDeclarationFileName(fileName string) bool { + return strings.HasSuffix(fileName, ".d.ts") +} + +func createGetCanonicalFileName(useCaseSensitiveFileNames bool) func(string) string { + if useCaseSensitiveFileNames { + return func(path string) string { return path } + } + return strings.ToLower +} + +func tryGetSourceMappingURL(content string) string { + lines := strings.Split(content, "\n") + for i := len(lines) - 1; i >= 0; i-- { + line := strings.TrimSpace(lines[i]) + if strings.HasPrefix(line, "//# sourceMappingURL=") { + return strings.TrimPrefix(line, "//# sourceMappingURL=") + } + if line != "" && !strings.HasPrefix(line, "//") { + break + } + } + return "" +} diff --git a/testdata/baselines/reference/fourslash/goToDef/DeclarationMapGoToDefinition.baseline.jsonc b/testdata/baselines/reference/fourslash/goToDef/DeclarationMapGoToDefinition.baseline.jsonc index 2f6b197efc..f25e7cfd7a 100644 --- a/testdata/baselines/reference/fourslash/goToDef/DeclarationMapGoToDefinition.baseline.jsonc +++ b/testdata/baselines/reference/fourslash/goToDef/DeclarationMapGoToDefinition.baseline.jsonc @@ -1,12 +1,12 @@ // === goToDefinition === -// === /indexdef.d.ts === +// === /index.ts === -// export declare class Foo { +// export class Foo { // member: string; -// [|methodName|](propName: SomeType): void; -// otherMethod(): { -// x: number; -// y?: undefined; +// [|methodName|](propName: SomeType): void {} +// otherMethod() { +// if (Math.random() > 0.5) { +// return {x: 42}; // // --- (line: 7) skipped --- diff --git a/testdata/baselines/reference/fourslash/goToDef/DeclarationMapsGoToDefinitionSameNameDifferentDirectory.baseline.jsonc b/testdata/baselines/reference/fourslash/goToDef/DeclarationMapsGoToDefinitionSameNameDifferentDirectory.baseline.jsonc index 26e09ac286..bfd3e06b3c 100644 --- a/testdata/baselines/reference/fourslash/goToDef/DeclarationMapsGoToDefinitionSameNameDifferentDirectory.baseline.jsonc +++ b/testdata/baselines/reference/fourslash/goToDef/DeclarationMapsGoToDefinitionSameNameDifferentDirectory.baseline.jsonc @@ -1,12 +1,11 @@ // === goToDefinition === -// === /BaseClass/Source.d.ts === +// === /BaseClass/Source.ts === -// declare class [|Control|] { -// constructor(); -// /** this is a super var */ -// myVar: boolean | 'yeah'; -// } -// //# sourceMappingURL=Source.d.ts.map +// class [|Control|]{ +// constructor(){ +// return; +// } +// // --- (line: 5) skipped --- // === /buttonClass/Source.ts === @@ -23,14 +22,15 @@ // === goToDefinition === -// === /BaseClass/Source.d.ts === +// === /BaseClass/Source.ts === -// declare class Control { -// constructor(); +// class Control{ +// constructor(){ +// return; +// } // /** this is a super var */ -// [|myVar|]: boolean | 'yeah'; +// public [|myVar|]: boolean | 'yeah' = true; // } -// //# sourceMappingURL=Source.d.ts.map // === /buttonClass/Source.ts === diff --git a/testdata/baselines/reference/fourslash/goToDef/DeclarationMapsOutOfDateMapping.baseline.jsonc b/testdata/baselines/reference/fourslash/goToDef/DeclarationMapsOutOfDateMapping.baseline.jsonc index 2d1cff1953..1972908cc8 100644 --- a/testdata/baselines/reference/fourslash/goToDef/DeclarationMapsOutOfDateMapping.baseline.jsonc +++ b/testdata/baselines/reference/fourslash/goToDef/DeclarationMapsOutOfDateMapping.baseline.jsonc @@ -1,10 +1,9 @@ // === goToDefinition === -// === /home/src/workspaces/project/node_modules/a/dist/index.d.ts === +// === /home/src/workspaces/project/node_modules/a/src/index.ts === -// export declare class [|Foo|] { -// bar: any; +// export class [|Foo|] { // } -// //# sourceMappingURL=index.d.ts.map +// // === /home/src/workspaces/project/index.ts ===