Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 59 additions & 2 deletions tsc/internal/lsp/lsproto/lsp.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,53 @@ import (
"github.com/microsoft/TypeScript/tsc/internal/tspath"
)

type DocumentUri string // !!!
type DocumentUri string

var _ json.UnmarshalerFrom = (*DocumentUri)(nil)

func (uri *DocumentUri) UnmarshalJSONFrom(dec *json.Decoder) error {
value, err := unmarshalURI(dec)
if err != nil {
return err
}
*uri = DocumentUri(value)
return nil
}

func unmarshalURI(dec *json.Decoder) (string, error) {
var value string
if err := json.UnmarshalDecode(dec, &value); err != nil {
return "", err
}

parsed, err := url.Parse(value)
if err != nil {
return "", fmt.Errorf("invalid URI %q", value)
}
// Match vscode-uri's compatibility behavior, which is used by
// vscode-languageclient: schemeless values use the file scheme, and file
// URI paths are resolved against the root.
if parsed.Scheme == "" {
parsed.Scheme = "file"
}
if parsed.Scheme == "file" {
if parsed.Opaque != "" {
parsed.Path = parsed.Opaque
parsed.Opaque = ""
}
if parsed.Path == "" {
parsed.Path = "/"
} else if parsed.Path[0] != '/' {
parsed.Path = "/" + parsed.Path
}
if parsed.Host == "" && strings.HasPrefix(parsed.Path, "//") {
return "", fmt.Errorf("invalid URI %q: path cannot start with // without an authority", value)
}
value = parsed.String()
}

return value, nil
}

func (uri DocumentUri) Path() tspath.RootedPath {
if bundled.IsBundled(string(uri)) {
Expand Down Expand Up @@ -167,7 +213,18 @@ type HasLocation interface {
GetLocation() Location
}

type URI string // !!!
type URI string

var _ json.UnmarshalerFrom = (*URI)(nil)

func (uri *URI) UnmarshalJSONFrom(dec *json.Decoder) error {
value, err := unmarshalURI(dec)
if err != nil {
return err
}
*uri = URI(value)
return nil
}

type Method string

Expand Down
61 changes: 61 additions & 0 deletions tsc/internal/lsp/lsproto/lsp_json_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package lsproto

import (
"reflect"
"strconv"
"strings"
"testing"

Expand Down Expand Up @@ -113,6 +114,66 @@ func TestUnmarshalRejectsNullForOptionalNonNullableFields(t *testing.T) {
}
}

func TestUnmarshalURI(t *testing.T) {
t.Parallel()

tests := []struct {
name string
uri string
expected DocumentUri
wantErr bool
}{
{name: "file", uri: "file:///test.ts", expected: "file:///test.ts"},
{name: "untitled", uri: "untitled:Untitled-1", expected: "untitled:Untitled-1"},
{name: "empty file path", uri: "file://", expected: "file:///"},
{name: "relative file path", uri: "file:test.ts", expected: "file:///test.ts"},
{name: "missing scheme", uri: "/test.ts", expected: "file:///test.ts"},
{name: "invalid escape", uri: "file:///test%xx.ts", wantErr: true},
{name: "path without authority", uri: "file:////server/share", wantErr: true},
}

for _, tt := range tests {
for _, uriType := range []string{"DocumentUri", "URI"} {
t.Run(uriType+"/"+tt.name, func(t *testing.T) {
t.Parallel()
var target any
switch uriType {
case "DocumentUri":
target = new(DocumentUri)
case "URI":
target = new(URI)
}
err := json.Unmarshal([]byte(strconv.Quote(tt.uri)), target)
if tt.wantErr {
assert.ErrorContains(t, err, "invalid URI")
} else {
assert.NilError(t, err)
assert.Equal(t, reflect.ValueOf(target).Elem().String(), string(tt.expected))
}
})
}
}

request := &RequestMessage{
Params: json.Value(`{"textDocument":{"uri":"file:////server/share","languageId":"typescript","version":1,"text":""}}`),
}
_, err := request.UnmarshalParams[*DidOpenTextDocumentParams]()
assert.ErrorIs(t, err, ErrorCodeInvalidParams)
assert.ErrorContains(t, err, "invalid URI")
}

func TestUnmarshalDocumentUriMapKey(t *testing.T) {
t.Parallel()

var value map[DocumentUri]int
err := json.Unmarshal([]byte(`{"file://":1}`), &value)
assert.NilError(t, err)
assert.DeepEqual(t, value, map[DocumentUri]int{"file:///": 1})

err = json.Unmarshal([]byte(`{"file:////server/share":1}`), &value)
assert.ErrorContains(t, err, "invalid URI")
}

func TestUnmarshalAcceptsNullForNullableFields(t *testing.T) {
t.Parallel()

Expand Down
37 changes: 37 additions & 0 deletions tsc/internal/lsp/server_projectinfo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,40 @@ func TestProjectInfoInferredProject(t *testing.T) {
assert.Assert(t, msg.AsResponse().Error == nil)
assert.Equal(t, resp.ConfigFilePath, "")
}

func TestEmptyFileURIDoesNotStopServer(t *testing.T) {
t.Parallel()

if !bundled.Embedded {
t.Skip("bundled files are not embedded")
}

client := initProjectInfoClient(t, map[string]string{
"/home/projects/index.ts": "export const x = 1;",
})

client.SendNotification(t, lsproto.TextDocumentDidOpenInfo, &lsproto.DidOpenTextDocumentParams{
TextDocument: &lsproto.TextDocumentItem{
Uri: "file://",
LanguageId: "typescript",
Version: 1,
Text: "export const root = true;",
},
})

uri := lsproto.DocumentUri("file:///home/projects/index.ts")
client.SendNotification(t, lsproto.TextDocumentDidOpenInfo, &lsproto.DidOpenTextDocumentParams{
TextDocument: &lsproto.TextDocumentItem{
Uri: uri,
LanguageId: "typescript",
Version: 1,
Text: "export const x = 1;",
},
})

msg, _, ok := client.SendRequest(t, lsproto.CustomProjectInfoInfo, &lsproto.ProjectInfoParams{
TextDocument: lsproto.TextDocumentIdentifier{Uri: uri},
})
assert.Assert(t, ok, "expected a response")
assert.Assert(t, msg.AsResponse().Error == nil, "valid request failed after empty file URI")
}
Loading