Skip to content
2 changes: 1 addition & 1 deletion internal/ast/ast.go
Original file line number Diff line number Diff line change
Expand Up @@ -10072,7 +10072,7 @@ type SourceFile struct {
}

func (f *NodeFactory) NewSourceFile(opts SourceFileParseOptions, text string, statements *NodeList, endOfFileToken *TokenNode) *Node {
if (tspath.GetEncodedRootLength(opts.FileName) == 0 && !strings.HasPrefix(opts.FileName, "^/")) || opts.FileName != tspath.NormalizePath(opts.FileName) {
if tspath.GetEncodedRootLength(opts.FileName) == 0 || opts.FileName != tspath.NormalizePath(opts.FileName) {
panic(fmt.Sprintf("fileName should be normalized and absolute: %q", opts.FileName))
}
data := &SourceFile{}
Expand Down
159 changes: 159 additions & 0 deletions internal/ls/untitled_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
package ls_test

import (
"strings"
"testing"

"github.com/microsoft/typescript-go/internal/bundled"
"github.com/microsoft/typescript-go/internal/ls"
"github.com/microsoft/typescript-go/internal/lsp/lsproto"
"github.com/microsoft/typescript-go/internal/testutil/projecttestutil"
"github.com/microsoft/typescript-go/internal/tspath"
"gotest.tools/v3/assert"
)

func TestUntitledReferences(t *testing.T) {
t.Parallel()
if !bundled.Embedded {
t.Skip("bundled files are not embedded")
}

// First test the URI conversion functions to understand the issue
untitledURI := lsproto.DocumentUri("untitled:Untitled-2")
convertedFileName := ls.DocumentURIToFileName(untitledURI)
t.Logf("URI '%s' converts to filename '%s'", untitledURI, convertedFileName)

backToURI := ls.FileNameToDocumentURI(convertedFileName)
t.Logf("Filename '%s' converts back to URI '%s'", convertedFileName, backToURI)

if string(backToURI) != string(untitledURI) {
t.Errorf("Round-trip conversion failed: '%s' -> '%s' -> '%s'", untitledURI, convertedFileName, backToURI)
}

// Create a test case that simulates how untitled files should work
testContent := `let x = 42;

x

x++;`

// Use the converted filename that DocumentURIToFileName would produce
untitledFileName := convertedFileName // "^/untitled/ts-nul-authority/Untitled-2"
t.Logf("Would use untitled filename: %s", untitledFileName)

// Set up the file system with an untitled file -
// But use a regular file first to see the current behavior
files := map[string]string{
"/Untitled-2.ts": testContent,
}

ctx := projecttestutil.WithRequestID(t.Context())
service, done := createLanguageService(ctx, "/Untitled-2.ts", files)
defer done()

// Test the filename that the source file reports
program := service.GetProgram()
sourceFile := program.GetSourceFile("/Untitled-2.ts")
t.Logf("SourceFile.FileName() returns: '%s'", sourceFile.FileName())

// Calculate position of 'x' on line 3 (zero-indexed line 2, character 0)
position := 13 // After "let x = 42;\n\n"

// Call ProvideReferences using the test method
refs := service.TestProvideReferences("/Untitled-2.ts", position)

// Log the results
t.Logf("Input file name: %s", "/Untitled-2.ts")
t.Logf("Number of references found: %d", len(refs))
for i, ref := range refs {
t.Logf("Reference %d: URI=%s, Range=%+v", i+1, ref.Uri, ref.Range)
}

// We expect to find 3 references
assert.Assert(t, len(refs) == 3, "Expected 3 references, got %d", len(refs))

// Also test definition using ProvideDefinition
uri := ls.FileNameToDocumentURI("/Untitled-2.ts")
lspPosition := lsproto.Position{Line: 2, Character: 0}
definition, err := service.ProvideDefinition(t.Context(), uri, lspPosition)
assert.NilError(t, err)
if definition != nil && definition.Locations != nil {
t.Logf("Definition found: %d locations", len(*definition.Locations))
for i, loc := range *definition.Locations {
t.Logf("Definition %d: URI=%s, Range=%+v", i+1, loc.Uri, loc.Range)
}
}
}

func TestUntitledFileNameDebugging(t *testing.T) {
t.Parallel()
if !bundled.Embedded {
t.Skip("bundled files are not embedded")
}

// Test the URI conversion flow
untitledURI := lsproto.DocumentUri("untitled:Untitled-2")
convertedFileName := ls.DocumentURIToFileName(untitledURI)
t.Logf("1. URI '%s' converts to filename '%s'", untitledURI, convertedFileName)

// Test the path handling
currentDir := "/home/daniel/TypeScript"
path := tspath.ToPath(convertedFileName, currentDir, true)
t.Logf("2. ToPath('%s', '%s') returns: '%s'", convertedFileName, currentDir, string(path))

// Verify the path is NOT resolved against current directory
if strings.HasPrefix(string(path), currentDir) {
t.Errorf("Path was incorrectly resolved against current directory: %s", string(path))
}

// Test converting back to URI
backToURI := ls.FileNameToDocumentURI(string(path))
t.Logf("3. Path '%s' converts back to URI '%s'", string(path), backToURI)

if string(backToURI) != string(untitledURI) {
t.Errorf("Round-trip conversion failed: '%s' -> '%s' -> '%s'", untitledURI, string(path), backToURI)
}

t.Logf("✅ Fix working: untitled paths are not resolved against current directory")
}

func TestUntitledFileIntegration(t *testing.T) {
t.Parallel()
if !bundled.Embedded {
t.Skip("bundled files are not embedded")
}

// This test simulates the exact scenario from the issue:
// 1. VS Code sends untitled:Untitled-2 URI
// 2. References/definitions should return untitled:Untitled-2 URIs, not file:// URIs

// Simulate exactly what happens in the LSP flow
originalURI := lsproto.DocumentUri("untitled:Untitled-2")

// Step 1: URI gets converted to filename when file is opened
fileName := ls.DocumentURIToFileName(originalURI)
t.Logf("1. Opening file: URI '%s' -> fileName '%s'", originalURI, fileName)

// Step 2: fileName gets processed through ToPath in project service
currentDir := "/home/daniel/TypeScript" // Current directory from the original issue
path := tspath.ToPath(fileName, currentDir, true)
t.Logf("2. Project service processes: fileName '%s' -> path '%s'", fileName, string(path))

// Step 3: Verify path is NOT corrupted by current directory resolution
if strings.HasPrefix(string(path), currentDir) {
t.Fatalf("❌ BUG: Path was incorrectly resolved against current directory: %s", string(path))
}

// Step 4: When references are found, the path gets converted back to URI
resultURI := ls.FileNameToDocumentURI(string(path))
t.Logf("3. References return: path '%s' -> URI '%s'", string(path), resultURI)

// Step 5: Verify the round-trip conversion works
if string(resultURI) != string(originalURI) {
t.Fatalf("❌ Round-trip failed: %s != %s", originalURI, resultURI)
}

t.Logf("✅ SUCCESS: Untitled file URIs are preserved correctly")
t.Logf(" Original URI: %s", originalURI)
t.Logf(" Final URI: %s", resultURI)
}
5 changes: 5 additions & 0 deletions internal/tspath/path.go
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,11 @@ func GetEncodedRootLength(path string) int {
}
}

// Untitled paths (e.g., "^/untitled/ts-nul-authority/Untitled-1")
if ch0 == '^' && ln > 1 && path[1] == '/' {
return 2 // Untitled: "^/"
}

// URL
schemeEnd := strings.Index(path, urlSchemeSeparator)
if schemeEnd != -1 {
Expand Down
61 changes: 61 additions & 0 deletions internal/tspath/untitled_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package tspath_test

import (
"testing"

"github.com/microsoft/typescript-go/internal/tspath"
"gotest.tools/v3/assert"
)

func TestUntitledPathHandling(t *testing.T) {
t.Parallel()
// Test that untitled paths are treated as rooted
untitledPath := "^/untitled/ts-nul-authority/Untitled-2"

// GetEncodedRootLength should return 2 for "^/"
rootLength := tspath.GetEncodedRootLength(untitledPath)
assert.Equal(t, rootLength, 2, "GetEncodedRootLength should return 2 for untitled paths")

// IsRootedDiskPath should return true
isRooted := tspath.IsRootedDiskPath(untitledPath)
assert.Assert(t, isRooted, "IsRootedDiskPath should return true for untitled paths")

// ToPath should not resolve untitled paths against current directory
currentDir := "/home/user/project"
path := tspath.ToPath(untitledPath, currentDir, true)
// The path should be the original untitled path
assert.Equal(t, string(path), "^/untitled/ts-nul-authority/Untitled-2", "ToPath should not resolve untitled paths against current directory")

// Test GetNormalizedAbsolutePath doesn't resolve untitled paths
normalized := tspath.GetNormalizedAbsolutePath(untitledPath, currentDir)
assert.Equal(t, normalized, "^/untitled/ts-nul-authority/Untitled-2", "GetNormalizedAbsolutePath should not resolve untitled paths")
}

func TestUntitledPathEdgeCases(t *testing.T) {
t.Parallel()
// Test edge cases
testCases := []struct {
path string
expected int
isRooted bool
}{
{"^/", 2, true}, // Minimal untitled path
{"^/untitled/ts-nul-authority/test", 2, true}, // Normal untitled path
{"^", 0, false}, // Just ^ is not rooted
{"^x", 0, false}, // ^x is not untitled
{"^^/", 0, false}, // ^^/ is not untitled
{"x^/", 0, false}, // x^/ is not untitled (doesn't start with ^)
{"^/untitled/ts-nul-authority/path/with/deeper/structure", 2, true}, // Deeper path
}

for _, tc := range testCases {
t.Run(tc.path, func(t *testing.T) {
t.Parallel()
rootLength := tspath.GetEncodedRootLength(tc.path)
assert.Equal(t, rootLength, tc.expected, "GetEncodedRootLength for path %s", tc.path)

isRooted := tspath.IsRootedDiskPath(tc.path)
assert.Equal(t, isRooted, tc.isRooted, "IsRootedDiskPath for path %s", tc.path)
})
}
}
Loading