diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index cf55359498..f4eda87bb4 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -141,6 +141,15 @@ "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 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 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 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/package-lock.json b/package-lock.json index 6afc8a000f..b3bef2a866 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 283952ad5d..6eae076ee6 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", @@ -124,6 +125,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": { @@ -1870,6 +1872,18 @@ "category": ".NET", "enablement": "isWorkspaceTrusted && dotnet.server.activationContext == 'Roslyn'" }, + { + "command": "dotnet.installCopilotLsp", + "title": "%command.dotnet.installCopilotLsp%", + "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 019331ec22..c79dedfd3b 100644 --- a/package.nls.json +++ b/package.nls.json @@ -2,6 +2,8 @@ "command.o.restart": "Restart OmniSharp", "command.o.pickProjectAndStart": "Select Project", "command.dotnet.openSolution": "Open Solution", + "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", "command.o.fixAll.document": "Fix all occurrences of a code issue within document", diff --git a/redist/lsp-config.json b/redist/lsp-config.json new file mode 100644 index 0000000000..91c9b4f96d --- /dev/null +++ b/redist/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/src/activateRoslyn.ts b/src/activateRoslyn.ts index 5f072201a3..579a3fff20 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 265b9f7885..9c8edee417 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/lsptoolshost/commands.ts b/src/lsptoolshost/commands.ts index 4bb3b5dd21..37d5501540 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'; @@ -22,6 +25,11 @@ import { TelemetryEventNames } from '../shared/telemetryEventNames'; import { registerCollectLogsCommand } from './logging/collectLogs'; import { ObservableLogOutputChannel } from './logging/observableLogOutputChannel'; import { RazorLogger } from '../razor/src/razorLogger'; +import { getUninstalledCopilotLspConfigContent, getUpdatedCopilotLspConfigContent } from './copilotLspConfig'; + +const installCopilotLspCommand = 'dotnet.installCopilotLsp'; +const uninstallCopilotLspCommand = 'dotnet.uninstallCopilotLsp'; +const packagedCopilotLspConfigPath = path.join('redist', 'lsp-config.json'); export function registerCommands( context: vscode.ExtensionContext, @@ -92,5 +100,118 @@ function registerExtensionCommands( context.subscriptions.push( vscode.commands.registerCommand('csharp.showOutputWindow', async () => outputChannel.show()) ); + context.subscriptions.push( + 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; + try { + packagedContent = await fs.readFile(extensionLspConfigPath, 'utf8'); + } 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 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 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 a C# LSP mapping for .cs files. No changes were made.' + ) + ); + return; + } + + 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) + ); + } catch (error) { + const nodeError = error as NodeJS.ErrnoException; + void vscode.window.showErrorMessage( + vscode.l10n.t('Failed to write Copilot LSP config: {0}', nodeError.message) + ); + } + }) + ); + 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 a C# LSP mapping for .cs files. No changes were made.' + ) + ); + return; + } + + try { + await fs.writeFile(lspConfigPath, uninstallResult.updatedContent, 'utf8'); + void vscode.window.showInformationMessage( + 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; + 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 new file mode 100644 index 0000000000..58c9d65263 --- /dev/null +++ b/src/lsptoolshost/copilotLspConfig.ts @@ -0,0 +1,118 @@ +/*--------------------------------------------------------------------------------------------- + * 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 (copilotConfigContainsCSharpLsp(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` }; +} + +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 updatedConfig: CopilotLspConfig = { + ...currentConfig, + lspServers: { ...lspServers }, + }; + + const matchingServerNames = getCSharpServerNames(lspServers); + for (const serverName of matchingServerNames) { + delete updatedConfig.lspServers![serverName]; + } + + if (matchingServerNames.length === 0) { + return { shouldWrite: false }; + } + + return { shouldWrite: true, updatedContent: `${JSON.stringify(updatedConfig, null, 2)}\n` }; +} + +function copilotConfigContainsCSharpLsp(lspConfig: CopilotLspConfig): boolean { + const lspServers = lspConfig.lspServers; + if (!lspServers || typeof lspServers !== 'object') { + return false; + } + + return containsCSharpServer(lspServers); +} + +function getCSharpServerNames(lspServers: { [key: string]: unknown }): string[] { + const csharpServerNames: string[] = []; + + for (const [serverName, server] of Object.entries(lspServers)) { + if (serverContainsCSharpFileExtension(server)) { + csharpServerNames.push(serverName); + } + } + + return csharpServerNames; +} + +function containsCSharpServer(lspServers: { [key: string]: unknown }): boolean { + for (const [serverName, server] of Object.entries(lspServers)) { + if (serverName === 'csharp' || serverContainsCSharpFileExtension(server)) { + return true; + } + } + + return false; +} + +function serverContainsCSharpFileExtension(server: unknown): boolean { + if (!server || typeof server !== 'object') { + return false; + } + + 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/src/main.ts b/src/main.ts index c2010b702b..f9d83589fb 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 0000000000..1f667edc99 --- /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; + } +} diff --git a/tasks/components/ingestCopilotLspConfig.ts b/tasks/components/ingestCopilotLspConfig.ts new file mode 100644 index 0000000000..953248045c --- /dev/null +++ b/tasks/components/ingestCopilotLspConfig.ts @@ -0,0 +1,81 @@ +/*--------------------------------------------------------------------------------------------- + * 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, 'redist', 'lsp-config.json'); + +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 + .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); + }); + }); +} diff --git a/test/lsptoolshost/unitTests/copilotLspConfig.test.ts b/test/lsptoolshost/unitTests/copilotLspConfig.test.ts new file mode 100644 index 0000000000..83ecff4448 --- /dev/null +++ b/test/lsptoolshost/unitTests/copilotLspConfig.test.ts @@ -0,0 +1,144 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { + getUninstalledCopilotLspConfigContent, + getUpdatedCopilotLspConfigContent, +} from '../../../src/lsptoolshost/copilotLspConfig'; + +describe('Copilot LSP config installation', () => { + test('is idempotent and preserves shipped config content across multiple runs', () => { + const packagedContent = readFileSync('redist/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); + }); + + 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: { + customServerName: { + command: 'some-other-command', + fileExtensions: { + '.cs': 'csharp', + }, + }, + }, + }, + null, + 2 + ); + + const result = getUpdatedCopilotLspConfigContent(existingConfig, packagedContent); + 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 when it maps .cs and preserves other servers', () => { + const existingConfig = JSON.stringify( + { + lspServers: { + csharp: { + command: 'dotnet', + fileExtensions: { + '.cs': 'csharp', + }, + }, + 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 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( + { + lspServers: { + typescript: { + command: 'typescript-language-server', + }, + }, + }, + null, + 2 + ); + + const result = getUninstalledCopilotLspConfigContent(existingConfig); + expect(result.shouldWrite).toBe(false); + }); +});