diff --git a/internal/ls/converters.go b/internal/ls/converters.go index 690870df03..7ad81d9b78 100644 --- a/internal/ls/converters.go +++ b/internal/ls/converters.go @@ -10,6 +10,7 @@ import ( "github.com/microsoft/typescript-go/internal/core" "github.com/microsoft/typescript-go/internal/lsp/lsproto" + "github.com/microsoft/typescript-go/internal/tspath" ) type Converters struct { @@ -80,23 +81,14 @@ func LanguageKindToScriptKind(languageID lsproto.LanguageKind) core.ScriptKind { } func DocumentURIToFileName(uri lsproto.DocumentUri) string { - uriStr := string(uri) - if strings.HasPrefix(uriStr, "file:///") { - path := uriStr[7:] - if len(path) >= 4 { - if nextSlash := strings.IndexByte(path[1:], '/'); nextSlash != -1 { - if possibleDrive, _ := url.PathUnescape(path[1 : nextSlash+2]); strings.HasSuffix(possibleDrive, ":/") { - return possibleDrive + path[nextSlash+2:] - } - } + parsed := core.Must(url.Parse(string(uri))) + if parsed.Scheme == "file" { + if parsed.Host != "" { + return "//" + parsed.Host + parsed.Path } - return path + return fixWindowsURIPath(parsed.Path) } - if strings.HasPrefix(uriStr, "file://") { - // UNC path - return uriStr[5:] - } - parsed := core.Must(url.Parse(uriStr)) + authority := parsed.Host if authority == "" { authority = "ts-nul-authority" @@ -108,6 +100,10 @@ func DocumentURIToFileName(uri lsproto.DocumentUri) string { if !strings.HasPrefix(path, "/") { path = "/" + path } + path = fixWindowsURIPath(path) + if !strings.HasPrefix(path, "/") { + path = "/" + path + } fragment := parsed.Fragment if fragment != "" { fragment = "#" + fragment @@ -115,14 +111,65 @@ func DocumentURIToFileName(uri lsproto.DocumentUri) string { return fmt.Sprintf("^/%s/%s%s%s", parsed.Scheme, authority, path, fragment) } +func fixWindowsURIPath(path string) string { + if rest, ok := strings.CutPrefix(path, "/"); ok { + if volume, rest, ok := splitVolumePath(rest); ok { + return volume + rest + } + } + return path +} + +func splitVolumePath(path string) (volume string, rest string, ok bool) { + if len(path) >= 2 && tspath.IsVolumeCharacter(path[0]) && path[1] == ':' { + return strings.ToLower(path[0:2]), path[2:], true + } + return "", path, false +} + +// https://github.com/microsoft/vscode-uri/blob/edfdccd976efaf4bb8fdeca87e97c47257721729/src/uri.ts#L455 +var extraEscapeReplacer = strings.NewReplacer( + ":", "%3A", + "/", "%2F", + "?", "%3F", + "#", "%23", + "[", "%5B", + "]", "%5D", + "@", "%40", + + "!", "%21", + "$", "%24", + "&", "%26", + "'", "%27", + "(", "%28", + ")", "%29", + "*", "%2A", + "+", "%2B", + ",", "%2C", + ";", "%3B", + "=", "%3D", + + " ", "%20", +) + func FileNameToDocumentURI(fileName string) lsproto.DocumentUri { if strings.HasPrefix(fileName, "^/") { return lsproto.DocumentUri(strings.Replace(fileName[2:], "/ts-nul-authority/", ":", 1)) } - if firstSlash := strings.IndexByte(fileName, '/'); firstSlash > 0 && fileName[firstSlash-1] == ':' { - return lsproto.DocumentUri("file:///" + url.PathEscape(fileName[:firstSlash]) + fileName[firstSlash:]) + + volume, fileName, _ := splitVolumePath(fileName) + if volume != "" { + volume = "/" + extraEscapeReplacer.Replace(volume) } - return lsproto.DocumentUri("file://" + fileName) + + fileName = strings.TrimPrefix(fileName, "//") + + parts := strings.Split(fileName, "/") + for i, part := range parts { + parts[i] = extraEscapeReplacer.Replace(url.PathEscape(part)) + } + + return lsproto.DocumentUri("file://" + volume + strings.Join(parts, "/")) } func (c *Converters) LineAndCharacterToPosition(script Script, lineAndCharacter lsproto.Position) core.TextPos { diff --git a/internal/ls/converters_test.go b/internal/ls/converters_test.go new file mode 100644 index 0000000000..c725f1c711 --- /dev/null +++ b/internal/ls/converters_test.go @@ -0,0 +1,81 @@ +package ls_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/ls" + "github.com/microsoft/typescript-go/internal/lsp/lsproto" + "gotest.tools/v3/assert" +) + +func TestDocumentURIToFileName(t *testing.T) { + t.Parallel() + + tests := []struct { + uri lsproto.DocumentUri + fileName string + }{ + {"file:///path/to/file.ts", "/path/to/file.ts"}, + {"file://server/share/file.ts", "//server/share/file.ts"}, + {"file:///d%3A/work/tsgo932/lib/utils.ts", "d:/work/tsgo932/lib/utils.ts"}, + {"file:///D%3A/work/tsgo932/lib/utils.ts", "d:/work/tsgo932/lib/utils.ts"}, + {"file:///d%3A/work/tsgo932/app/%28test%29/comp/comp-test.tsx", "d:/work/tsgo932/app/(test)/comp/comp-test.tsx"}, + {"file:///path/to/file.ts#section", "/path/to/file.ts"}, + {"file:///c:/test/me", "c:/test/me"}, + {"file://shares/files/c%23/p.cs", "//shares/files/c#/p.cs"}, + {"file:///c:/Source/Z%C3%BCrich%20or%20Zurich%20(%CB%88zj%CA%8A%C9%99r%C9%AAk,/Code/resources/app/plugins/c%23/plugin.json", "c:/Source/Zürich or Zurich (ˈzjʊərɪk,/Code/resources/app/plugins/c#/plugin.json"}, + {"file:///c:/test %25/path", "c:/test %/path"}, + // {"file:?q", "/"}, + {"file:///_:/path", "/_:/path"}, + {"file:///users/me/c%23-projects/", "/users/me/c#-projects/"}, + {"file://localhost/c%24/GitDevelopment/express", "//localhost/c$/GitDevelopment/express"}, + {"file:///c%3A/test%20with%20%2525/c%23code", "c:/test with %25/c#code"}, + + {"untitled:Untitled-1", "^/untitled/ts-nul-authority/Untitled-1"}, + {"untitled:Untitled-1#fragment", "^/untitled/ts-nul-authority/Untitled-1#fragment"}, + {"untitled:c:/Users/jrieken/Code/abc.txt", "^/untitled/ts-nul-authority/c:/Users/jrieken/Code/abc.txt"}, + {"untitled:C:/Users/jrieken/Code/abc.txt", "^/untitled/ts-nul-authority/c:/Users/jrieken/Code/abc.txt"}, + } + + for _, test := range tests { + t.Run(string(test.uri), func(t *testing.T) { + t.Parallel() + assert.Equal(t, ls.DocumentURIToFileName(test.uri), test.fileName) + }) + } +} + +func TestFileNameToDocumentURI(t *testing.T) { + t.Parallel() + + tests := []struct { + fileName string + uri lsproto.DocumentUri + }{ + {"/path/to/file.ts", "file:///path/to/file.ts"}, + {"//server/share/file.ts", "file://server/share/file.ts"}, + {"d:/work/tsgo932/lib/utils.ts", "file:///d%3A/work/tsgo932/lib/utils.ts"}, + {"d:/work/tsgo932/lib/utils.ts", "file:///d%3A/work/tsgo932/lib/utils.ts"}, + {"d:/work/tsgo932/app/(test)/comp/comp-test.tsx", "file:///d%3A/work/tsgo932/app/%28test%29/comp/comp-test.tsx"}, + {"/path/to/file.ts", "file:///path/to/file.ts"}, + {"c:/test/me", "file:///c%3A/test/me"}, + {"//shares/files/c#/p.cs", "file://shares/files/c%23/p.cs"}, + {"c:/Source/Zürich or Zurich (ˈzjʊərɪk,/Code/resources/app/plugins/c#/plugin.json", "file:///c%3A/Source/Z%C3%BCrich%20or%20Zurich%20%28%CB%88zj%CA%8A%C9%99r%C9%AAk%2C/Code/resources/app/plugins/c%23/plugin.json"}, + {"c:/test %/path", "file:///c%3A/test%20%25/path"}, + {"/", "file:///"}, + {"/_:/path", "file:///_%3A/path"}, + {"/users/me/c#-projects/", "file:///users/me/c%23-projects/"}, + {"//localhost/c$/GitDevelopment/express", "file://localhost/c%24/GitDevelopment/express"}, + {"c:/test with %25/c#code", "file:///c%3A/test%20with%20%2525/c%23code"}, + + {"^/untitled/ts-nul-authority/Untitled-1", "untitled:Untitled-1"}, + {"^/untitled/ts-nul-authority/c:/Users/jrieken/Code/abc.txt", "untitled:c:/Users/jrieken/Code/abc.txt"}, + } + + for _, test := range tests { + t.Run(test.fileName, func(t *testing.T) { + t.Parallel() + assert.Equal(t, ls.FileNameToDocumentURI(test.fileName), test.uri) + }) + } +} diff --git a/internal/tspath/path.go b/internal/tspath/path.go index 9b5b41492b..1fc4229152 100644 --- a/internal/tspath/path.go +++ b/internal/tspath/path.go @@ -136,7 +136,7 @@ func pathComponents(path string, rootLength int) []string { return append([]string{root}, rest...) } -func isVolumeCharacter(char byte) bool { +func IsVolumeCharacter(char byte) bool { return char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' } @@ -180,7 +180,7 @@ func GetEncodedRootLength(path string) int { } // DOS - if isVolumeCharacter(ch0) && ln > 1 && path[1] == ':' { + if IsVolumeCharacter(ch0) && ln > 1 && path[1] == ':' { if ln == 2 { return 2 // DOS: "c:" (but not "c:d") } @@ -203,7 +203,7 @@ func GetEncodedRootLength(path string) int { // special case interpreted as "the machine from which the URL is being interpreted". scheme := path[:schemeEnd] authority := path[authorityStart:authorityEnd] - if scheme == "file" && (authority == "" || authority == "localhost") && (len(path) > authorityEnd+2) && isVolumeCharacter(path[authorityEnd+1]) { + if scheme == "file" && (authority == "" || authority == "localhost") && (len(path) > authorityEnd+2) && IsVolumeCharacter(path[authorityEnd+1]) { volumeSeparatorEnd := getFileUrlVolumeSeparatorEnd(path, authorityEnd+2) if volumeSeparatorEnd != -1 { if volumeSeparatorEnd == len(path) {