diff --git a/packages/vscode-typescript/package.json b/packages/vscode-typescript/package.json index 93090a2bd4461..ee0c3410e77a9 100644 --- a/packages/vscode-typescript/package.json +++ b/packages/vscode-typescript/package.json @@ -32,7 +32,9 @@ "onLanguage:javascript", "onLanguage:javascriptreact", "onLanguage:typescript", - "onLanguage:typescriptreact" + "onLanguage:typescriptreact", + "workspaceContains:**/tsconfig.json", + "workspaceContains:**/jsconfig.json" ], "contributes": { "configuration": [ diff --git a/packages/vscode-typescript/src/contentMapperContributions.ts b/packages/vscode-typescript/src/contentMapperContributions.ts index a4570d7bd96bb..842dd792038fb 100644 --- a/packages/vscode-typescript/src/contentMapperContributions.ts +++ b/packages/vscode-typescript/src/contentMapperContributions.ts @@ -1,5 +1,12 @@ import * as vscode from "vscode"; +export const jsTsLanguageModes = [ + "typescript", + "typescriptreact", + "javascript", + "javascriptreact", +]; + export interface ContentMapperManifest { readonly name: string; readonly version?: string; @@ -92,3 +99,11 @@ export function documentMatchesContentMapperContributions( } return false; } + +const candidateSchemes = new Set(["file", "untitled"]); + +export function documentIsContentMapperCandidate( + document: { readonly uri: { readonly scheme: string; }; readonly languageId: string; }, +): boolean { + return candidateSchemes.has(document.uri.scheme) && !jsTsLanguageModes.includes(document.languageId); +} diff --git a/packages/vscode-typescript/src/session.ts b/packages/vscode-typescript/src/session.ts index 46303606b836f..0e5bf7ecdb329 100644 --- a/packages/vscode-typescript/src/session.ts +++ b/packages/vscode-typescript/src/session.ts @@ -8,6 +8,7 @@ import { } from "./commands"; import { type ContentMapperContribution, + documentIsContentMapperCandidate, documentMatchesContentMapperContributions, serializeContentMapperContributions, validateContentMapperRegistration, @@ -16,6 +17,7 @@ import { ProjectStatus } from "./projectStatus"; import { setupStatusBar } from "./statusBar"; import { TelemetryReporter } from "./telemetryReporting"; import { + contentMappersEnabled, getDefaultExePath, getExe, getWorkspaceTsdkConfigValue, @@ -60,7 +62,7 @@ export class SessionManager implements vscode.Disposable { } })); this.disposables.push(vscode.workspace.onDidOpenTextDocument(document => { - if (documentMatchesContentMapperContributions(document, this.contentMapperRegistrations)) { + if (this.shouldSyncForDocument(document)) { void this.syncContentMapperContributions(); } })); @@ -136,11 +138,18 @@ export class SessionManager implements vscode.Disposable { return operation; } + private shouldSyncForDocument(document: vscode.TextDocument): boolean { + if (documentMatchesContentMapperContributions(document, this.contentMapperRegistrations)) { + return true; + } + return contentMappersEnabled() && documentIsContentMapperCandidate(document); + } + private async syncContentMapperContributionsNow(): Promise { try { if (!this.currentSession?.client.isInitialized) return; const openDocuments = vscode.workspace.textDocuments - .filter(document => documentMatchesContentMapperContributions(document, this.contentMapperRegistrations)) + .filter(document => this.shouldSyncForDocument(document)) .map(document => document.uri); await this.currentSession.client.setContentMapperContributions( serializeContentMapperContributions(this.contentMapperRegistrations), diff --git a/packages/vscode-typescript/src/util.ts b/packages/vscode-typescript/src/util.ts index a299423f81948..156b1d6dfc876 100644 --- a/packages/vscode-typescript/src/util.ts +++ b/packages/vscode-typescript/src/util.ts @@ -1,21 +1,17 @@ import * as fs from "fs"; import * as path from "path"; import * as vscode from "vscode"; +import { jsTsLanguageModes } from "./contentMapperContributions"; import { resolvePackageExecutable } from "./tsdkPackage"; +export { jsTsLanguageModes }; + export const aiConnectionString = "0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255"; export const languageClientName = "TypeScript Language Server"; export const nightlyExtensionId = "TypeScriptTeam.vscode-typescript-nightly"; export const enableContributedNightlyVersion = true; -export const jsTsLanguageModes = [ - "typescript", - "typescriptreact", - "javascript", - "javascriptreact", -]; - export const builtinTSExtensionId = "vscode.typescript-language-features"; /** diff --git a/packages/vscode-typescript/test/contentMapperContributions.test.ts b/packages/vscode-typescript/test/contentMapperContributions.test.ts index 74c4dd07116f9..2feff7bb7788d 100644 --- a/packages/vscode-typescript/test/contentMapperContributions.test.ts +++ b/packages/vscode-typescript/test/contentMapperContributions.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { type ContentMapperContribution, + documentIsContentMapperCandidate, documentMatchesContentMapperContributions, serializeContentMapperContributions, } from "../src/contentMapperContributions"; @@ -30,6 +31,24 @@ test("content mapper extensions match document paths case-insensitively", () => assert.equal(documentMatchesContentMapperContributions(document, registrations), true); }); +test("content mapper candidates are non-js/ts file and untitled documents", () => { + assert.equal(documentIsContentMapperCandidate({ uri: { scheme: "file" }, languageId: "plaintext" }), true); + assert.equal(documentIsContentMapperCandidate({ uri: { scheme: "file" }, languageId: "unknown" }), true); + assert.equal(documentIsContentMapperCandidate({ uri: { scheme: "untitled" }, languageId: "plaintext" }), true); +}); + +test("content mapper candidates exclude js/ts documents", () => { + for (const languageId of ["typescript", "typescriptreact", "javascript", "javascriptreact"]) { + assert.equal(documentIsContentMapperCandidate({ uri: { scheme: "file" }, languageId }), false); + assert.equal(documentIsContentMapperCandidate({ uri: { scheme: "untitled" }, languageId }), false); + } +}); + +test("content mapper candidates exclude non-file schemes", () => { + assert.equal(documentIsContentMapperCandidate({ uri: { scheme: "git" }, languageId: "plaintext" }), false); + assert.equal(documentIsContentMapperCandidate({ uri: { scheme: "output" }, languageId: "plaintext" }), false); +}); + test("serializes the documented inferred project contribution", () => { const registrations = new Map([[ "publisher.extension", diff --git a/tsc/internal/lsp/lsproto/_generate/generate.mts b/tsc/internal/lsp/lsproto/_generate/generate.mts index 6f65a9af99c1e..88720ccde0142 100755 --- a/tsc/internal/lsp/lsproto/_generate/generate.mts +++ b/tsc/internal/lsp/lsproto/_generate/generate.mts @@ -477,7 +477,7 @@ const customStructures: Structure[] = [ name: "SetContentMapperContributionsParams", properties: [ { name: "contributions", type: { kind: "array", element: { kind: "reference", name: "ContentMapperContribution" } }, documentation: "Complete replacement set of active extension contributions." }, - { name: "openDocuments", type: { kind: "array", element: { kind: "reference", name: "TextDocumentIdentifier" } }, documentation: "Currently open documents matching contributed extensions." }, + { name: "openDocuments", type: { kind: "array", element: { kind: "reference", name: "TextDocumentIdentifier" } }, documentation: "Currently open candidate documents used to discover the configured projects that declare their content mappers." }, ], documentation: "Parameters for the custom/setContentMapperContributions request.", }, diff --git a/tsc/internal/lsp/lsproto/lsp_generated.go b/tsc/internal/lsp/lsproto/lsp_generated.go index 022513d644b5a..31ba97d10e87e 100644 --- a/tsc/internal/lsp/lsproto/lsp_generated.go +++ b/tsc/internal/lsp/lsproto/lsp_generated.go @@ -9175,7 +9175,7 @@ type SetContentMapperContributionsParams struct { // Complete replacement set of active extension contributions. Contributions []*ContentMapperContribution `json:"contributions" lsp:"required"` - // Currently open documents matching contributed extensions. + // Currently open candidate documents used to discover the configured projects that declare their content mappers. OpenDocuments []TextDocumentIdentifier `json:"openDocuments" lsp:"required"` } diff --git a/tsc/internal/lsp/server_contentmapper_test.go b/tsc/internal/lsp/server_contentmapper_test.go index 6e4326f566f77..29785f3a67264 100644 --- a/tsc/internal/lsp/server_contentmapper_test.go +++ b/tsc/internal/lsp/server_contentmapper_test.go @@ -246,3 +246,97 @@ export const title = "Profile"; TextDocument: lsproto.TextDocumentIdentifier{Uri: uri}, }) } + +func TestSetContentMapperCandidatesDiscoverConfiguredProject(t *testing.T) { + t.Parallel() + if !bundled.Embedded { + t.Skip("bundled files are not embedded") + } + + const component = ` + +` + files := map[string]string{ + "/home/project/tsconfig.json": `{ + "compilerOptions": { "target": "es2020", "module": "esnext", "moduleResolution": "bundler", "strict": true }, + "contentMappers": [ { "package": "mapper", "extensions": [".vue"] } ] + }`, + "/home/project/node_modules/mapper/package.json": contentmappertest.PackageJSON(contentmappertest.ComponentMapper), + "/home/project/ProfileCard.vue": component, + } + + var mu sync.Mutex + var didOpenRegistration *lsproto.Registration + onServerRequest := func(_ context.Context, req *lsproto.RequestMessage) *lsproto.ResponseMessage { + switch req.Method { + case lsproto.MethodWorkspaceConfiguration: + return &lsproto.ResponseMessage{ID: req.ID, JSONRPC: req.JSONRPC, Result: []any{nil, nil, nil, nil}} + case lsproto.MethodClientRegisterCapability: + params, err := req.UnmarshalParams[*lsproto.RegistrationParams]() + assert.NilError(t, err) + mu.Lock() + defer mu.Unlock() + for _, registration := range params.Registrations { + if registration.Id == "content-mapper-did-open" { + didOpenRegistration = registration + } + } + return &lsproto.ResponseMessage{ID: req.ID, JSONRPC: req.JSONRPC, Result: lsproto.Null{}} + default: + return nil + } + } + + fs := bundled.WrapFS(vfstest.FromMap(files, false)) + client, closeClient := lsptestutil.NewLSPClient(t, lsp.ServerOptions{ + Err: io.Discard, + Cwd: "/home/project", + FS: fs, + DefaultLibraryPath: bundled.LibPath(), + Spawn: contentmappertest.NewSpawner().Spawn, + }, onServerRequest) + t.Cleanup(func() { _ = closeClient() }) + + caps := &lsproto.ClientCapabilities{ + TextDocument: &lsproto.TextDocumentClientCapabilities{ + Synchronization: &lsproto.TextDocumentSyncClientCapabilities{DynamicRegistration: new(true)}, + }, + } + initMsg, _, ok := client.SendRequest(t, lsproto.InitializeInfo, &lsproto.InitializeParams{ + Capabilities: caps, + InitializationOptions: &lsproto.InitializationOptionsOrNull{InitializationOptions: &lsproto.InitializationOptions{ + RunExternalCode: new(true), + }}, + }) + assert.Assert(t, ok && initMsg.AsResponse().Error == nil, "initialize failed") + client.SendNotification(t, lsproto.InitializedInfo, &lsproto.InitializedParams{}) + <-client.Server.InitComplete() + + uri := lsproto.DocumentUri("file:///home/project/ProfileCard.vue") + msg, _, ok := client.SendRequest(t, lsproto.CustomSetContentMapperContributionsInfo, &lsproto.SetContentMapperContributionsParams{ + Contributions: []*lsproto.ContentMapperContribution{}, + OpenDocuments: []lsproto.TextDocumentIdentifier{{Uri: uri}}, + }) + assert.Assert(t, ok && msg.AsResponse().Error == nil) + + mu.Lock() + registration := didOpenRegistration + mu.Unlock() + assert.Assert(t, registration != nil, "expected content-mapper-did-open registration for tsconfig-declared mapper") + assert.Assert(t, registration.RegisterOptions != nil && registration.RegisterOptions.TextDocumentDidOpen != nil) + selector := registration.RegisterOptions.TextDocumentDidOpen.DocumentSelector.DocumentSelector + assert.Assert(t, selector != nil && len(*selector) == 1) + assert.Equal(t, *(*selector)[0].Pattern.Pattern.Pattern, "**/*.vue") + + client.SendNotification(t, lsproto.TextDocumentDidOpenInfo, &lsproto.DidOpenTextDocumentParams{ + TextDocument: &lsproto.TextDocumentItem{Uri: uri, LanguageId: "vue", Version: 1, Text: component}, + }) + hoverMsg, hover, ok := client.SendRequest(t, lsproto.TextDocumentHoverInfo, &lsproto.HoverParams{ + TextDocument: lsproto.TextDocumentIdentifier{Uri: uri}, + Position: lsproto.Position{Line: 3, Character: 15}, + }) + assert.Assert(t, ok && hoverMsg.AsResponse().Error == nil) + assert.Assert(t, hover.Hover != nil, "expected hover for mapped-only file without opening a TS file") +}