From 74e515c2b83e29e73e6ff5c8e321aea42d06b911 Mon Sep 17 00:00:00 2001 From: Phil Allen Date: Wed, 20 May 2026 11:39:01 -0700 Subject: [PATCH 01/20] Initial implementation --- package-lock.json | 35 ++++++++++ package.json | 21 ++++++ src/activateRoslyn.ts | 4 ++ src/csharpExtensionExports.ts | 1 + src/main.ts | 15 +++- src/shared/tasExperimentationService.ts | 93 +++++++++++++++++++++++++ 6 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 src/shared/tasExperimentationService.ts diff --git a/package-lock.json b/package-lock.json index 6afc8a000..b3bef2a86 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "semver": "7.5.4", "vscode-html-languageservice": "^5.3.1", "vscode-languageclient": "10.0.0-next.20", + "vscode-tas-client": "^0.2.1", "yauzl": "3.2.1" }, "devDependencies": { @@ -11830,6 +11831,15 @@ "node": ">= 6" } }, + "node_modules/tas-client": { + "version": "0.4.1", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/tas-client/-/tas-client-0.4.1.tgz", + "integrity": "sha1-6izL1bku0LFPFaXuUHq6AoCfmqI=", + "license": "MIT", + "engines": { + "node": ">=22" + } + }, "node_modules/terminal-link": { "version": "4.0.0", "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/terminal-link/-/terminal-link-4.0.0.tgz", @@ -12474,6 +12484,18 @@ "integrity": "sha512-L9WMGRfrjOhgHSdOYgCt/yRMsXzLDJSL7BPrOZt73gU0iWO4mpqzqQzOz5srxqTvMBaR0XZTSrVWo4j55Rc6cA==", "dev": true }, + "node_modules/vscode-tas-client": { + "version": "0.2.1", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/vscode-tas-client/-/vscode-tas-client-0.2.1.tgz", + "integrity": "sha1-HuvkOqqVmQbJUS4ThULgnuoZUs4=", + "license": "MIT", + "dependencies": { + "tas-client": "^0.4.0" + }, + "engines": { + "vscode": "^1.85.0" + } + }, "node_modules/vscode-textmate": { "version": "6.0.0", "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/vscode-textmate/-/vscode-textmate-6.0.0.tgz", @@ -21239,6 +21261,11 @@ } } }, + "tas-client": { + "version": "0.4.1", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/tas-client/-/tas-client-0.4.1.tgz", + "integrity": "sha1-6izL1bku0LFPFaXuUHq6AoCfmqI=" + }, "terminal-link": { "version": "4.0.0", "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/terminal-link/-/terminal-link-4.0.0.tgz", @@ -21675,6 +21702,14 @@ "integrity": "sha512-L9WMGRfrjOhgHSdOYgCt/yRMsXzLDJSL7BPrOZt73gU0iWO4mpqzqQzOz5srxqTvMBaR0XZTSrVWo4j55Rc6cA==", "dev": true }, + "vscode-tas-client": { + "version": "0.2.1", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/vscode-tas-client/-/vscode-tas-client-0.2.1.tgz", + "integrity": "sha1-HuvkOqqVmQbJUS4ThULgnuoZUs4=", + "requires": { + "tas-client": "^0.4.0" + } + }, "vscode-textmate": { "version": "6.0.0", "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/vscode-textmate/-/vscode-textmate-6.0.0.tgz", diff --git a/package.json b/package.json index 283952ad5..ae9e72a6f 100644 --- a/package.json +++ b/package.json @@ -124,6 +124,7 @@ "semver": "7.5.4", "vscode-html-languageservice": "^5.3.1", "vscode-languageclient": "10.0.0-next.20", + "vscode-tas-client": "^0.2.1", "yauzl": "3.2.1" }, "devDependencies": { @@ -750,6 +751,26 @@ "type": "string", "description": "%configuration.dotnet.defaultSolution.description%", "order": 0 + }, + "dotnet.experiments.targetPopulation": { + "type": "string", + "enum": [ + "auto", + "team", + "internal", + "insiders", + "public" + ], + "default": "auto", + "enumDescriptions": [ + "%configuration.dotnet.experiments.targetPopulation.auto%", + "%configuration.dotnet.experiments.targetPopulation.team%", + "%configuration.dotnet.experiments.targetPopulation.internal%", + "%configuration.dotnet.experiments.targetPopulation.insiders%", + "%configuration.dotnet.experiments.targetPopulation.public%" + ], + "description": "%configuration.dotnet.experiments.targetPopulation%", + "order": 1 } } }, diff --git a/src/activateRoslyn.ts b/src/activateRoslyn.ts index 5f072201a..579a3fff2 100644 --- a/src/activateRoslyn.ts +++ b/src/activateRoslyn.ts @@ -25,6 +25,7 @@ import { SolutionSnapshotProvider } from './lsptoolshost/solutionSnapshot/soluti import { BuildResultDiagnostics } from './lsptoolshost/diagnostics/buildResultReporterService'; import { getComponentFolder } from './lsptoolshost/extensions/builtInComponents'; import { RazorLogger } from './razor/src/razorLogger'; +import { IExperimentationService } from 'vscode-tas-client'; export function activateRoslyn( context: vscode.ExtensionContext, @@ -33,6 +34,7 @@ export function activateRoslyn( eventStream: EventStream, csharpChannel: vscode.LogOutputChannel, reporter: TelemetryReporter, + experimentationService: IExperimentationService | undefined, csharpDevkitExtension: vscode.Extension | undefined, getCoreClrDebugPromise: (languageServerStarted: Promise) => Promise ): CSharpExtensionExports { @@ -78,6 +80,8 @@ export function activateRoslyn( logDirectory: context.logUri.fsPath, determineBrowserType: BlazorDebugConfigurationProvider.determineBrowserType, experimental: { + getTreatmentVariable: (name: string, configId = 'vscode') => + experimentationService?.getTreatmentVariable(configId, name), sendServerRequest: async (t, p, ct) => await languageServerExport.sendRequest(t, p, ct), sendServerRequestWithProgress: async (t, p, pr, ct) => await languageServerExport.sendRequestWithProgress(t, p, pr, ct), diff --git a/src/csharpExtensionExports.ts b/src/csharpExtensionExports.ts index 265b9f788..9c8edee41 100644 --- a/src/csharpExtensionExports.ts +++ b/src/csharpExtensionExports.ts @@ -48,6 +48,7 @@ export interface CSharpExtensionExports { } export interface CSharpExtensionExperimentalExports { + getTreatmentVariable: (name: string, configId?: string) => T | undefined; sendServerRequest: ( type: RequestType, params: RequestParam, diff --git a/src/main.ts b/src/main.ts index c2010b702..f9d83589f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -12,7 +12,6 @@ import { CsharpChannelObserver } from './shared/observers/csharpChannelObserver' import { CsharpLoggerObserver } from './shared/observers/csharpLoggerObserver'; import { EventStream } from './eventStream'; import { PlatformInformation } from './shared/platform'; -import TelemetryReporter from '@vscode/extension-telemetry'; import { vscodeNetworkSettingsProvider } from './networkSettings'; import createOptionStream from './shared/observables/createOptionStream'; import { AbsolutePathPackage } from './packageManager/absolutePathPackage'; @@ -30,6 +29,8 @@ import { checkIsSupportedPlatform } from './checkSupportedPlatform'; import { activateOmniSharp } from './activateOmniSharp'; import { activateRoslyn } from './activateRoslyn'; import { LimitedActivationStatus } from './shared/limitedActivationStatus'; +import { initializeTasExperimentationService, TasTelemetryReporter } from './shared/tasExperimentationService'; +import { IExperimentationService } from 'vscode-tas-client'; export async function activate( context: vscode.ExtensionContext @@ -43,10 +44,19 @@ export async function activate( util.setExtensionPath(context.extension.extensionPath); const aiKey = context.extension.packageJSON.contributes.debuggers[0].aiKey; - const reporter = new TelemetryReporter(aiKey); + const reporter = new TasTelemetryReporter(aiKey); // ensure it gets properly disposed. Upon disposal the events will be flushed. context.subscriptions.push(reporter); + let experimentationService: IExperimentationService | undefined; + try { + experimentationService = await initializeTasExperimentationService(context, reporter); + const tasService = experimentationService; + context.subscriptions.push({ dispose: () => tasService.dispose() }); + } catch (error) { + csharpChannel.warn(`Unable to initialize TAS experimentation service: ${error}`); + } + const eventStream = new EventStream(); const csharpchannelObserver = new CsharpChannelObserver(csharpChannel); const csharpLogObserver = new CsharpLoggerObserver(csharpChannel); @@ -138,6 +148,7 @@ export async function activate( eventStream, csharpChannel, reporter, + experimentationService, csharpDevkitExtension, getCoreClrDebugPromise ); diff --git a/src/shared/tasExperimentationService.ts b/src/shared/tasExperimentationService.ts new file mode 100644 index 000000000..1f667edc9 --- /dev/null +++ b/src/shared/tasExperimentationService.ts @@ -0,0 +1,93 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import TelemetryReporter from '@vscode/extension-telemetry'; +import { + getExperimentationServiceAsync, + IExperimentationService, + IExperimentationTelemetry, + TargetPopulation, +} from 'vscode-tas-client'; +import * as vscode from 'vscode'; + +export class TasTelemetryReporter extends TelemetryReporter implements IExperimentationTelemetry { + private readonly _sharedProperties: { [key: string]: string } = {}; + + public setSharedProperty(name: string, value: string): void { + this._sharedProperties[name] = value; + } + + public postEvent(eventName: string, props: Map): void { + this.sendTelemetryEvent(eventName, Object.fromEntries(props)); + } + + public override sendTelemetryEvent( + eventName: string, + properties?: { [key: string]: string }, + measurements?: { [key: string]: number } + ): void { + super.sendTelemetryEvent(eventName, this.withSharedProperties(properties), measurements); + } + + public override sendTelemetryErrorEvent( + eventName: string, + properties?: { [key: string]: string }, + measurements?: { [key: string]: number }, + _errorProps?: string[] + ): void { + super.sendTelemetryErrorEvent(eventName, this.withSharedProperties(properties), measurements); + } + + private withSharedProperties(properties?: { [key: string]: string }): { [key: string]: string } | undefined { + if (Object.keys(this._sharedProperties).length === 0) { + return properties; + } + + if (!properties) { + return { ...this._sharedProperties }; + } + + return { + ...this._sharedProperties, + ...properties, + }; + } +} + +export async function initializeTasExperimentationService( + context: vscode.ExtensionContext, + reporter: TasTelemetryReporter +): Promise { + const targetPopulation = getTargetPopulation(); + + const service = await getExperimentationServiceAsync( + context.extension.packageJSON.name, + context.extension.packageJSON.version, + targetPopulation, + reporter, + context.globalState + ); + + return service; +} + +function getTargetPopulation(): TargetPopulation { + switch (vscode.env.uriScheme) { + case 'vscode': + return TargetPopulation.Public; + + case 'vscode-insiders': + return TargetPopulation.Insiders; + + case 'vscode-exploration': + return TargetPopulation.Internal; + + case 'code-oss': + return TargetPopulation.Team; + + default: + return TargetPopulation.Public; + } +} From 411a8d17d815d0adfe1b86b5576239ca67d1cb9d Mon Sep 17 00:00:00 2001 From: Phil Allen Date: Wed, 20 May 2026 13:02:23 -0700 Subject: [PATCH 02/20] Please author a command in this vscode extension that will make the above fullConfig changes to the lsp-config.json file if it exists, and write a new lsp-config.json file if it doesn't exist, assuming the .copilot folder does exist. --- l10n/bundle.l10n.json | 3 ++ package.json | 6 ++++ package.nls.json | 1 + src/lsptoolshost/commands.ts | 53 ++++++++++++++++++++++++++++++++++++ 4 files changed, 63 insertions(+) diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index cf5535949..a1d16a19b 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -141,6 +141,9 @@ "Text editor must be focused to fix all issues": "Text editor must be focused to fix all issues", "Fix all issues": "Fix all issues", "Select fix all action": "Select fix all action", + "Failed to read Copilot LSP config: {0}": "Failed to read Copilot LSP config: {0}", + "Updated Copilot LSP config at {0}.": "Updated Copilot LSP config at {0}.", + "Failed to write Copilot LSP config: {0}": "Failed to write Copilot LSP config: {0}", "C# LSP Trace Logs": "C# LSP Trace Logs", "Open solution": "Open solution", "Restart server": "Restart server", diff --git a/package.json b/package.json index ae9e72a6f..151e60f0d 100644 --- a/package.json +++ b/package.json @@ -1891,6 +1891,12 @@ "category": ".NET", "enablement": "isWorkspaceTrusted && dotnet.server.activationContext == 'Roslyn'" }, + { + "command": "dotnet.configureCopilotLsp", + "title": "%command.dotnet.configureCopilotLsp%", + "category": ".NET", + "enablement": "isWorkspaceTrusted" + }, { "command": "o.fixAll.solution", "title": "%command.o.fixAll.solution%", diff --git a/package.nls.json b/package.nls.json index 019331ec2..156ca0fe3 100644 --- a/package.nls.json +++ b/package.nls.json @@ -2,6 +2,7 @@ "command.o.restart": "Restart OmniSharp", "command.o.pickProjectAndStart": "Select Project", "command.dotnet.openSolution": "Open Solution", + "command.dotnet.configureCopilotLsp": "Configure Copilot CLI C# LSP", "command.o.fixAll.solution": "Fix all occurrences of a code issue within solution", "command.o.fixAll.project": "Fix all occurrences of a code issue within project", "command.o.fixAll.document": "Fix all occurrences of a code issue within document", diff --git a/src/lsptoolshost/commands.ts b/src/lsptoolshost/commands.ts index 4bb3b5dd2..ee307216e 100644 --- a/src/lsptoolshost/commands.ts +++ b/src/lsptoolshost/commands.ts @@ -4,6 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; import { RoslynLanguageServer } from './server/roslynLanguageServer'; import reportIssue from '../shared/reportIssue'; import { getDotnetInfo } from '../shared/utils/getDotnetInfo'; @@ -23,6 +26,8 @@ import { registerCollectLogsCommand } from './logging/collectLogs'; import { ObservableLogOutputChannel } from './logging/observableLogOutputChannel'; import { RazorLogger } from '../razor/src/razorLogger'; +const configureCopilotLspCommand = 'dotnet.configureCopilotLsp'; + export function registerCommands( context: vscode.ExtensionContext, languageServer: RoslynLanguageServer, @@ -92,5 +97,53 @@ function registerExtensionCommands( context.subscriptions.push( vscode.commands.registerCommand('csharp.showOutputWindow', async () => outputChannel.show()) ); + context.subscriptions.push( + vscode.commands.registerCommand(configureCopilotLspCommand, async () => { + const lspConfigPath = path.join(os.homedir(), '.copilot', 'lsp-config.json'); + const csharpLspServerConfig = { + command: 'dotnet', + args: ['dnx', 'roslyn-language-server', '--yes', '--prerelease', '--', '--stdio', '--autoLoadProjects'], + fileExtensions: { + '.cs': 'csharp', + '.razor': 'aspnetcorerazor', + '.cshtml': 'aspnetcorerazor', + }, + warmupTimeoutMs: 120000, + }; + + let lspConfig: { lspServers?: { [key: string]: unknown } } = {}; + + try { + const currentContent = await fs.readFile(lspConfigPath, 'utf8'); + lspConfig = JSON.parse(currentContent); + } catch (error) { + const nodeError = error as NodeJS.ErrnoException; + if (nodeError.code !== 'ENOENT') { + void vscode.window.showErrorMessage( + vscode.l10n.t('Failed to read Copilot LSP config: {0}', nodeError.message) + ); + return; + } + } + + if (!lspConfig.lspServers || typeof lspConfig.lspServers !== 'object') { + lspConfig.lspServers = {}; + } + + lspConfig.lspServers.csharp = csharpLspServerConfig; + + try { + await fs.writeFile(lspConfigPath, `${JSON.stringify(lspConfig, null, 2)}\n`, 'utf8'); + void vscode.window.showInformationMessage( + vscode.l10n.t('Updated Copilot LSP config at {0}.', lspConfigPath) + ); + } catch (error) { + const nodeError = error as NodeJS.ErrnoException; + void vscode.window.showErrorMessage( + vscode.l10n.t('Failed to write Copilot LSP config: {0}', nodeError.message) + ); + } + }) + ); registerCollectLogsCommand(context, languageServer, outputChannel, csharpTraceChannel, razorLogger); } From 29ad016b632ce0da13daaf67d1c0c32175e2f805 Mon Sep 17 00:00:00 2001 From: Phil Allen Date: Wed, 20 May 2026 15:57:27 -0700 Subject: [PATCH 03/20] Change it so that #sym:csharpLspServerConfig is read from a file included in the vsix. Copy the file from the dotnet/skills repo now. --- copilot/lsp-config.json | 22 ++++++++++++++++++++++ l10n/bundle.l10n.json | 2 ++ src/lsptoolshost/commands.ts | 34 ++++++++++++++++++++++++---------- 3 files changed, 48 insertions(+), 10 deletions(-) create mode 100644 copilot/lsp-config.json diff --git a/copilot/lsp-config.json b/copilot/lsp-config.json new file mode 100644 index 000000000..91c9b4f96 --- /dev/null +++ b/copilot/lsp-config.json @@ -0,0 +1,22 @@ +{ + "lspServers": { + "csharp": { + "command": "dotnet", + "args": [ + "dnx", + "roslyn-language-server", + "--yes", + "--prerelease", + "--", + "--stdio", + "--autoLoadProjects" + ], + "fileExtensions": { + ".cs": "csharp", + ".razor": "aspnetcorerazor", + ".cshtml": "aspnetcorerazor" + }, + "warmupTimeoutMs": 120000 + } + } +} diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index a1d16a19b..c1cddd59f 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -141,6 +141,8 @@ "Text editor must be focused to fix all issues": "Text editor must be focused to fix all issues", "Fix all issues": "Fix all issues", "Select fix all action": "Select fix all action", + "Packaged Copilot LSP config is missing lspServers.csharp.": "Packaged Copilot LSP config is missing lspServers.csharp.", + "Failed to read packaged Copilot LSP config: {0}": "Failed to read packaged Copilot LSP config: {0}", "Failed to read Copilot LSP config: {0}": "Failed to read Copilot LSP config: {0}", "Updated Copilot LSP config at {0}.": "Updated Copilot LSP config at {0}.", "Failed to write Copilot LSP config: {0}": "Failed to write Copilot LSP config: {0}", diff --git a/src/lsptoolshost/commands.ts b/src/lsptoolshost/commands.ts index ee307216e..c648c62d6 100644 --- a/src/lsptoolshost/commands.ts +++ b/src/lsptoolshost/commands.ts @@ -27,6 +27,7 @@ import { ObservableLogOutputChannel } from './logging/observableLogOutputChannel import { RazorLogger } from '../razor/src/razorLogger'; const configureCopilotLspCommand = 'dotnet.configureCopilotLsp'; +const packagedCopilotLspConfigPath = path.join('copilot', 'lsp-config.json'); export function registerCommands( context: vscode.ExtensionContext, @@ -100,16 +101,29 @@ function registerExtensionCommands( context.subscriptions.push( vscode.commands.registerCommand(configureCopilotLspCommand, async () => { const lspConfigPath = path.join(os.homedir(), '.copilot', 'lsp-config.json'); - const csharpLspServerConfig = { - command: 'dotnet', - args: ['dnx', 'roslyn-language-server', '--yes', '--prerelease', '--', '--stdio', '--autoLoadProjects'], - fileExtensions: { - '.cs': 'csharp', - '.razor': 'aspnetcorerazor', - '.cshtml': 'aspnetcorerazor', - }, - warmupTimeoutMs: 120000, - }; + const extensionLspConfigPath = path.join(context.extension.extensionPath, packagedCopilotLspConfigPath); + + let csharpLspServerConfig: unknown; + try { + const extensionLspConfigContent = await fs.readFile(extensionLspConfigPath, 'utf8'); + const extensionLspConfig = JSON.parse(extensionLspConfigContent) as { + lspServers?: { [key: string]: unknown }; + }; + csharpLspServerConfig = extensionLspConfig.lspServers?.csharp; + + if (!csharpLspServerConfig || typeof csharpLspServerConfig !== 'object') { + void vscode.window.showErrorMessage( + vscode.l10n.t('Packaged Copilot LSP config is missing lspServers.csharp.') + ); + return; + } + } catch (error) { + const nodeError = error as NodeJS.ErrnoException; + void vscode.window.showErrorMessage( + vscode.l10n.t('Failed to read packaged Copilot LSP config: {0}', nodeError.message) + ); + return; + } let lspConfig: { lspServers?: { [key: string]: unknown } } = {}; From 45ed73af2921652d949d9500667e8cbcc1afb2b7 Mon Sep 17 00:00:00 2001 From: Phil Allen Date: Wed, 20 May 2026 16:02:16 -0700 Subject: [PATCH 04/20] As part of the update code, check and see if the lsp-config.json file on the user's machine already contains content for the roslyn-language-server. If so, make no changes. --- l10n/bundle.l10n.json | 1 + src/lsptoolshost/commands.ts | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index c1cddd59f..3903dc268 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -144,6 +144,7 @@ "Packaged Copilot LSP config is missing lspServers.csharp.": "Packaged Copilot LSP config is missing lspServers.csharp.", "Failed to read packaged Copilot LSP config: {0}": "Failed to read packaged Copilot LSP config: {0}", "Failed to read Copilot LSP config: {0}": "Failed to read Copilot LSP config: {0}", + "Copilot LSP config already contains roslyn-language-server. No changes were made.": "Copilot LSP config already contains roslyn-language-server. No changes were made.", "Updated Copilot LSP config at {0}.": "Updated Copilot LSP config at {0}.", "Failed to write Copilot LSP config: {0}": "Failed to write Copilot LSP config: {0}", "C# LSP Trace Logs": "C# LSP Trace Logs", diff --git a/src/lsptoolshost/commands.ts b/src/lsptoolshost/commands.ts index c648c62d6..14d323e8f 100644 --- a/src/lsptoolshost/commands.ts +++ b/src/lsptoolshost/commands.ts @@ -140,6 +140,13 @@ function registerExtensionCommands( } } + if (copilotConfigContainsRoslynLanguageServer(lspConfig)) { + void vscode.window.showInformationMessage( + vscode.l10n.t('Copilot LSP config already contains roslyn-language-server. No changes were made.') + ); + return; + } + if (!lspConfig.lspServers || typeof lspConfig.lspServers !== 'object') { lspConfig.lspServers = {}; } @@ -161,3 +168,19 @@ function registerExtensionCommands( ); registerCollectLogsCommand(context, languageServer, outputChannel, csharpTraceChannel, razorLogger); } + +function copilotConfigContainsRoslynLanguageServer(lspConfig: { lspServers?: { [key: string]: unknown } }): boolean { + const lspServers = lspConfig.lspServers; + if (!lspServers || typeof lspServers !== 'object') { + return false; + } + + return Object.values(lspServers).some((serverConfig) => { + if (!serverConfig || typeof serverConfig !== 'object') { + return false; + } + + const args = (serverConfig as { args?: unknown }).args; + return Array.isArray(args) && args.some((arg) => arg === 'roslyn-language-server'); + }); +} From ecda5a5715ec60a9d7cd4cbb00457c2ba9deeee4 Mon Sep 17 00:00:00 2001 From: Phil Allen Date: Wed, 20 May 2026 16:04:57 -0700 Subject: [PATCH 05/20] Now refactor as necessary and add a test so that if the 'install roslyn-language-server for Copilot CLI' runs multiple times, the resulting lsp-config.json has exactly the same contents as the copy of the file shipped with the product initially. --- src/lsptoolshost/commands.ts | 78 ++++++++++++------- .../unitTests/copilotLspConfig.test.ts | 24 ++++++ 2 files changed, 75 insertions(+), 27 deletions(-) create mode 100644 test/lsptoolshost/unitTests/copilotLspConfig.test.ts diff --git a/src/lsptoolshost/commands.ts b/src/lsptoolshost/commands.ts index 14d323e8f..8d958d929 100644 --- a/src/lsptoolshost/commands.ts +++ b/src/lsptoolshost/commands.ts @@ -29,6 +29,10 @@ import { RazorLogger } from '../razor/src/razorLogger'; const configureCopilotLspCommand = 'dotnet.configureCopilotLsp'; const packagedCopilotLspConfigPath = path.join('copilot', 'lsp-config.json'); +interface CopilotLspConfig { + lspServers?: { [key: string]: unknown }; +} + export function registerCommands( context: vscode.ExtensionContext, languageServer: RoslynLanguageServer, @@ -102,21 +106,9 @@ function registerExtensionCommands( vscode.commands.registerCommand(configureCopilotLspCommand, async () => { const lspConfigPath = path.join(os.homedir(), '.copilot', 'lsp-config.json'); const extensionLspConfigPath = path.join(context.extension.extensionPath, packagedCopilotLspConfigPath); - - let csharpLspServerConfig: unknown; + let packagedContent: string; try { - const extensionLspConfigContent = await fs.readFile(extensionLspConfigPath, 'utf8'); - const extensionLspConfig = JSON.parse(extensionLspConfigContent) as { - lspServers?: { [key: string]: unknown }; - }; - csharpLspServerConfig = extensionLspConfig.lspServers?.csharp; - - if (!csharpLspServerConfig || typeof csharpLspServerConfig !== 'object') { - void vscode.window.showErrorMessage( - vscode.l10n.t('Packaged Copilot LSP config is missing lspServers.csharp.') - ); - return; - } + packagedContent = await fs.readFile(extensionLspConfigPath, 'utf8'); } catch (error) { const nodeError = error as NodeJS.ErrnoException; void vscode.window.showErrorMessage( @@ -124,12 +116,10 @@ function registerExtensionCommands( ); return; } - - let lspConfig: { lspServers?: { [key: string]: unknown } } = {}; + let currentContent: string | undefined; try { - const currentContent = await fs.readFile(lspConfigPath, 'utf8'); - lspConfig = JSON.parse(currentContent); + currentContent = await fs.readFile(lspConfigPath, 'utf8'); } catch (error) { const nodeError = error as NodeJS.ErrnoException; if (nodeError.code !== 'ENOENT') { @@ -140,21 +130,24 @@ function registerExtensionCommands( } } - if (copilotConfigContainsRoslynLanguageServer(lspConfig)) { + let updateResult: { shouldWrite: boolean; updatedContent?: string }; + try { + updateResult = getUpdatedCopilotLspConfigContent(currentContent, packagedContent); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + void vscode.window.showErrorMessage(vscode.l10n.t('Failed to update Copilot LSP config: {0}', message)); + return; + } + + if (!updateResult.shouldWrite || !updateResult.updatedContent) { void vscode.window.showInformationMessage( vscode.l10n.t('Copilot LSP config already contains roslyn-language-server. No changes were made.') ); return; } - if (!lspConfig.lspServers || typeof lspConfig.lspServers !== 'object') { - lspConfig.lspServers = {}; - } - - lspConfig.lspServers.csharp = csharpLspServerConfig; - try { - await fs.writeFile(lspConfigPath, `${JSON.stringify(lspConfig, null, 2)}\n`, 'utf8'); + await fs.writeFile(lspConfigPath, updateResult.updatedContent, 'utf8'); void vscode.window.showInformationMessage( vscode.l10n.t('Updated Copilot LSP config at {0}.', lspConfigPath) ); @@ -169,7 +162,38 @@ function registerExtensionCommands( registerCollectLogsCommand(context, languageServer, outputChannel, csharpTraceChannel, razorLogger); } -function copilotConfigContainsRoslynLanguageServer(lspConfig: { lspServers?: { [key: string]: unknown } }): boolean { +export function getUpdatedCopilotLspConfigContent( + currentContent: string | undefined, + packagedContent: string +): { shouldWrite: boolean; updatedContent?: string } { + const packagedConfig = JSON.parse(packagedContent) as CopilotLspConfig; + const packagedCsharpConfig = packagedConfig.lspServers?.csharp; + if (!packagedCsharpConfig || typeof packagedCsharpConfig !== 'object') { + throw new Error('Packaged Copilot LSP config is missing lspServers.csharp.'); + } + + if (currentContent === undefined) { + return { shouldWrite: true, updatedContent: packagedContent }; + } + + const currentConfig = JSON.parse(currentContent) as CopilotLspConfig; + if (copilotConfigContainsRoslynLanguageServer(currentConfig)) { + return { shouldWrite: false }; + } + + const updatedConfig: CopilotLspConfig = { + ...currentConfig, + lspServers: + currentConfig.lspServers && typeof currentConfig.lspServers === 'object' + ? { ...currentConfig.lspServers } + : {}, + }; + + updatedConfig.lspServers!.csharp = packagedCsharpConfig; + return { shouldWrite: true, updatedContent: `${JSON.stringify(updatedConfig, null, 2)}\n` }; +} + +function copilotConfigContainsRoslynLanguageServer(lspConfig: CopilotLspConfig): boolean { const lspServers = lspConfig.lspServers; if (!lspServers || typeof lspServers !== 'object') { return false; diff --git a/test/lsptoolshost/unitTests/copilotLspConfig.test.ts b/test/lsptoolshost/unitTests/copilotLspConfig.test.ts new file mode 100644 index 000000000..3f142f4d0 --- /dev/null +++ b/test/lsptoolshost/unitTests/copilotLspConfig.test.ts @@ -0,0 +1,24 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, test } from '@jest/globals'; +import { readFileSync } from 'fs'; +import { getUpdatedCopilotLspConfigContent } from '../../../src/lsptoolshost/commands'; + +describe('Copilot LSP config installation', () => { + test('is idempotent and preserves shipped config content across multiple runs', () => { + const packagedContent = readFileSync('copilot/lsp-config.json', 'utf8'); + + const firstRun = getUpdatedCopilotLspConfigContent(undefined, packagedContent); + expect(firstRun.shouldWrite).toBe(true); + expect(firstRun.updatedContent).toBe(packagedContent); + + const secondRun = getUpdatedCopilotLspConfigContent(firstRun.updatedContent, packagedContent); + expect(secondRun.shouldWrite).toBe(false); + + const finalContent = secondRun.updatedContent ?? firstRun.updatedContent; + expect(finalContent).toBe(packagedContent); + }); +}); From 6f84fed09f528b66fc778eabeccb1753c04ca7fe Mon Sep 17 00:00:00 2001 From: Phil Allen Date: Wed, 20 May 2026 16:08:29 -0700 Subject: [PATCH 06/20] Fixup: Add tests --- l10n/bundle.l10n.json | 2 +- src/lsptoolshost/commands.ts | 52 +----------------- src/lsptoolshost/copilotLspConfig.ts | 55 +++++++++++++++++++ .../unitTests/copilotLspConfig.test.ts | 2 +- 4 files changed, 58 insertions(+), 53 deletions(-) create mode 100644 src/lsptoolshost/copilotLspConfig.ts diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index 3903dc268..a56c231c8 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -141,9 +141,9 @@ "Text editor must be focused to fix all issues": "Text editor must be focused to fix all issues", "Fix all issues": "Fix all issues", "Select fix all action": "Select fix all action", - "Packaged Copilot LSP config is missing lspServers.csharp.": "Packaged Copilot LSP config is missing lspServers.csharp.", "Failed to read packaged Copilot LSP config: {0}": "Failed to read packaged Copilot LSP config: {0}", "Failed to read Copilot LSP config: {0}": "Failed to read Copilot LSP config: {0}", + "Failed to update Copilot LSP config: {0}": "Failed to update Copilot LSP config: {0}", "Copilot LSP config already contains roslyn-language-server. No changes were made.": "Copilot LSP config already contains roslyn-language-server. No changes were made.", "Updated Copilot LSP config at {0}.": "Updated Copilot LSP config at {0}.", "Failed to write Copilot LSP config: {0}": "Failed to write Copilot LSP config: {0}", diff --git a/src/lsptoolshost/commands.ts b/src/lsptoolshost/commands.ts index 8d958d929..ff94a8054 100644 --- a/src/lsptoolshost/commands.ts +++ b/src/lsptoolshost/commands.ts @@ -25,14 +25,11 @@ import { TelemetryEventNames } from '../shared/telemetryEventNames'; import { registerCollectLogsCommand } from './logging/collectLogs'; import { ObservableLogOutputChannel } from './logging/observableLogOutputChannel'; import { RazorLogger } from '../razor/src/razorLogger'; +import { getUpdatedCopilotLspConfigContent } from './copilotLspConfig'; const configureCopilotLspCommand = 'dotnet.configureCopilotLsp'; const packagedCopilotLspConfigPath = path.join('copilot', 'lsp-config.json'); -interface CopilotLspConfig { - lspServers?: { [key: string]: unknown }; -} - export function registerCommands( context: vscode.ExtensionContext, languageServer: RoslynLanguageServer, @@ -161,50 +158,3 @@ function registerExtensionCommands( ); registerCollectLogsCommand(context, languageServer, outputChannel, csharpTraceChannel, razorLogger); } - -export function getUpdatedCopilotLspConfigContent( - currentContent: string | undefined, - packagedContent: string -): { shouldWrite: boolean; updatedContent?: string } { - const packagedConfig = JSON.parse(packagedContent) as CopilotLspConfig; - const packagedCsharpConfig = packagedConfig.lspServers?.csharp; - if (!packagedCsharpConfig || typeof packagedCsharpConfig !== 'object') { - throw new Error('Packaged Copilot LSP config is missing lspServers.csharp.'); - } - - if (currentContent === undefined) { - return { shouldWrite: true, updatedContent: packagedContent }; - } - - const currentConfig = JSON.parse(currentContent) as CopilotLspConfig; - if (copilotConfigContainsRoslynLanguageServer(currentConfig)) { - return { shouldWrite: false }; - } - - const updatedConfig: CopilotLspConfig = { - ...currentConfig, - lspServers: - currentConfig.lspServers && typeof currentConfig.lspServers === 'object' - ? { ...currentConfig.lspServers } - : {}, - }; - - updatedConfig.lspServers!.csharp = packagedCsharpConfig; - return { shouldWrite: true, updatedContent: `${JSON.stringify(updatedConfig, null, 2)}\n` }; -} - -function copilotConfigContainsRoslynLanguageServer(lspConfig: CopilotLspConfig): boolean { - const lspServers = lspConfig.lspServers; - if (!lspServers || typeof lspServers !== 'object') { - return false; - } - - return Object.values(lspServers).some((serverConfig) => { - if (!serverConfig || typeof serverConfig !== 'object') { - return false; - } - - const args = (serverConfig as { args?: unknown }).args; - return Array.isArray(args) && args.some((arg) => arg === 'roslyn-language-server'); - }); -} diff --git a/src/lsptoolshost/copilotLspConfig.ts b/src/lsptoolshost/copilotLspConfig.ts new file mode 100644 index 000000000..e4613d977 --- /dev/null +++ b/src/lsptoolshost/copilotLspConfig.ts @@ -0,0 +1,55 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export interface CopilotLspConfig { + lspServers?: { [key: string]: unknown }; +} + +export function getUpdatedCopilotLspConfigContent( + currentContent: string | undefined, + packagedContent: string +): { shouldWrite: boolean; updatedContent?: string } { + const packagedConfig = JSON.parse(packagedContent) as CopilotLspConfig; + const packagedCsharpConfig = packagedConfig.lspServers?.csharp; + if (!packagedCsharpConfig || typeof packagedCsharpConfig !== 'object') { + throw new Error('Packaged Copilot LSP config is missing lspServers.csharp.'); + } + + if (currentContent === undefined) { + return { shouldWrite: true, updatedContent: packagedContent }; + } + + const currentConfig = JSON.parse(currentContent) as CopilotLspConfig; + if (copilotConfigContainsRoslynLanguageServer(currentConfig)) { + return { shouldWrite: false }; + } + + const updatedConfig: CopilotLspConfig = { + ...currentConfig, + lspServers: + currentConfig.lspServers && typeof currentConfig.lspServers === 'object' + ? { ...currentConfig.lspServers } + : {}, + }; + + updatedConfig.lspServers!.csharp = packagedCsharpConfig; + return { shouldWrite: true, updatedContent: `${JSON.stringify(updatedConfig, null, 2)}\n` }; +} + +function copilotConfigContainsRoslynLanguageServer(lspConfig: CopilotLspConfig): boolean { + const lspServers = lspConfig.lspServers; + if (!lspServers || typeof lspServers !== 'object') { + return false; + } + + return Object.values(lspServers).some((serverConfig) => { + if (!serverConfig || typeof serverConfig !== 'object') { + return false; + } + + const args = (serverConfig as { args?: unknown }).args; + return Array.isArray(args) && args.some((arg) => arg === 'roslyn-language-server'); + }); +} diff --git a/test/lsptoolshost/unitTests/copilotLspConfig.test.ts b/test/lsptoolshost/unitTests/copilotLspConfig.test.ts index 3f142f4d0..ae14ac789 100644 --- a/test/lsptoolshost/unitTests/copilotLspConfig.test.ts +++ b/test/lsptoolshost/unitTests/copilotLspConfig.test.ts @@ -5,7 +5,7 @@ import { describe, expect, test } from '@jest/globals'; import { readFileSync } from 'fs'; -import { getUpdatedCopilotLspConfigContent } from '../../../src/lsptoolshost/commands'; +import { getUpdatedCopilotLspConfigContent } from '../../../src/lsptoolshost/copilotLspConfig'; describe('Copilot LSP config installation', () => { test('is idempotent and preserves shipped config content across multiple runs', () => { From 4140a061774ec3eac968118ca146df7e01dd76c5 Mon Sep 17 00:00:00 2001 From: Phil Allen Date: Wed, 20 May 2026 16:10:55 -0700 Subject: [PATCH 07/20] Add a command to the npm build system as 'npm run ingest' which will do the same copy that you did earlier of the lsp-config.json from the dotnet/skills repo, but do it again when 'npm run ingest' is specifically run. --- package.json | 1 + tasks/components/ingestCopilotLspConfig.ts | 57 ++++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 tasks/components/ingestCopilotLspConfig.ts diff --git a/package.json b/package.json index 151e60f0d..81ba8e3a9 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "createTags": "npx ts-node tasks/tags/createTags.ts", "fixLocUrls": "npx ts-node tasks/debugger/fixLocUrls.ts", "generateOptionsSchema": "npx ts-node tasks/debugger/generateOptionsSchema.ts", + "ingest": "npx ts-node tasks/components/ingestCopilotLspConfig.ts", "incrementVersion": "npx ts-node tasks/snap/incrementVersion.ts", "installDependencies": "npx ts-node tasks/packaging/installDependencies.ts", "installDependenciesClean": "npx ts-node tasks/packaging/installDependenciesClean.ts", diff --git a/tasks/components/ingestCopilotLspConfig.ts b/tasks/components/ingestCopilotLspConfig.ts new file mode 100644 index 000000000..7c0b2106e --- /dev/null +++ b/tasks/components/ingestCopilotLspConfig.ts @@ -0,0 +1,57 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as fs from 'node:fs/promises'; +import * as https from 'node:https'; +import * as path from 'node:path'; +import { runTask } from '../runTask'; +import { rootPath } from '../projectPaths'; + +const sourceUrl = 'https://raw.githubusercontent.com/dotnet/skills/main/plugins/dotnet/lsp.json'; +const outputPath = path.join(rootPath, 'copilot', 'lsp-config.json'); + +runTask(ingestCopilotLspConfig); + +async function ingestCopilotLspConfig() { + const content = await downloadText(sourceUrl); + await fs.mkdir(path.dirname(outputPath), { recursive: true }); + await fs.writeFile(outputPath, content, 'utf8'); + console.log(`Updated ${outputPath} from ${sourceUrl}`); +} + +async function downloadText(url: string): Promise { + return new Promise((resolve, reject) => { + https + .get(url, (response) => { + if (!response.statusCode) { + reject(new Error(`Failed to download ${url}: missing status code.`)); + return; + } + + if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) { + response.resume(); + void downloadText(response.headers.location).then(resolve, reject); + return; + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + reject(new Error(`Failed to download ${url}: HTTP ${response.statusCode}.`)); + return; + } + + response.setEncoding('utf8'); + let content = ''; + response.on('data', (chunk) => { + content += chunk; + }); + response.on('end', () => { + resolve(content); + }); + }) + .on('error', (error) => { + reject(error); + }); + }); +} From 684a7e6e81293ad16cd3f1a195986245c8ae629c Mon Sep 17 00:00:00 2001 From: Phil Allen Date: Wed, 20 May 2026 16:21:36 -0700 Subject: [PATCH 08/20] Code review the changes that have been committed but are 'ahead' of main. Change the Roslyn detection logic such that if there is a lspServer.csharp object in the read lsp-config.json, that counts as Roslyn detection = true --- src/lsptoolshost/copilotLspConfig.ts | 14 ++++++-------- .../unitTests/copilotLspConfig.test.ts | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/src/lsptoolshost/copilotLspConfig.ts b/src/lsptoolshost/copilotLspConfig.ts index e4613d977..b13d13254 100644 --- a/src/lsptoolshost/copilotLspConfig.ts +++ b/src/lsptoolshost/copilotLspConfig.ts @@ -44,12 +44,10 @@ function copilotConfigContainsRoslynLanguageServer(lspConfig: CopilotLspConfig): return false; } - return Object.values(lspServers).some((serverConfig) => { - if (!serverConfig || typeof serverConfig !== 'object') { - return false; - } - - const args = (serverConfig as { args?: unknown }).args; - return Array.isArray(args) && args.some((arg) => arg === 'roslyn-language-server'); - }); + const csharpServer = lspServers.csharp; + if (csharpServer && typeof csharpServer === 'object') { + return true; + } + + return false; } diff --git a/test/lsptoolshost/unitTests/copilotLspConfig.test.ts b/test/lsptoolshost/unitTests/copilotLspConfig.test.ts index ae14ac789..68a8beb32 100644 --- a/test/lsptoolshost/unitTests/copilotLspConfig.test.ts +++ b/test/lsptoolshost/unitTests/copilotLspConfig.test.ts @@ -21,4 +21,22 @@ describe('Copilot LSP config installation', () => { const finalContent = secondRun.updatedContent ?? firstRun.updatedContent; expect(finalContent).toBe(packagedContent); }); + + test('does not modify config when lspServers.csharp object already exists', () => { + const packagedContent = readFileSync('copilot/lsp-config.json', 'utf8'); + const existingConfig = JSON.stringify( + { + lspServers: { + csharp: { + command: 'some-other-command', + }, + }, + }, + null, + 2 + ); + + const result = getUpdatedCopilotLspConfigContent(existingConfig, packagedContent); + expect(result.shouldWrite).toBe(false); + }); }); From b673308e00b1f4871d0d78d77855460f28c04863 Mon Sep 17 00:00:00 2001 From: Phil Allen Date: Wed, 20 May 2026 16:24:07 -0700 Subject: [PATCH 09/20] Change the name of the folder that contains lsp-config.json in this repo and in the eventual extension to be 'redist'. Update all references. --- {copilot => redist}/lsp-config.json | 0 src/lsptoolshost/commands.ts | 2 +- tasks/components/ingestCopilotLspConfig.ts | 2 +- test/lsptoolshost/unitTests/copilotLspConfig.test.ts | 4 ++-- 4 files changed, 4 insertions(+), 4 deletions(-) rename {copilot => redist}/lsp-config.json (100%) diff --git a/copilot/lsp-config.json b/redist/lsp-config.json similarity index 100% rename from copilot/lsp-config.json rename to redist/lsp-config.json diff --git a/src/lsptoolshost/commands.ts b/src/lsptoolshost/commands.ts index ff94a8054..e710790b5 100644 --- a/src/lsptoolshost/commands.ts +++ b/src/lsptoolshost/commands.ts @@ -28,7 +28,7 @@ import { RazorLogger } from '../razor/src/razorLogger'; import { getUpdatedCopilotLspConfigContent } from './copilotLspConfig'; const configureCopilotLspCommand = 'dotnet.configureCopilotLsp'; -const packagedCopilotLspConfigPath = path.join('copilot', 'lsp-config.json'); +const packagedCopilotLspConfigPath = path.join('redist', 'lsp-config.json'); export function registerCommands( context: vscode.ExtensionContext, diff --git a/tasks/components/ingestCopilotLspConfig.ts b/tasks/components/ingestCopilotLspConfig.ts index 7c0b2106e..8260885a7 100644 --- a/tasks/components/ingestCopilotLspConfig.ts +++ b/tasks/components/ingestCopilotLspConfig.ts @@ -10,7 +10,7 @@ import { runTask } from '../runTask'; import { rootPath } from '../projectPaths'; const sourceUrl = 'https://raw.githubusercontent.com/dotnet/skills/main/plugins/dotnet/lsp.json'; -const outputPath = path.join(rootPath, 'copilot', 'lsp-config.json'); +const outputPath = path.join(rootPath, 'redist', 'lsp-config.json'); runTask(ingestCopilotLspConfig); diff --git a/test/lsptoolshost/unitTests/copilotLspConfig.test.ts b/test/lsptoolshost/unitTests/copilotLspConfig.test.ts index 68a8beb32..a5eae048b 100644 --- a/test/lsptoolshost/unitTests/copilotLspConfig.test.ts +++ b/test/lsptoolshost/unitTests/copilotLspConfig.test.ts @@ -9,7 +9,7 @@ import { getUpdatedCopilotLspConfigContent } from '../../../src/lsptoolshost/cop describe('Copilot LSP config installation', () => { test('is idempotent and preserves shipped config content across multiple runs', () => { - const packagedContent = readFileSync('copilot/lsp-config.json', 'utf8'); + const packagedContent = readFileSync('redist/lsp-config.json', 'utf8'); const firstRun = getUpdatedCopilotLspConfigContent(undefined, packagedContent); expect(firstRun.shouldWrite).toBe(true); @@ -23,7 +23,7 @@ describe('Copilot LSP config installation', () => { }); test('does not modify config when lspServers.csharp object already exists', () => { - const packagedContent = readFileSync('copilot/lsp-config.json', 'utf8'); + const packagedContent = readFileSync('redist/lsp-config.json', 'utf8'); const existingConfig = JSON.stringify( { lspServers: { From db67bfba12f1c48da40c42ee8094fa10c79eb86d Mon Sep 17 00:00:00 2001 From: Phil Allen Date: Wed, 20 May 2026 16:28:25 -0700 Subject: [PATCH 10/20] Add a new command to uninstall the CSharp LSP for Copilot CLI use. It should find the lsp-config.json in the customer's HOME/.copilot directory; if it exists it should parse the file, and if the "lspServers.csharp" element exists, remove the "lspServers.csharp" element and leave all others. --- l10n/bundle.l10n.json | 3 ++ package.json | 6 +++ package.nls.json | 1 + src/lsptoolshost/commands.ts | 51 ++++++++++++++++++- src/lsptoolshost/copilotLspConfig.ts | 28 ++++++++++ .../unitTests/copilotLspConfig.test.ts | 46 ++++++++++++++++- 6 files changed, 133 insertions(+), 2 deletions(-) diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index a56c231c8..f97d91cd3 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -147,6 +147,9 @@ "Copilot LSP config already contains roslyn-language-server. No changes were made.": "Copilot LSP config already contains roslyn-language-server. No changes were made.", "Updated Copilot LSP config at {0}.": "Updated Copilot LSP config at {0}.", "Failed to write Copilot LSP config: {0}": "Failed to write Copilot LSP config: {0}", + "Failed to uninstall Copilot LSP config: {0}": "Failed to uninstall Copilot LSP config: {0}", + "Copilot LSP config does not contain lspServers.csharp. No changes were made.": "Copilot LSP config does not contain lspServers.csharp. No changes were made.", + "Removed lspServers.csharp from Copilot LSP config at {0}.": "Removed lspServers.csharp from Copilot LSP config at {0}.", "C# LSP Trace Logs": "C# LSP Trace Logs", "Open solution": "Open solution", "Restart server": "Restart server", diff --git a/package.json b/package.json index 81ba8e3a9..5045c3193 100644 --- a/package.json +++ b/package.json @@ -1898,6 +1898,12 @@ "category": ".NET", "enablement": "isWorkspaceTrusted" }, + { + "command": "dotnet.uninstallCopilotLsp", + "title": "%command.dotnet.uninstallCopilotLsp%", + "category": ".NET", + "enablement": "isWorkspaceTrusted" + }, { "command": "o.fixAll.solution", "title": "%command.o.fixAll.solution%", diff --git a/package.nls.json b/package.nls.json index 156ca0fe3..bb32a63c9 100644 --- a/package.nls.json +++ b/package.nls.json @@ -3,6 +3,7 @@ "command.o.pickProjectAndStart": "Select Project", "command.dotnet.openSolution": "Open Solution", "command.dotnet.configureCopilotLsp": "Configure Copilot CLI C# LSP", + "command.dotnet.uninstallCopilotLsp": "Uninstall Copilot CLI C# LSP", "command.o.fixAll.solution": "Fix all occurrences of a code issue within solution", "command.o.fixAll.project": "Fix all occurrences of a code issue within project", "command.o.fixAll.document": "Fix all occurrences of a code issue within document", diff --git a/src/lsptoolshost/commands.ts b/src/lsptoolshost/commands.ts index e710790b5..528e901e2 100644 --- a/src/lsptoolshost/commands.ts +++ b/src/lsptoolshost/commands.ts @@ -25,9 +25,10 @@ import { TelemetryEventNames } from '../shared/telemetryEventNames'; import { registerCollectLogsCommand } from './logging/collectLogs'; import { ObservableLogOutputChannel } from './logging/observableLogOutputChannel'; import { RazorLogger } from '../razor/src/razorLogger'; -import { getUpdatedCopilotLspConfigContent } from './copilotLspConfig'; +import { getUninstalledCopilotLspConfigContent, getUpdatedCopilotLspConfigContent } from './copilotLspConfig'; const configureCopilotLspCommand = 'dotnet.configureCopilotLsp'; +const uninstallCopilotLspCommand = 'dotnet.uninstallCopilotLsp'; const packagedCopilotLspConfigPath = path.join('redist', 'lsp-config.json'); export function registerCommands( @@ -156,5 +157,53 @@ function registerExtensionCommands( } }) ); + context.subscriptions.push( + vscode.commands.registerCommand(uninstallCopilotLspCommand, async () => { + const lspConfigPath = path.join(os.homedir(), '.copilot', 'lsp-config.json'); + + let currentContent: string | undefined; + try { + currentContent = await fs.readFile(lspConfigPath, 'utf8'); + } catch (error) { + const nodeError = error as NodeJS.ErrnoException; + if (nodeError.code !== 'ENOENT') { + void vscode.window.showErrorMessage( + vscode.l10n.t('Failed to read Copilot LSP config: {0}', nodeError.message) + ); + return; + } + } + + let uninstallResult: { shouldWrite: boolean; updatedContent?: string }; + try { + uninstallResult = getUninstalledCopilotLspConfigContent(currentContent); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + void vscode.window.showErrorMessage( + vscode.l10n.t('Failed to uninstall Copilot LSP config: {0}', message) + ); + return; + } + + if (!uninstallResult.shouldWrite || !uninstallResult.updatedContent) { + void vscode.window.showInformationMessage( + vscode.l10n.t('Copilot LSP config does not contain lspServers.csharp. No changes were made.') + ); + return; + } + + try { + await fs.writeFile(lspConfigPath, uninstallResult.updatedContent, 'utf8'); + void vscode.window.showInformationMessage( + vscode.l10n.t('Removed lspServers.csharp from Copilot LSP config at {0}.', lspConfigPath) + ); + } catch (error) { + const nodeError = error as NodeJS.ErrnoException; + void vscode.window.showErrorMessage( + vscode.l10n.t('Failed to write Copilot LSP config: {0}', nodeError.message) + ); + } + }) + ); registerCollectLogsCommand(context, languageServer, outputChannel, csharpTraceChannel, razorLogger); } diff --git a/src/lsptoolshost/copilotLspConfig.ts b/src/lsptoolshost/copilotLspConfig.ts index b13d13254..84ff59b57 100644 --- a/src/lsptoolshost/copilotLspConfig.ts +++ b/src/lsptoolshost/copilotLspConfig.ts @@ -38,6 +38,34 @@ export function getUpdatedCopilotLspConfigContent( return { shouldWrite: true, updatedContent: `${JSON.stringify(updatedConfig, null, 2)}\n` }; } +export function getUninstalledCopilotLspConfigContent(currentContent: string | undefined): { + shouldWrite: boolean; + updatedContent?: string; +} { + if (currentContent === undefined) { + return { shouldWrite: false }; + } + + const currentConfig = JSON.parse(currentContent) as CopilotLspConfig; + const lspServers = currentConfig.lspServers; + if (!lspServers || typeof lspServers !== 'object') { + return { shouldWrite: false }; + } + + const csharpServer = lspServers.csharp; + if (!csharpServer || typeof csharpServer !== 'object') { + return { shouldWrite: false }; + } + + const updatedConfig: CopilotLspConfig = { + ...currentConfig, + lspServers: { ...lspServers }, + }; + delete updatedConfig.lspServers!.csharp; + + return { shouldWrite: true, updatedContent: `${JSON.stringify(updatedConfig, null, 2)}\n` }; +} + function copilotConfigContainsRoslynLanguageServer(lspConfig: CopilotLspConfig): boolean { const lspServers = lspConfig.lspServers; if (!lspServers || typeof lspServers !== 'object') { diff --git a/test/lsptoolshost/unitTests/copilotLspConfig.test.ts b/test/lsptoolshost/unitTests/copilotLspConfig.test.ts index a5eae048b..dc111f02c 100644 --- a/test/lsptoolshost/unitTests/copilotLspConfig.test.ts +++ b/test/lsptoolshost/unitTests/copilotLspConfig.test.ts @@ -5,7 +5,10 @@ import { describe, expect, test } from '@jest/globals'; import { readFileSync } from 'fs'; -import { getUpdatedCopilotLspConfigContent } from '../../../src/lsptoolshost/copilotLspConfig'; +import { + getUninstalledCopilotLspConfigContent, + getUpdatedCopilotLspConfigContent, +} from '../../../src/lsptoolshost/copilotLspConfig'; describe('Copilot LSP config installation', () => { test('is idempotent and preserves shipped config content across multiple runs', () => { @@ -39,4 +42,45 @@ describe('Copilot LSP config installation', () => { const result = getUpdatedCopilotLspConfigContent(existingConfig, packagedContent); expect(result.shouldWrite).toBe(false); }); + + test('uninstall removes lspServers.csharp and preserves other servers', () => { + const existingConfig = JSON.stringify( + { + lspServers: { + csharp: { + command: 'dotnet', + }, + typescript: { + command: 'typescript-language-server', + args: ['--stdio'], + }, + }, + }, + null, + 2 + ); + + const result = getUninstalledCopilotLspConfigContent(existingConfig); + expect(result.shouldWrite).toBe(true); + const parsed = JSON.parse(result.updatedContent!); + expect(parsed.lspServers.csharp).toBeUndefined(); + expect(parsed.lspServers.typescript).toBeDefined(); + }); + + test('uninstall is a no-op when lspServers.csharp is absent', () => { + const existingConfig = JSON.stringify( + { + lspServers: { + typescript: { + command: 'typescript-language-server', + }, + }, + }, + null, + 2 + ); + + const result = getUninstalledCopilotLspConfigContent(existingConfig); + expect(result.shouldWrite).toBe(false); + }); }); From 2e434ea2fe8737f48ffd326ceb24a2da0a1ee4f6 Mon Sep 17 00:00:00 2001 From: Phil Allen Date: Wed, 20 May 2026 16:32:44 -0700 Subject: [PATCH 11/20] Rename the configure command to be 'install' so that it mirrors the naming for the uninstall command --- package.json | 4 ++-- package.nls.json | 2 +- src/lsptoolshost/commands.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 5045c3193..832ac6bfc 100644 --- a/package.json +++ b/package.json @@ -1893,8 +1893,8 @@ "enablement": "isWorkspaceTrusted && dotnet.server.activationContext == 'Roslyn'" }, { - "command": "dotnet.configureCopilotLsp", - "title": "%command.dotnet.configureCopilotLsp%", + "command": "dotnet.installCopilotLsp", + "title": "%command.dotnet.installCopilotLsp%", "category": ".NET", "enablement": "isWorkspaceTrusted" }, diff --git a/package.nls.json b/package.nls.json index bb32a63c9..c79dedfd3 100644 --- a/package.nls.json +++ b/package.nls.json @@ -2,7 +2,7 @@ "command.o.restart": "Restart OmniSharp", "command.o.pickProjectAndStart": "Select Project", "command.dotnet.openSolution": "Open Solution", - "command.dotnet.configureCopilotLsp": "Configure Copilot CLI C# LSP", + "command.dotnet.installCopilotLsp": "Install Copilot CLI C# LSP", "command.dotnet.uninstallCopilotLsp": "Uninstall Copilot CLI C# LSP", "command.o.fixAll.solution": "Fix all occurrences of a code issue within solution", "command.o.fixAll.project": "Fix all occurrences of a code issue within project", diff --git a/src/lsptoolshost/commands.ts b/src/lsptoolshost/commands.ts index 528e901e2..0d892c05a 100644 --- a/src/lsptoolshost/commands.ts +++ b/src/lsptoolshost/commands.ts @@ -27,7 +27,7 @@ import { ObservableLogOutputChannel } from './logging/observableLogOutputChannel import { RazorLogger } from '../razor/src/razorLogger'; import { getUninstalledCopilotLspConfigContent, getUpdatedCopilotLspConfigContent } from './copilotLspConfig'; -const configureCopilotLspCommand = 'dotnet.configureCopilotLsp'; +const installCopilotLspCommand = 'dotnet.installCopilotLsp'; const uninstallCopilotLspCommand = 'dotnet.uninstallCopilotLsp'; const packagedCopilotLspConfigPath = path.join('redist', 'lsp-config.json'); @@ -101,7 +101,7 @@ function registerExtensionCommands( vscode.commands.registerCommand('csharp.showOutputWindow', async () => outputChannel.show()) ); context.subscriptions.push( - vscode.commands.registerCommand(configureCopilotLspCommand, async () => { + vscode.commands.registerCommand(installCopilotLspCommand, async () => { const lspConfigPath = path.join(os.homedir(), '.copilot', 'lsp-config.json'); const extensionLspConfigPath = path.join(context.extension.extensionPath, packagedCopilotLspConfigPath); let packagedContent: string; From 0efb779e678a446201379a330f3764dfe0f5bb64 Mon Sep 17 00:00:00 2001 From: Phil Allen Date: Thu, 21 May 2026 08:24:22 -0700 Subject: [PATCH 12/20] Change how to determine if the lsp is installed. Instead of looking for an object named 'csharp', look for an object that lists ".cs" in the "fileExtensions" collection. Use lsp-config.json in the redist folder as a guide. --- src/lsptoolshost/copilotLspConfig.ts | 16 ++++++++-- .../unitTests/copilotLspConfig.test.ts | 30 +++++++++++++++++-- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/src/lsptoolshost/copilotLspConfig.ts b/src/lsptoolshost/copilotLspConfig.ts index 84ff59b57..47cee728f 100644 --- a/src/lsptoolshost/copilotLspConfig.ts +++ b/src/lsptoolshost/copilotLspConfig.ts @@ -72,9 +72,19 @@ function copilotConfigContainsRoslynLanguageServer(lspConfig: CopilotLspConfig): return false; } - const csharpServer = lspServers.csharp; - if (csharpServer && typeof csharpServer === 'object') { - return true; + for (const server of Object.values(lspServers)) { + if (!server || typeof server !== 'object') { + continue; + } + + const fileExtensions = (server as { fileExtensions?: unknown }).fileExtensions; + if (!fileExtensions) { + continue; + } + + if (typeof fileExtensions === 'object' && '.cs' in fileExtensions) { + return true; + } } return false; diff --git a/test/lsptoolshost/unitTests/copilotLspConfig.test.ts b/test/lsptoolshost/unitTests/copilotLspConfig.test.ts index dc111f02c..606a6ea92 100644 --- a/test/lsptoolshost/unitTests/copilotLspConfig.test.ts +++ b/test/lsptoolshost/unitTests/copilotLspConfig.test.ts @@ -25,13 +25,16 @@ describe('Copilot LSP config installation', () => { expect(finalContent).toBe(packagedContent); }); - test('does not modify config when lspServers.csharp object already exists', () => { + test('does not modify config when any server maps .cs in fileExtensions', () => { const packagedContent = readFileSync('redist/lsp-config.json', 'utf8'); const existingConfig = JSON.stringify( { lspServers: { - csharp: { + customServerName: { command: 'some-other-command', + fileExtensions: { + '.cs': 'csharp', + }, }, }, }, @@ -43,6 +46,29 @@ describe('Copilot LSP config installation', () => { expect(result.shouldWrite).toBe(false); }); + test('adds csharp server when .cs is not present in fileExtensions', () => { + const packagedContent = readFileSync('redist/lsp-config.json', 'utf8'); + const existingConfig = JSON.stringify( + { + lspServers: { + typescript: { + command: 'typescript-language-server', + fileExtensions: { + '.ts': 'typescript', + }, + }, + }, + }, + null, + 2 + ); + + const result = getUpdatedCopilotLspConfigContent(existingConfig, packagedContent); + expect(result.shouldWrite).toBe(true); + const parsed = JSON.parse(result.updatedContent!); + expect(parsed.lspServers.csharp).toBeDefined(); + }); + test('uninstall removes lspServers.csharp and preserves other servers', () => { const existingConfig = JSON.stringify( { From 60b0ab3f33f0766123606d991ceea801526d6ecc Mon Sep 17 00:00:00 2001 From: Phil Allen Date: Thu, 21 May 2026 08:26:54 -0700 Subject: [PATCH 13/20] Change the method #sym:getUninstalledCopilotLspConfigContent to also search for content to delete by looking for an lspServer object that has a "fileExtensions" with ".cs" --- src/lsptoolshost/copilotLspConfig.ts | 35 +++++++++++++++---- .../unitTests/copilotLspConfig.test.ts | 29 +++++++++++++++ 2 files changed, 58 insertions(+), 6 deletions(-) diff --git a/src/lsptoolshost/copilotLspConfig.ts b/src/lsptoolshost/copilotLspConfig.ts index 47cee728f..a50d1bbae 100644 --- a/src/lsptoolshost/copilotLspConfig.ts +++ b/src/lsptoolshost/copilotLspConfig.ts @@ -52,16 +52,26 @@ export function getUninstalledCopilotLspConfigContent(currentContent: string | u return { shouldWrite: false }; } - const csharpServer = lspServers.csharp; - if (!csharpServer || typeof csharpServer !== 'object') { - return { shouldWrite: false }; - } - const updatedConfig: CopilotLspConfig = { ...currentConfig, lspServers: { ...lspServers }, }; - delete updatedConfig.lspServers!.csharp; + + let removedServer = false; + for (const [serverName, server] of Object.entries(lspServers)) { + if (!server || typeof server !== 'object') { + continue; + } + + if (serverName === 'csharp' || serverContainsCSharpFileExtension(server)) { + delete updatedConfig.lspServers![serverName]; + removedServer = true; + } + } + + if (!removedServer) { + return { shouldWrite: false }; + } return { shouldWrite: true, updatedContent: `${JSON.stringify(updatedConfig, null, 2)}\n` }; } @@ -89,3 +99,16 @@ function copilotConfigContainsRoslynLanguageServer(lspConfig: CopilotLspConfig): return false; } + +function serverContainsCSharpFileExtension(server: object): boolean { + const fileExtensions = (server as { fileExtensions?: unknown }).fileExtensions; + if (!fileExtensions) { + return false; + } + + if (Array.isArray(fileExtensions)) { + return fileExtensions.includes('.cs'); + } + + return typeof fileExtensions === 'object' && '.cs' in fileExtensions; +} diff --git a/test/lsptoolshost/unitTests/copilotLspConfig.test.ts b/test/lsptoolshost/unitTests/copilotLspConfig.test.ts index 606a6ea92..f80688484 100644 --- a/test/lsptoolshost/unitTests/copilotLspConfig.test.ts +++ b/test/lsptoolshost/unitTests/copilotLspConfig.test.ts @@ -93,6 +93,35 @@ describe('Copilot LSP config installation', () => { expect(parsed.lspServers.typescript).toBeDefined(); }); + test('uninstall removes custom server when it maps .cs in fileExtensions', () => { + const existingConfig = JSON.stringify( + { + lspServers: { + customCsharpServer: { + command: 'dotnet', + fileExtensions: { + '.cs': 'csharp', + }, + }, + typescript: { + command: 'typescript-language-server', + fileExtensions: { + '.ts': 'typescript', + }, + }, + }, + }, + null, + 2 + ); + + const result = getUninstalledCopilotLspConfigContent(existingConfig); + expect(result.shouldWrite).toBe(true); + const parsed = JSON.parse(result.updatedContent!); + expect(parsed.lspServers.customCsharpServer).toBeUndefined(); + expect(parsed.lspServers.typescript).toBeDefined(); + }); + test('uninstall is a no-op when lspServers.csharp is absent', () => { const existingConfig = JSON.stringify( { From b92d796b1b879c504daa81394c74634e2ed49894 Mon Sep 17 00:00:00 2001 From: Phil Allen Date: Thu, 21 May 2026 08:31:26 -0700 Subject: [PATCH 14/20] Factor together both loops that look for an existing lspServer content into one method and use from both existing implementation sites. Change how the callers of getCSharpServerNames works so that 'contains Roslyn Language Server' need only find one instance before eventually returns true. --- src/lsptoolshost/copilotLspConfig.ts | 44 +++++++++++++++------------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/src/lsptoolshost/copilotLspConfig.ts b/src/lsptoolshost/copilotLspConfig.ts index a50d1bbae..0deaf30b7 100644 --- a/src/lsptoolshost/copilotLspConfig.ts +++ b/src/lsptoolshost/copilotLspConfig.ts @@ -57,19 +57,12 @@ export function getUninstalledCopilotLspConfigContent(currentContent: string | u lspServers: { ...lspServers }, }; - let removedServer = false; - for (const [serverName, server] of Object.entries(lspServers)) { - if (!server || typeof server !== 'object') { - continue; - } - - if (serverName === 'csharp' || serverContainsCSharpFileExtension(server)) { - delete updatedConfig.lspServers![serverName]; - removedServer = true; - } + const matchingServerNames = getCSharpServerNames(lspServers); + for (const serverName of matchingServerNames) { + delete updatedConfig.lspServers![serverName]; } - if (!removedServer) { + if (matchingServerNames.length === 0) { return { shouldWrite: false }; } @@ -82,17 +75,24 @@ function copilotConfigContainsRoslynLanguageServer(lspConfig: CopilotLspConfig): return false; } - for (const server of Object.values(lspServers)) { - if (!server || typeof server !== 'object') { - continue; - } + return containsCSharpServer(lspServers); +} - const fileExtensions = (server as { fileExtensions?: unknown }).fileExtensions; - if (!fileExtensions) { - continue; +function getCSharpServerNames(lspServers: { [key: string]: unknown }): string[] { + const csharpServerNames: string[] = []; + + for (const [serverName, server] of Object.entries(lspServers)) { + if (serverName === 'csharp' || serverContainsCSharpFileExtension(server)) { + csharpServerNames.push(serverName); } + } - if (typeof fileExtensions === 'object' && '.cs' in fileExtensions) { + return csharpServerNames; +} + +function containsCSharpServer(lspServers: { [key: string]: unknown }): boolean { + for (const [serverName, server] of Object.entries(lspServers)) { + if (serverName === 'csharp' || serverContainsCSharpFileExtension(server)) { return true; } } @@ -100,7 +100,11 @@ function copilotConfigContainsRoslynLanguageServer(lspConfig: CopilotLspConfig): return false; } -function serverContainsCSharpFileExtension(server: object): boolean { +function serverContainsCSharpFileExtension(server: unknown): boolean { + if (!server || typeof server !== 'object') { + return false; + } + const fileExtensions = (server as { fileExtensions?: unknown }).fileExtensions; if (!fileExtensions) { return false; From 426c84a85603a2a1cc8b847cda1b7d5e3325c019 Mon Sep 17 00:00:00 2001 From: Phil Allen Date: Thu, 21 May 2026 08:33:56 -0700 Subject: [PATCH 15/20] Change all of the methods that are new in this branch as compared to origin/main such that if they contained #sym:RoslynLanguageServer , they instead contain CSharpLsp --- src/lsptoolshost/copilotLspConfig.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lsptoolshost/copilotLspConfig.ts b/src/lsptoolshost/copilotLspConfig.ts index 0deaf30b7..b63ceae92 100644 --- a/src/lsptoolshost/copilotLspConfig.ts +++ b/src/lsptoolshost/copilotLspConfig.ts @@ -22,7 +22,7 @@ export function getUpdatedCopilotLspConfigContent( } const currentConfig = JSON.parse(currentContent) as CopilotLspConfig; - if (copilotConfigContainsRoslynLanguageServer(currentConfig)) { + if (copilotConfigContainsCSharpLsp(currentConfig)) { return { shouldWrite: false }; } @@ -69,7 +69,7 @@ export function getUninstalledCopilotLspConfigContent(currentContent: string | u return { shouldWrite: true, updatedContent: `${JSON.stringify(updatedConfig, null, 2)}\n` }; } -function copilotConfigContainsRoslynLanguageServer(lspConfig: CopilotLspConfig): boolean { +function copilotConfigContainsCSharpLsp(lspConfig: CopilotLspConfig): boolean { const lspServers = lspConfig.lspServers; if (!lspServers || typeof lspServers !== 'object') { return false; From 6890590cd7a1c249d4f1cf46b1cfcb2f91596b9b Mon Sep 17 00:00:00 2001 From: Phil Allen Date: Thu, 21 May 2026 13:14:14 -0700 Subject: [PATCH 16/20] Code review all code on the branch as compared to origin/main Fix element 1 in the above list. (create .copilot file if necessary) --- src/lsptoolshost/commands.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lsptoolshost/commands.ts b/src/lsptoolshost/commands.ts index 0d892c05a..d7a3b1c22 100644 --- a/src/lsptoolshost/commands.ts +++ b/src/lsptoolshost/commands.ts @@ -145,6 +145,7 @@ function registerExtensionCommands( } try { + await fs.mkdir(path.dirname(lspConfigPath), { recursive: true }); await fs.writeFile(lspConfigPath, updateResult.updatedContent, 'utf8'); void vscode.window.showInformationMessage( vscode.l10n.t('Updated Copilot LSP config at {0}.', lspConfigPath) From 878d50d2107505129f5640daf594a37a3e721cf0 Mon Sep 17 00:00:00 2001 From: Phil Allen Date: Thu, 21 May 2026 13:16:12 -0700 Subject: [PATCH 17/20] Remove "%configuration.dotnet.experiments.targetPopulation...% keys," from the package.json file Now remove the "dotnet.experiments.targetPopulation" property from package.json --- package.json | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/package.json b/package.json index 832ac6bfc..6eae076ee 100644 --- a/package.json +++ b/package.json @@ -752,26 +752,6 @@ "type": "string", "description": "%configuration.dotnet.defaultSolution.description%", "order": 0 - }, - "dotnet.experiments.targetPopulation": { - "type": "string", - "enum": [ - "auto", - "team", - "internal", - "insiders", - "public" - ], - "default": "auto", - "enumDescriptions": [ - "%configuration.dotnet.experiments.targetPopulation.auto%", - "%configuration.dotnet.experiments.targetPopulation.team%", - "%configuration.dotnet.experiments.targetPopulation.internal%", - "%configuration.dotnet.experiments.targetPopulation.insiders%", - "%configuration.dotnet.experiments.targetPopulation.public%" - ], - "description": "%configuration.dotnet.experiments.targetPopulation%", - "order": 1 } } }, From e1b403e12fc297e0c7f919438e1c1baa3b5b67ad Mon Sep 17 00:00:00 2001 From: Phil Allen Date: Thu, 21 May 2026 13:19:08 -0700 Subject: [PATCH 18/20] Fix 4 from the list above Update message text to reflect current behavior --- src/lsptoolshost/commands.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/lsptoolshost/commands.ts b/src/lsptoolshost/commands.ts index d7a3b1c22..37d550154 100644 --- a/src/lsptoolshost/commands.ts +++ b/src/lsptoolshost/commands.ts @@ -139,7 +139,9 @@ function registerExtensionCommands( if (!updateResult.shouldWrite || !updateResult.updatedContent) { void vscode.window.showInformationMessage( - vscode.l10n.t('Copilot LSP config already contains roslyn-language-server. No changes were made.') + vscode.l10n.t( + 'Copilot LSP config already contains a C# LSP mapping for .cs files. No changes were made.' + ) ); return; } @@ -188,7 +190,9 @@ function registerExtensionCommands( if (!uninstallResult.shouldWrite || !uninstallResult.updatedContent) { void vscode.window.showInformationMessage( - vscode.l10n.t('Copilot LSP config does not contain lspServers.csharp. No changes were made.') + vscode.l10n.t( + 'Copilot LSP config does not contain a C# LSP mapping for .cs files. No changes were made.' + ) ); return; } @@ -196,7 +200,10 @@ function registerExtensionCommands( try { await fs.writeFile(lspConfigPath, uninstallResult.updatedContent, 'utf8'); void vscode.window.showInformationMessage( - vscode.l10n.t('Removed lspServers.csharp from Copilot LSP config at {0}.', lspConfigPath) + vscode.l10n.t( + 'Removed C# LSP mapping(s) for .cs files from Copilot LSP config at {0}.', + lspConfigPath + ) ); } catch (error) { const nodeError = error as NodeJS.ErrnoException; From 849edb5551bef74494ff65d317bff1b747ee297e Mon Sep 17 00:00:00 2001 From: Phil Allen Date: Thu, 21 May 2026 13:20:18 -0700 Subject: [PATCH 19/20] Change the ingest code so that if the lspServers.csharp element does not exist, it reports an error --- tasks/components/ingestCopilotLspConfig.ts | 24 ++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tasks/components/ingestCopilotLspConfig.ts b/tasks/components/ingestCopilotLspConfig.ts index 8260885a7..953248045 100644 --- a/tasks/components/ingestCopilotLspConfig.ts +++ b/tasks/components/ingestCopilotLspConfig.ts @@ -16,11 +16,35 @@ runTask(ingestCopilotLspConfig); async function ingestCopilotLspConfig() { const content = await downloadText(sourceUrl); + validateLspConfig(content, sourceUrl); await fs.mkdir(path.dirname(outputPath), { recursive: true }); await fs.writeFile(outputPath, content, 'utf8'); console.log(`Updated ${outputPath} from ${sourceUrl}`); } +function validateLspConfig(content: string, source: string): void { + let parsed: unknown; + try { + parsed = JSON.parse(content) as unknown; + } catch { + throw new Error(`Failed to parse JSON from ${source}.`); + } + + if (!parsed || typeof parsed !== 'object') { + throw new Error(`Downloaded config from ${source} is not a JSON object.`); + } + + const lspServers = (parsed as { lspServers?: unknown }).lspServers; + if (!lspServers || typeof lspServers !== 'object') { + throw new Error(`Downloaded config from ${source} is missing lspServers.`); + } + + const csharp = (lspServers as { csharp?: unknown }).csharp; + if (!csharp || typeof csharp !== 'object') { + throw new Error(`Downloaded config from ${source} is missing lspServers.csharp.`); + } +} + async function downloadText(url: string): Promise { return new Promise((resolve, reject) => { https From a12608ba52e95627a576b255f1e2f698c65f872d Mon Sep 17 00:00:00 2001 From: Phil Allen Date: Fri, 22 May 2026 09:18:19 -0700 Subject: [PATCH 20/20] Local Code Review feedback taken --- l10n/bundle.l10n.json | 6 +++--- src/lsptoolshost/copilotLspConfig.ts | 2 +- test/lsptoolshost/unitTests/copilotLspConfig.test.ts | 5 ++++- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index f97d91cd3..f4eda87bb 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -144,12 +144,12 @@ "Failed to read packaged Copilot LSP config: {0}": "Failed to read packaged Copilot LSP config: {0}", "Failed to read Copilot LSP config: {0}": "Failed to read Copilot LSP config: {0}", "Failed to update Copilot LSP config: {0}": "Failed to update Copilot LSP config: {0}", - "Copilot LSP config already contains roslyn-language-server. No changes were made.": "Copilot LSP config already contains roslyn-language-server. No changes were made.", + "Copilot LSP config already contains a C# LSP mapping for .cs files. No changes were made.": "Copilot LSP config already contains a C# LSP mapping for .cs files. No changes were made.", "Updated Copilot LSP config at {0}.": "Updated Copilot LSP config at {0}.", "Failed to write Copilot LSP config: {0}": "Failed to write Copilot LSP config: {0}", "Failed to uninstall Copilot LSP config: {0}": "Failed to uninstall Copilot LSP config: {0}", - "Copilot LSP config does not contain lspServers.csharp. No changes were made.": "Copilot LSP config does not contain lspServers.csharp. No changes were made.", - "Removed lspServers.csharp from Copilot LSP config at {0}.": "Removed lspServers.csharp from Copilot LSP config at {0}.", + "Copilot LSP config does not contain a C# LSP mapping for .cs files. No changes were made.": "Copilot LSP config does not contain a C# LSP mapping for .cs files. No changes were made.", + "Removed C# LSP mapping(s) for .cs files from Copilot LSP config at {0}.": "Removed C# LSP mapping(s) for .cs files from Copilot LSP config at {0}.", "C# LSP Trace Logs": "C# LSP Trace Logs", "Open solution": "Open solution", "Restart server": "Restart server", diff --git a/src/lsptoolshost/copilotLspConfig.ts b/src/lsptoolshost/copilotLspConfig.ts index b63ceae92..58c9d6526 100644 --- a/src/lsptoolshost/copilotLspConfig.ts +++ b/src/lsptoolshost/copilotLspConfig.ts @@ -82,7 +82,7 @@ function getCSharpServerNames(lspServers: { [key: string]: unknown }): string[] const csharpServerNames: string[] = []; for (const [serverName, server] of Object.entries(lspServers)) { - if (serverName === 'csharp' || serverContainsCSharpFileExtension(server)) { + if (serverContainsCSharpFileExtension(server)) { csharpServerNames.push(serverName); } } diff --git a/test/lsptoolshost/unitTests/copilotLspConfig.test.ts b/test/lsptoolshost/unitTests/copilotLspConfig.test.ts index f80688484..83ecff444 100644 --- a/test/lsptoolshost/unitTests/copilotLspConfig.test.ts +++ b/test/lsptoolshost/unitTests/copilotLspConfig.test.ts @@ -69,12 +69,15 @@ describe('Copilot LSP config installation', () => { expect(parsed.lspServers.csharp).toBeDefined(); }); - test('uninstall removes lspServers.csharp and preserves other servers', () => { + test('uninstall removes lspServers.csharp when it maps .cs and preserves other servers', () => { const existingConfig = JSON.stringify( { lspServers: { csharp: { command: 'dotnet', + fileExtensions: { + '.cs': 'csharp', + }, }, typescript: { command: 'typescript-language-server',