diff --git a/tsc/internal/lsp/lsproto/lsp.go b/tsc/internal/lsp/lsproto/lsp.go index 12e8ecb56fccc..045c8ab8562fe 100644 --- a/tsc/internal/lsp/lsproto/lsp.go +++ b/tsc/internal/lsp/lsproto/lsp.go @@ -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)) { @@ -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 diff --git a/tsc/internal/lsp/lsproto/lsp_json_test.go b/tsc/internal/lsp/lsproto/lsp_json_test.go index 7b9ee36bcb2c7..61d138ce60327 100644 --- a/tsc/internal/lsp/lsproto/lsp_json_test.go +++ b/tsc/internal/lsp/lsproto/lsp_json_test.go @@ -2,6 +2,7 @@ package lsproto import ( "reflect" + "strconv" "strings" "testing" @@ -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() diff --git a/tsc/internal/lsp/server_projectinfo_test.go b/tsc/internal/lsp/server_projectinfo_test.go index c2e14089d00d9..f7e918175d7b0 100644 --- a/tsc/internal/lsp/server_projectinfo_test.go +++ b/tsc/internal/lsp/server_projectinfo_test.go @@ -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") +}