diff --git a/package-lock.json b/package-lock.json index a3087c63..a635c0ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.29.2", "license": "MIT", "dependencies": { + "@github/copilot-language-server": "^1.316.0", "@iconify-icons/codicon": "1.2.8", "@iconify/react": "^1.1.4", "@reduxjs/toolkit": "^1.8.6", @@ -128,6 +129,90 @@ "node": ">=10.0.0" } }, + "node_modules/@github/copilot-language-server": { + "version": "1.373.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.373.0.tgz", + "integrity": "sha512-tcRyxEvm36M30x5v3u/OuPnPENZJsmbMkcY+6A45Fsr0ZtUJF7BtAS/Si/2QTCVJndA2Oi7taicIuqSDucAR/Q==", + "license": "MIT", + "dependencies": { + "vscode-languageserver-protocol": "^3.17.5" + }, + "bin": { + "copilot-language-server": "dist/language-server.js" + }, + "optionalDependencies": { + "@github/copilot-language-server-darwin-arm64": "1.373.0", + "@github/copilot-language-server-darwin-x64": "1.373.0", + "@github/copilot-language-server-linux-arm64": "1.373.0", + "@github/copilot-language-server-linux-x64": "1.373.0", + "@github/copilot-language-server-win32-x64": "1.373.0" + } + }, + "node_modules/@github/copilot-language-server-darwin-arm64": { + "version": "1.373.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-arm64/-/copilot-language-server-darwin-arm64-1.373.0.tgz", + "integrity": "sha512-pzZZnQX3jIYmQ0/LgcB54xfnbFTmCmymSL1v5OemH9qpG3Xi4ekTnRy/YRGStxHAbM5mvPX9QDJJ+/CFTvSBGg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@github/copilot-language-server-darwin-x64": { + "version": "1.373.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-x64/-/copilot-language-server-darwin-x64-1.373.0.tgz", + "integrity": "sha512-1yfXy5cum7it3jUJ43ruymtj9StERUPEEY2nM9lCGgtv+Wn7ip0k2IFQvzfp/ql0FCivH0O954pqkrHO7GUYZg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@github/copilot-language-server-linux-arm64": { + "version": "1.373.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-arm64/-/copilot-language-server-linux-arm64-1.373.0.tgz", + "integrity": "sha512-dijhk5AlP3SQuECFXEHyNlzGxV0HClWM3yP54pod8Wu3Yb6Xo5Ek9ClEiNPc1f0FOiVT3DJ0ldmtm6Tb2/2xTA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@github/copilot-language-server-linux-x64": { + "version": "1.373.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-x64/-/copilot-language-server-linux-x64-1.373.0.tgz", + "integrity": "sha512-YCjhxglxPEneJUAycT90GWpNpswWsl1/RCYe7hG7lxKN6At0haE9XF/i/bisvwyqSBB9vUOFp2TB/XhwD9dQWg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@github/copilot-language-server-win32-x64": { + "version": "1.373.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-win32-x64/-/copilot-language-server-win32-x64-1.373.0.tgz", + "integrity": "sha512-lxMIjKwVbpg2JAgo11Ddwv7i0FSgCxjC+2XG6f/3ItG8M0dRkGzJzVNl9sQaTbZPria8T4vNB9nRM0Lpe92LUA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@iconify-icons/codicon": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@iconify-icons/codicon/-/codicon-1.2.8.tgz", @@ -3594,6 +3679,31 @@ "uuid": "^8.3.2" } }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "license": "MIT" + }, "node_modules/vscode-tas-client": { "version": "0.1.84", "resolved": "https://registry.npmjs.org/vscode-tas-client/-/vscode-tas-client-0.1.84.tgz", diff --git a/package.json b/package.json index 56049d14..a74aa2b8 100644 --- a/package.json +++ b/package.json @@ -381,6 +381,7 @@ "VisualStudioExptTeam.vscodeintellicode" ], "dependencies": { + "@github/copilot-language-server": "^1.316.0", "@iconify-icons/codicon": "1.2.8", "@iconify/react": "^1.1.4", "@reduxjs/toolkit": "^1.8.6", diff --git a/src/commands/handler.ts b/src/commands/handler.ts index 84825d6e..75a7bd32 100644 --- a/src/commands/handler.ts +++ b/src/commands/handler.ts @@ -126,4 +126,4 @@ export async function toggleAwtDevelopmentHandler(context: vscode.ExtensionConte fetchInitProps(context); vscode.window.showInformationMessage(`Java AWT development is ${enable ? "enabled" : "disabled"}.`); -} +} \ No newline at end of file diff --git a/src/copilot/context/copilotHelper.ts b/src/copilot/context/copilotHelper.ts new file mode 100644 index 00000000..f3c2d8b7 --- /dev/null +++ b/src/copilot/context/copilotHelper.ts @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +import { commands, Uri, CancellationToken } from "vscode"; +import { logger } from "../utils"; +import { validateExtensionInstalled } from "../../recommendation"; + +export interface INodeImportClass { + uri: string; + className: string; // Changed from 'class' to 'className' to match Java code +} +/** + * Helper class for Copilot integration to analyze Java project dependencies + */ +export namespace CopilotHelper { + /** + * Resolves all local project types imported by the given file + * @param fileUri The URI of the Java file to analyze + * @param cancellationToken Optional cancellation token to abort the operation + * @returns Array of strings in format "type:fully.qualified.name" where type is class|interface|enum|annotation + */ + export async function resolveLocalImports(fileUri: Uri, cancellationToken?: CancellationToken): Promise { + if (cancellationToken?.isCancellationRequested) { + return []; + } + // Ensure the Java Dependency extension is installed and meets the minimum version requirement. + if (!await validateExtensionInstalled("vscjava.vscode-java-dependency", "0.26.0")) { + return []; + } + + if (cancellationToken?.isCancellationRequested) { + return []; + } + + try { + // Create a promise that can be cancelled + const commandPromise = commands.executeCommand("java.execute.workspaceCommand", "java.project.getImportClassContent", fileUri.toString()) as Promise; + + if (cancellationToken) { + const result = await Promise.race([ + commandPromise, + new Promise((_, reject) => { + cancellationToken.onCancellationRequested(() => { + reject(new Error('Operation cancelled')); + }); + }) + ]); + return result || []; + } else { + const result = await commandPromise; + return result || []; + } + } catch (error: any) { + if (error.message === 'Operation cancelled') { + logger.info('Resolve local imports cancelled'); + return []; + } + logger.error("Error resolving copilot request:", error); + return []; + } + } +} diff --git a/src/copilot/contextProvider.ts b/src/copilot/contextProvider.ts new file mode 100644 index 00000000..5f0e0144 --- /dev/null +++ b/src/copilot/contextProvider.ts @@ -0,0 +1,189 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { + ResolveRequest, + SupportedContextItem, + type ContextProvider, +} from '@github/copilot-language-server'; +import * as vscode from 'vscode'; +import { CopilotHelper } from './context/copilotHelper'; +import { sendInfo } from "vscode-extension-telemetry-wrapper"; +import { + logger, + JavaContextProviderUtils, + CancellationError, + InternalCancellationError, + CopilotCancellationError, + ContextResolverFunction, + CopilotApi +} from './utils'; +import { getExtensionName } from '../utils/extension'; + +export async function registerCopilotContextProviders( + context: vscode.ExtensionContext +) { + try { + const apis = await JavaContextProviderUtils.getCopilotApis(); + if (!apis.clientApi || !apis.chatApi) { + logger.info('Failed to find compatible version of GitHub Copilot extension installed. Skip registration of Copilot context provider.'); + return; + } + + // Register the Java completion context provider + const provider: ContextProvider = { + id: getExtensionName(), // use extension id as provider id for now + selector: [{ language: "java" }], + resolver: { resolve: createJavaContextResolver() } + }; + + const installCount = await JavaContextProviderUtils.installContextProviderOnApis(apis, provider, context, installContextProvider); + + if (installCount === 0) { + logger.info('Incompatible GitHub Copilot extension installed. Skip registration of Java context providers.'); + return; + } + + logger.info('Registration of Java context provider for GitHub Copilot extension succeeded.'); + sendInfo("", { + "action": "registerCopilotContextProvider", + "extension": getExtensionName(), + "status": "succeeded", + "installCount": installCount + }); + } + catch (error) { + logger.error('Error occurred while registering Java context provider for GitHub Copilot extension:', error); + } +} + +/** + * Create the Java context resolver function + */ +function createJavaContextResolver(): ContextResolverFunction { + return async (request: ResolveRequest, copilotCancel: vscode.CancellationToken): Promise => { + const resolveStartTime = performance.now(); + let logMessage = `Java Context Provider: resolve(${request.documentContext.uri}:${request.documentContext.offset}):`; + + try { + // Check for immediate cancellation + JavaContextProviderUtils.checkCancellation(copilotCancel); + + return await resolveJavaContext(request, copilotCancel); + } catch (error: any) { + try { + JavaContextProviderUtils.handleError(error, 'Java context provider resolve', resolveStartTime, logMessage); + } catch (handledError) { + // Return empty array if error handling throws + return []; + } + // This should never be reached due to handleError throwing, but TypeScript requires it + return []; + } finally { + const duration = Math.round(performance.now() - resolveStartTime); + if (!logMessage.includes('cancellation')) { + logMessage += `(completed in ${duration}ms)`; + logger.info(logMessage); + } + } + }; +} + +/** + * Send telemetry data for Java context resolution + */ +function sendContextTelemetry(request: ResolveRequest, start: number, itemCount: number, status: string, error?: string) { + const duration = Math.round(performance.now() - start); + const telemetryData: any = { + "action": "resolveJavaContext", + "completionId": request.completionId, + "duration": duration, + "itemCount": itemCount, + "status": status + }; + + if (error) { + telemetryData.error = error; + } + + sendInfo("", telemetryData); +} + +async function resolveJavaContext(request: ResolveRequest, copilotCancel: vscode.CancellationToken): Promise { + const items: SupportedContextItem[] = []; + const start = performance.now(); + const documentUri = request.documentContext.uri; + const caretOffset = request.documentContext.offset; + + try { + // Check for cancellation before starting + JavaContextProviderUtils.checkCancellation(copilotCancel); + + // Get current document and position information + const activeEditor = vscode.window.activeTextEditor; + if (!activeEditor || activeEditor.document.languageId !== 'java') { + return items; + } + + const document = activeEditor.document; + + // Resolve imports directly without caching + const importClass = await CopilotHelper.resolveLocalImports(document.uri, copilotCancel); + logger.trace('Resolved imports count:', importClass?.length || 0); + + // Check for cancellation after resolution + JavaContextProviderUtils.checkCancellation(copilotCancel); + + // Check for cancellation before processing results + JavaContextProviderUtils.checkCancellation(copilotCancel); + + if (importClass) { + // Process imports in batches to reduce cancellation check overhead + const contextItems = JavaContextProviderUtils.createContextItemsFromImports(importClass); + + // Check cancellation once after creating all items + JavaContextProviderUtils.checkCancellation(copilotCancel); + + items.push(...contextItems); + } + } catch (error: any) { + if (error instanceof CopilotCancellationError) { + sendContextTelemetry(request, start, items.length, "cancelled_by_copilot"); + throw error; + } + if (error instanceof vscode.CancellationError || error.message === CancellationError.Canceled) { + sendContextTelemetry(request, start, items.length, "cancelled_internally"); + throw new InternalCancellationError(); + } + + // Send telemetry for general errors (but continue with partial results) + sendContextTelemetry(request, start, items.length, "error_partial_results", error.message || "unknown_error"); + + logger.error(`Error resolving Java context for ${documentUri}:${caretOffset}:`, error); + + // Return partial results and log completion for error case + JavaContextProviderUtils.logCompletion('Java context resolution', documentUri, caretOffset, start, items.length); + return items; + } + + // Send telemetry data once at the end for success case + sendContextTelemetry(request, start, items.length, "succeeded"); + + JavaContextProviderUtils.logCompletion('Java context resolution', documentUri, caretOffset, start, items.length); + return items; +} + +export async function installContextProvider( + copilotAPI: CopilotApi, + contextProvider: ContextProvider +): Promise { + const hasGetContextProviderAPI = typeof copilotAPI.getContextProviderAPI === 'function'; + if (hasGetContextProviderAPI) { + const contextAPI = await copilotAPI.getContextProviderAPI('v1'); + if (contextAPI) { + return contextAPI.registerContextProvider(contextProvider); + } + } + return undefined; +} diff --git a/src/copilot/utils.ts b/src/copilot/utils.ts index 8ad431db..534ea611 100644 --- a/src/copilot/utils.ts +++ b/src/copilot/utils.ts @@ -1,7 +1,14 @@ import { LogOutputChannel, SymbolKind, TextDocument, commands, window, Range, Selection, workspace, DocumentSymbol, version } from "vscode"; +import * as vscode from 'vscode'; import { SymbolNode } from "./inspect/SymbolNode"; import { SemVer } from "semver"; import { createUuid, sendOperationEnd, sendOperationError, sendOperationStart } from "vscode-extension-telemetry-wrapper"; +import { + ContextProviderApiV1, + ResolveRequest, + SupportedContextItem, + type ContextProvider, +} from '@github/copilot-language-server'; export const CLASS_KINDS: SymbolKind[] = [SymbolKind.Class, SymbolKind.Interface, SymbolKind.Enum]; export const METHOD_KINDS: SymbolKind[] = [SymbolKind.Method, SymbolKind.Constructor]; @@ -151,4 +158,184 @@ export function fixedInstrumentOperation( */ export function fixedInstrumentSimpleOperation(operationName: string, cb: (...args: any[]) => any, thisArg?: any): (...args: any[]) => any { return fixedInstrumentOperation(operationName, async (_operationId, ...args) => await cb.apply(thisArg, args), thisArg /** unnecessary */); +} + +/** + * Error classes for Copilot context provider cancellation handling + */ +export class CancellationError extends Error { + static readonly Canceled = "Canceled"; + constructor() { + super(CancellationError.Canceled); + this.name = this.message; + } +} + +export class InternalCancellationError extends CancellationError { +} + +export class CopilotCancellationError extends CancellationError { +} + +/** + * Type definitions for common patterns + */ +export type ContextResolverFunction = (request: ResolveRequest, token: vscode.CancellationToken) => Promise; + +export interface CopilotApiWrapper { + clientApi?: CopilotApi; + chatApi?: CopilotApi; +} + +export interface CopilotApi { + getContextProviderAPI(version: string): Promise; +} + +/** + * Utility class for handling common operations in Java Context Provider + */ +export class JavaContextProviderUtils { + /** + * Check if operation should be cancelled and throw appropriate error + */ + static checkCancellation(token: vscode.CancellationToken): void { + if (token.isCancellationRequested) { + throw new CopilotCancellationError(); + } + } + + /** + * Handle errors with appropriate logging and re-throwing + */ + static handleError(error: any, operation: string, startTime: number, logMessage: string): never { + const duration = Math.round(performance.now() - startTime); + + if (error instanceof CopilotCancellationError) { + const message = `${logMessage}(copilot cancellation after ${duration}ms)`; + logger.info(message); + throw error; + } + if (error instanceof InternalCancellationError) { + const message = `${logMessage}(internal cancellation after ${duration}ms)`; + logger.info(message); + throw error; + } + if (error instanceof vscode.CancellationError || error.message === CancellationError.Canceled) { + const message = `${logMessage}(cancellation after ${duration}ms)`; + logger.info(message); + throw new CancellationError(); + } + + logger.error(`Error in ${operation}:`, error); + throw error; + } + + /** + * Create context items from import classes + */ + static createContextItemsFromImports(importClasses: any[]): SupportedContextItem[] { + return importClasses.map((cls: any) => ({ + uri: cls.uri, + value: cls.className, + importance: 70, + origin: 'request' as const + })); + } + + /** + * Create a basic Java version context item + */ + static createJavaVersionItem(javaVersion: string): SupportedContextItem { + return { + name: 'java.version', + value: javaVersion, + importance: 90, + id: 'java-version', + origin: 'request' + }; + } + + /** + * Log completion with timing information + */ + static logCompletion(operation: string, documentUri: string, caretOffset: number, startTime: number, itemCount: number): void { + const duration = Math.round(performance.now() - startTime); + logger.info(`${operation} for ${documentUri}:${caretOffset} completed in ${duration}ms with ${itemCount} items`); + } + + /** + * Get and validate Copilot APIs + */ + static async getCopilotApis(): Promise { + const copilotClientApi = await getCopilotClientApi(); + const copilotChatApi = await getCopilotChatApi(); + return { clientApi: copilotClientApi, chatApi: copilotChatApi }; + } + + /** + * Install context provider on available APIs + */ + static async installContextProviderOnApis( + apis: CopilotApiWrapper, + provider: ContextProvider, + context: vscode.ExtensionContext, + installFn: (api: CopilotApi, provider: ContextProvider) => Promise + ): Promise { + let installCount = 0; + + if (apis.clientApi) { + const disposable = await installFn(apis.clientApi, provider); + if (disposable) { + context.subscriptions.push(disposable); + installCount++; + } + } + + if (apis.chatApi) { + const disposable = await installFn(apis.chatApi, provider); + if (disposable) { + context.subscriptions.push(disposable); + installCount++; + } + } + + return installCount; + } +} + +/** + * Get Copilot client API + */ +export async function getCopilotClientApi(): Promise { + const extension = vscode.extensions.getExtension('github.copilot'); + if (!extension) { + return undefined; + } + try { + return await extension.activate(); + } catch { + return undefined; + } +} + +/** + * Get Copilot chat API + */ +export async function getCopilotChatApi(): Promise { + type CopilotChatApi = { getAPI?(version: number): CopilotApi | undefined }; + const extension = vscode.extensions.getExtension('github.copilot-chat'); + if (!extension) { + return undefined; + } + + let exports: CopilotChatApi | undefined; + try { + exports = await extension.activate(); + } catch { + return undefined; + } + if (!exports || typeof exports.getAPI !== 'function') { + return undefined; + } + return exports.getAPI(1); } \ No newline at end of file diff --git a/src/extension.ts b/src/extension.ts index 43f65356..431cf32c 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -24,6 +24,7 @@ import { scheduleAction } from "./utils/scheduler"; import { showWelcomeWebview, WelcomeViewSerializer } from "./welcome"; import { ProjectSettingsViewSerializer } from "./project-settings/projectSettingsView"; import { TelemetryFilter } from "./utils/telemetryFilter"; +import { registerCopilotContextProviders } from "./copilot/contextProvider"; let cleanJavaWorkspaceIndicator: string; let activatedTimestamp: number; @@ -82,6 +83,8 @@ async function initializeExtension(_operationId: string, context: vscode.Extensi vscode.commands.executeCommand("java.runtime"); }); } + + await registerCopilotContextProviders(context); } async function presentFirstView(context: vscode.ExtensionContext) { diff --git a/src/recommendation/index.ts b/src/recommendation/index.ts index f749ed07..fe0cf31a 100644 --- a/src/recommendation/index.ts +++ b/src/recommendation/index.ts @@ -3,7 +3,7 @@ import * as vscode from "vscode"; import { initialize as initHandler, extensionRecommendationHandler } from "./handler"; -import { isExtensionInstalled, getExtensionContext } from "../utils"; +import { isExtensionInstalled, getExtensionContext, getInstalledExtension } from "../utils"; export function initialize(_context: vscode.ExtensionContext) { initHandler(); @@ -18,3 +18,13 @@ export async function validateAndRecommendExtension(extName: string, message: st return false; } + +export async function validateExtensionInstalled(extName: string, version: string) { + if(!isExtensionInstalled(extName)) { + return false; + } + if(version && getInstalledExtension(extName)?.packageJSON.version >= version) { + return true; + } + return false; +} diff --git a/src/utils/index.ts b/src/utils/index.ts index fe520eee..eaac33a5 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -27,6 +27,10 @@ export function isExtensionInstalled(extName: string) { return !!vscode.extensions.getExtension(extName); } +export function getInstalledExtension(extName: string) { + return vscode.extensions.getExtension(extName); +} + export async function recommendExtension(extName: string, message: string): Promise { const action = "Install"; const answer = await vscode.window.showInformationMessage(message, action);