From b3d1b218d6406e15ff74b606f5d4f46bdeefe872 Mon Sep 17 00:00:00 2001 From: etsai-stripe <88805634+etsai-stripe@users.noreply.github.com> Date: Wed, 20 Oct 2021 12:34:31 -0700 Subject: [PATCH] DX-6792: java language server (#379) * DX-6802: download and extract jdt server (#377) * Java Lang Server Prototype (#383) * DX-6795: java server prototype (WIP) * wip * wip * wip * wip * wip * java lang server prototype * lint fix * update extension dependency * bug fixes * DX-6839: refactor and add tests (#386) * DX-6839: refactor and add tests * wip * language client tests * stripe hover provider tests * add code reference comments * fix lint * execute parameters tests * check package json file * fix tests * remove hardcoded jar path * fix test * handle errors and telemetry * address comments * DX-6804: auto update jdt server version (#389) * DX-6804: get latest jdt server version * remove latest version text file * move jdt server to distributed folder for release * update comment --- .vscode/launch.json | 3 +- .vscode/tasks.json | 10 +- package.json | 15 +- src/languageServerClient.ts | 407 +++++++++++++- src/stripeJavaLanguageClient/commands.ts | 10 + src/stripeJavaLanguageClient/hoverProvider.ts | 211 ++++++++ .../javaServerStarter.ts | 169 ++++++ .../standardLanguageClient.ts | 76 +++ .../syntaxLanguageClient.ts | 80 +++ src/stripeJavaLanguageClient/utils.ts | 510 ++++++++++++++++++ src/stripeJavaLanguageServer/extractServer.ts | 99 ++++ test/mocks/vscode.ts | 19 + test/suite/hoverProvider.test.ts | 106 ++++ test/suite/javaServerStarter.test.ts | 182 +++++++ test/suite/languageServerClient.test.ts | 96 ++++ 15 files changed, 1965 insertions(+), 28 deletions(-) create mode 100644 src/stripeJavaLanguageClient/commands.ts create mode 100644 src/stripeJavaLanguageClient/hoverProvider.ts create mode 100644 src/stripeJavaLanguageClient/javaServerStarter.ts create mode 100644 src/stripeJavaLanguageClient/standardLanguageClient.ts create mode 100644 src/stripeJavaLanguageClient/syntaxLanguageClient.ts create mode 100644 src/stripeJavaLanguageClient/utils.ts create mode 100644 src/stripeJavaLanguageServer/extractServer.ts create mode 100644 test/suite/hoverProvider.test.ts create mode 100644 test/suite/javaServerStarter.test.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index cfe4aa9c..89e3d724 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -23,7 +23,8 @@ "outFiles": ["${workspaceFolder}/dist/**/*.js"], "preLaunchTask": "Build all", "env": { - "EXTENSION_MODE": "development" + "EXTENSION_MODE": "development", + "DEBUG_VSCODE_JAVA":"true" } }, { diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 068b7115..98f238aa 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -36,10 +36,16 @@ "type": "npm", "script": "publish:dotnet-server", "isBackground": true - }, + }, + { + "label": "build java server", + "type": "npm", + "script": "java-server", + "isBackground": true + }, { "label": "Build all", - "dependsOn": ["Build language server", "build dotnet server", "Build extension"] + "dependsOn": ["Build language server", "build dotnet server", "build java server", "Build extension"] } ] } diff --git a/package.json b/package.json index 32647163..be47e01e 100644 --- a/package.json +++ b/package.json @@ -421,10 +421,12 @@ "webpack-prod:extension": "webpack --mode production --config ./webpack.config.js && webpack --mode production --config ./src/stripeLanguageServer/webpack.config.js", "webpack-prod:language-server": "webpack --mode production --config ./src/stripeLanguageServer/webpack.config.js", "webpack-prod": "npm run webpack-prod:extension && npm run webpack-prod:language-server", - "publish:dotnet-server": "dotnet publish src/stripeDotnetLanguageServer/stripe.LanguageServer/ -o ./dist/stripeDotnetLanguageServer" + "publish:dotnet-server": "dotnet publish src/stripeDotnetLanguageServer/stripe.LanguageServer/ -o ./dist/stripeDotnetLanguageServer", + "java-server": "node ./out/src/stripeJavaLanguageServer/extractServer.js" }, "devDependencies": { "@types/byline": "^4.2.33", + "@types/fs-extra": "^8.0.0", "@types/glob": "^7.1.4", "@types/google-protobuf": "^3.15.5", "@types/mocha": "^9.0.0", @@ -457,17 +459,24 @@ "byline": "^5.0.0", "compare-versions": "^3.6.0", "execa": "^5.1.1", + "expand-home-dir": "^0.0.3", + "find-java-home": "1.1.0", + "fs-extra": "^8.1.0", "moment": "^2.29.1", "os-name": "^3.1.0", "proxyquire": "^2.1.3", "remark-gfm": "^1.0.0", + "superagent": "*", + "tar-fs": "*", "toml": "^3.0.0", "uuid": "^8.3.2", "vscode-languageclient": "^6.1.3", "vscode-languageserver": "^6.1.1", - "vscode-languageserver-textdocument": "^1.0.1" + "vscode-languageserver-textdocument": "^1.0.1", + "zlib": "*" }, "extensionDependencies": [ - "ms-dotnettools.vscode-dotnet-runtime" + "ms-dotnettools.vscode-dotnet-runtime", + "vscjava.vscode-java-pack" ] } diff --git a/src/languageServerClient.ts b/src/languageServerClient.ts index 365ff120..98d1881e 100644 --- a/src/languageServerClient.ts +++ b/src/languageServerClient.ts @@ -1,37 +1,91 @@ /* eslint-disable no-warning-comments */ + +import * as os from 'os'; import * as path from 'path'; -import * as vscode from 'vscode'; +import { + ACTIVE_BUILD_TOOL_STATE, + ClientStatus, + JDKInfo, + ServerMode, + getJavaFilePathOfTextDocument, + getJavaSDKInfo, + getJavaServerLaunchMode, + hasNoBuildToolConflicts, + isPrefix, + makeRandomHexString, +} from './stripeJavaLanguageClient/utils'; import { CloseAction, + Emitter, ErrorAction, LanguageClient, LanguageClientOptions, ServerOptions, Trace, } from 'vscode-languageclient'; +import { + ExtensionContext, + OutputChannel, + RelativePattern, + Uri, + commands, + window, + workspace, +} from 'vscode'; import {OSType, getOSType} from './utils'; +import {Commands} from './stripeJavaLanguageClient/commands'; +import {StandardLanguageClient} from './stripeJavaLanguageClient/standardLanguageClient'; +import {SyntaxLanguageClient} from './stripeJavaLanguageClient/syntaxLanguageClient'; import {Telemetry} from './telemetry'; +import {prepareExecutable} from './stripeJavaLanguageClient/javaServerStarter'; +import {registerHoverProvider} from './stripeJavaLanguageClient/hoverProvider'; + +const REQUIRED_DOTNET_RUNTIME_VERSION = '5.0'; +const REQUIRED_JDK_VERSION = 11; + +const syntaxClient: SyntaxLanguageClient = new SyntaxLanguageClient(); +const standardClient: StandardLanguageClient = new StandardLanguageClient(); +const onDidServerModeChangeEmitter: Emitter = new Emitter(); + +export let javaServerMode: ServerMode; export class StripeLanguageClient { static async activate( - context: vscode.ExtensionContext, + context: ExtensionContext, serverOptions: ServerOptions, telemetry: Telemetry, ) { - const outputChannel = vscode.window.createOutputChannel('Stripe Language Client'); + const outputChannel = window.createOutputChannel('Stripe Language Client'); // start the csharp server if this is a dotnet project const dotnetProjectFile = await this.getDotnetProjectFiles(); if (dotnetProjectFile.length > 0) { this.activateDotNetServer(context, outputChannel, dotnetProjectFile[0], telemetry); - } else { - this.activateUniversalServer(context, outputChannel, serverOptions, telemetry); + return; + } + + // start the java server if this is a java project + const javaFiles = await this.getJavaProjectFiles(); + if (javaFiles.length > 0) { + const jdkInfo = await getJavaSDKInfo(context, outputChannel); + if (jdkInfo.javaVersion < REQUIRED_JDK_VERSION) { + outputChannel.appendLine( + `Minimum JDK version required is ${REQUIRED_JDK_VERSION}. Please update the java.home setup in VSCode user settings.`, + ); + telemetry.sendEvent('doesNotMeetRequiredJdkVersion'); + return; + } + this.activateJavaServer(context, jdkInfo, outputChannel, javaFiles, telemetry); + return; } + + // start the universal server for all other languages + this.activateUniversalServer(context, outputChannel, serverOptions, telemetry); } static activateUniversalServer( - context: vscode.ExtensionContext, - outputChannel: vscode.OutputChannel, + context: ExtensionContext, + outputChannel: OutputChannel, serverOptions: ServerOptions, telemetry: Telemetry, ) { @@ -42,13 +96,13 @@ export class StripeLanguageClient { {scheme: 'file', language: 'javascript'}, {scheme: 'file', language: 'typescript'}, {scheme: 'file', language: 'go'}, - {scheme: 'file', language: 'java'}, + // {scheme: 'file', language: 'java'}, {scheme: 'file', language: 'php'}, {scheme: 'file', language: 'python'}, {scheme: 'file', language: 'ruby'}, ], synchronize: { - fileEvents: vscode.workspace.createFileSystemWatcher('**/.clientrc'), + fileEvents: workspace.createFileSystemWatcher('**/.clientrc'), }, }; @@ -70,30 +124,31 @@ export class StripeLanguageClient { } static async activateDotNetServer( - context: vscode.ExtensionContext, - outputChannel: vscode.OutputChannel, + context: ExtensionContext, + outputChannel: OutputChannel, projectFile: string, telemetry: Telemetry, ) { outputChannel.appendLine('Detected C# Project file: ' + projectFile); - const dotnetRuntimeVersion = '5.0'; // Applie Silicon is not supported for dotnet < 6.0: // https://github.com/dotnet/core/issues/4879#issuecomment-729046912 if (getOSType() === OSType.macOSarm) { - outputChannel.appendLine(`.NET runtime v${dotnetRuntimeVersion} is not supported for M1`); + outputChannel.appendLine( + `.NET runtime v${REQUIRED_DOTNET_RUNTIME_VERSION} is not supported for M1`, + ); telemetry.sendEvent('dotnetRuntimeAcquisitionSkippedForM1'); return; } - const result = await vscode.commands.executeCommand<{dotnetPath: string}>('dotnet.acquire', { - version: dotnetRuntimeVersion, + const result = await commands.executeCommand<{dotnetPath: string}>('dotnet.acquire', { + version: REQUIRED_DOTNET_RUNTIME_VERSION, requestingExtensionId: 'stripe.vscode-stripe', }); if (!result) { outputChannel.appendLine( - `Failed to install .NET runtime v${dotnetRuntimeVersion}. Unable to start language server`, + `Failed to install .NET runtime v${REQUIRED_DOTNET_RUNTIME_VERSION}. Unable to start language server`, ); telemetry.sendEvent('dotnetRuntimeAcquisitionFailed'); @@ -118,7 +173,7 @@ export class StripeLanguageClient { documentSelector: [{scheme: 'file', language: 'csharp'}], synchronize: { configurationSection: 'stripeCsharpLangaugeServer', - fileEvents: vscode.workspace.createFileSystemWatcher('**/*.cs'), + fileEvents: workspace.createFileSystemWatcher('**/*.cs'), }, diagnosticCollectionName: 'Stripe C# language server', errorHandler: { @@ -153,6 +208,106 @@ export class StripeLanguageClient { telemetry.sendEvent('dotnetServerStarted'); } + static async activateJavaServer( + context: ExtensionContext, + jdkInfo: JDKInfo, + outputChannel: OutputChannel, + projectFiles: string[], + telemetry: Telemetry, + ) { + outputChannel.appendLine('Detected Java Project file: ' + projectFiles[0]); + + let storagePath = context.storagePath; + if (!storagePath) { + storagePath = path.resolve(os.tmpdir(), 'vscodesws_' + makeRandomHexString(5)); + } + + const workspacePath = path.resolve(storagePath + '/jdt_ws'); + const syntaxServerWorkspacePath = path.resolve(storagePath + '/ss_ws'); + + javaServerMode = getJavaServerLaunchMode(); + commands.executeCommand('setContext', 'java:serverMode', javaServerMode); + const requireSyntaxServer = javaServerMode !== ServerMode.STANDARD; + const requireStandardServer = javaServerMode !== ServerMode.LIGHTWEIGHT; + + // Options to control the language client + const clientOptions: LanguageClientOptions = { + // Register the server for java + documentSelector: [{scheme: 'file', language: 'java'}], + synchronize: { + configurationSection: ['java', 'editor.insertSpaces', 'editor.tabSize'], + }, + initializationOptions: { + extendedClientCapabilities: { + classFileContentsSupport: true, + clientHoverProvider: true, + clientDocumentSymbolProvider: true, + shouldLanguageServerExitOnShutdown: true, + }, + projectFiles, + }, + revealOutputChannelOn: 4, // never + errorHandler: { + error: (error, message, count) => { + console.log(message); + console.log(error); + + return ErrorAction.Continue; + }, + closed: () => CloseAction.DoNotRestart, + }, + }; + + if (requireSyntaxServer) { + try { + await this.startSyntaxServer( + clientOptions, + prepareExecutable(jdkInfo, syntaxServerWorkspacePath, context, true, outputChannel, telemetry), + outputChannel, + telemetry, + ); + } catch (e) { + outputChannel.appendLine(`${e}`); + telemetry.sendEvent('syntaxJavaServerFailedToStart'); + } + } + + // handle server mode changes from syntax to standard + this.registerSwitchJavaServerModeCommand( + context, + jdkInfo, + clientOptions, + workspacePath, + outputChannel, + telemetry + ); + + onDidServerModeChangeEmitter.event((event: ServerMode) => { + if (event === ServerMode.STANDARD) { + syntaxClient.stop(); + } + commands.executeCommand('setContext', 'java:serverMode', event); + }); + + // register hover provider + registerHoverProvider(context); + + if (requireStandardServer) { + try { + await this.startStandardServer( + context, + clientOptions, + prepareExecutable(jdkInfo, workspacePath, context, false, outputChannel, telemetry), + outputChannel, + telemetry, + ); + } catch (e) { + outputChannel.appendLine(`${e}`); + telemetry.sendEvent('standardJavaServerFailedToStart'); + } + } + } + /** * Returns a solutions file or project file if it exists in the workspace. * @@ -162,7 +317,7 @@ export class StripeLanguageClient { * Returns [] if none of the workspaces are .NET projects. */ static async getDotnetProjectFiles(): Promise { - const workspaceFolders = vscode.workspace.workspaceFolders; + const workspaceFolders = workspace.workspaceFolders; if (!workspaceFolders) { return []; } @@ -172,19 +327,19 @@ export class StripeLanguageClient { const workspacePath = w.uri.fsPath; // First look for solutions files. We only expect one solutions file to be present in a workspace. - const pattern = new vscode.RelativePattern(workspacePath, '**/*.sln'); + const pattern = new RelativePattern(workspacePath, '**/*.sln'); // Files and folders to exclude // There may be more we want to exclude but starting with the same set omnisharp uses: // https://github.com/OmniSharp/omnisharp-vscode/blob/master/src/omnisharp/launcher.ts#L66 const exclude = '{**/node_modules/**,**/.git/**,**/bower_components/**}'; - const sln = await vscode.workspace.findFiles(pattern, exclude, 1); + const sln = await workspace.findFiles(pattern, exclude, 1); if (sln && sln.length === 1) { return sln[0].fsPath; } else { // If there was no solutions file, look for a csproj file. - const pattern = new vscode.RelativePattern(workspacePath, '**/*.csproj'); - const csproj = await vscode.workspace.findFiles(pattern, exclude, 1); + const pattern = new RelativePattern(workspacePath, '**/*.csproj'); + const csproj = await workspace.findFiles(pattern, exclude, 1); if (csproj && csproj.length === 1) { return csproj[0].fsPath; } @@ -194,4 +349,212 @@ export class StripeLanguageClient { return projectFiles.filter((file): file is string => Boolean(file)); } + + static async getJavaProjectFiles() { + const openedJavaFiles = []; + if (!window.activeTextEditor) { + return []; + } + + const activeJavaFile = getJavaFilePathOfTextDocument(window.activeTextEditor.document); + if (activeJavaFile) { + openedJavaFiles.push(Uri.file(activeJavaFile).toString()); + } + + if (!workspace.workspaceFolders) { + return openedJavaFiles; + } + + await Promise.all( + workspace.workspaceFolders.map(async (rootFolder) => { + if (rootFolder.uri.scheme !== 'file') { + return; + } + + const rootPath = path.normalize(rootFolder.uri.fsPath); + if (activeJavaFile && isPrefix(rootPath, activeJavaFile)) { + return; + } + + for (const textEditor of window.visibleTextEditors) { + const javaFileInTextEditor = getJavaFilePathOfTextDocument(textEditor.document); + if (javaFileInTextEditor && isPrefix(rootPath, javaFileInTextEditor)) { + openedJavaFiles.push(Uri.file(javaFileInTextEditor).toString()); + return; + } + } + + for (const textDocument of workspace.textDocuments) { + const javaFileInTextDocument = getJavaFilePathOfTextDocument(textDocument); + if (javaFileInTextDocument && isPrefix(rootPath, javaFileInTextDocument)) { + openedJavaFiles.push(Uri.file(javaFileInTextDocument).toString()); + return; + } + } + + const javaFilesUnderRoot: Uri[] = await workspace.findFiles( + new RelativePattern(rootFolder, '*.java'), + undefined, + 1, + ); + for (const javaFile of javaFilesUnderRoot) { + if (isPrefix(rootPath, javaFile.fsPath)) { + openedJavaFiles.push(javaFile.toString()); + return; + } + } + + const javaFilesInCommonPlaces: Uri[] = await workspace.findFiles( + new RelativePattern(rootFolder, '{src, test}/**/*.java'), + undefined, + 1, + ); + for (const javaFile of javaFilesInCommonPlaces) { + if (isPrefix(rootPath, javaFile.fsPath)) { + openedJavaFiles.push(javaFile.toString()); + return; + } + } + }), + ); + + return openedJavaFiles; + } + + static async startSyntaxServer( + clientOptions: LanguageClientOptions, + serverOptions: ServerOptions, + outputChannel: OutputChannel, + telemetry: Telemetry, + ) { + await syntaxClient.initialize(clientOptions, serverOptions); + syntaxClient.start(); + outputChannel.appendLine('Java language service (syntax) is running.'); + telemetry.sendEvent('syntaxJavaServerStarted'); + } + + static async startStandardServer( + context: ExtensionContext, + clientOptions: LanguageClientOptions, + serverOptions: ServerOptions, + outputChannel: OutputChannel, + telemetry: Telemetry, + ) { + if (standardClient.getClientStatus() !== ClientStatus.Uninitialized) { + return; + } + + const checkConflicts: boolean = await hasNoBuildToolConflicts(context); + if (!checkConflicts) { + outputChannel.appendLine(`Build tool conflict detected in workspace. Please set '${ACTIVE_BUILD_TOOL_STATE}' to either maven or gradle.`); + telemetry.sendEvent('standardJavaServerHasBuildToolConflict'); + return; + } + + if (javaServerMode === ServerMode.LIGHTWEIGHT) { + // Before standard server is ready, we are in hybrid. + javaServerMode = ServerMode.HYBRID; + } + + await standardClient.initialize(clientOptions, serverOptions); + standardClient.start(); + + outputChannel.appendLine('Java language service (standard) is running.'); + telemetry.sendEvent('standardJavaServerStarted'); + } + + static async registerSwitchJavaServerModeCommand( + context: ExtensionContext, + jdkInfo: JDKInfo, + clientOptions: LanguageClientOptions, + workspacePath: string, + outputChannel: OutputChannel, + telemetry: Telemetry, + ) { + if ((await commands.getCommands()).includes(Commands.SWITCH_SERVER_MODE)) { + return; + } + + /** + * Command to switch the server mode. Currently it only supports switch from lightweight to standard. + * @param force force to switch server mode without asking + */ + commands.registerCommand( + Commands.SWITCH_SERVER_MODE, + async (switchTo: ServerMode, force: boolean = false) => { + const isWorkspaceTrusted = (workspace as any).isTrusted; + if (isWorkspaceTrusted !== undefined && !isWorkspaceTrusted) { + // keep compatibility for old engines < 1.56.0 + const button = 'Manage Workspace Trust'; + const choice = await window.showInformationMessage( + 'For security concern, Java language server cannot be switched to Standard mode in untrusted workspaces.', + button, + ); + if (choice === button) { + commands.executeCommand('workbench.action.manageTrust'); + } + return; + } + + const clientStatus: ClientStatus = standardClient.getClientStatus(); + if (clientStatus === ClientStatus.Starting || clientStatus === ClientStatus.Started) { + return; + } + + if (javaServerMode === switchTo || javaServerMode === ServerMode.STANDARD) { + return; + } + + let choice: string; + if (force) { + choice = 'Yes'; + } else { + choice = await window.showInformationMessage( + 'Are you sure you want to switch the Java language server to Standard mode?', + 'Yes', + 'No', + ) || 'No'; + } + + if (choice === 'Yes') { + telemetry.sendEvent('switchToStandardMode'); + + try { + this.startStandardServer( + context, + clientOptions, + prepareExecutable(jdkInfo, workspacePath, context, false, outputChannel, telemetry), + outputChannel, + telemetry, + ); + } catch (e) { + outputChannel.appendLine(`${e}`); + telemetry.sendEvent('failedToSwitchToStandardMode'); + } + } + }, + ); + } +} + +export async function getActiveJavaLanguageClient(): Promise { + let languageClient: LanguageClient | undefined; + + if (javaServerMode === ServerMode.STANDARD) { + languageClient = standardClient.getClient(); + } else { + languageClient = syntaxClient.getClient(); + } + + if (!languageClient) { + return undefined; + } + + await languageClient.onReady(); + return languageClient; +} + +export function updateServerMode(serverMode: ServerMode) { + javaServerMode = serverMode; + console.log('server mode changed to ' + serverMode); } diff --git a/src/stripeJavaLanguageClient/commands.ts b/src/stripeJavaLanguageClient/commands.ts new file mode 100644 index 00000000..f5866789 --- /dev/null +++ b/src/stripeJavaLanguageClient/commands.ts @@ -0,0 +1,10 @@ +export namespace Commands { + /** + * Navigate To Super Method Command. + */ + export const NAVIGATE_TO_SUPER_IMPLEMENTATION_COMMAND = 'java.action.navigateToSuperImplementation'; + /** + * Command to switch between standard mode and lightweight mode. + */ + export const SWITCH_SERVER_MODE = 'java.server.mode.switch'; +} diff --git a/src/stripeJavaLanguageClient/hoverProvider.ts b/src/stripeJavaLanguageClient/hoverProvider.ts new file mode 100644 index 00000000..a6f86b27 --- /dev/null +++ b/src/stripeJavaLanguageClient/hoverProvider.ts @@ -0,0 +1,211 @@ +'use strict'; + +import { + CancellationToken, + Command, + ExtensionContext, + Hover, + HoverProvider, + MarkdownString, + MarkedString, + Position, + ProviderResult, + TextDocument, + languages, +} from 'vscode'; +import {FindLinks, ServerMode, getJavaApiDocLink} from './utils'; +import {HoverRequest, LanguageClient, TextDocumentPositionParams} from 'vscode-languageclient'; +import {getActiveJavaLanguageClient, javaServerMode} from '../languageServerClient'; +import {Commands as javaCommands} from './commands'; + +export type provideHoverCommandFn = ( + params: TextDocumentPositionParams, + token: CancellationToken, +) => ProviderResult; +const hoverCommandRegistry: provideHoverCommandFn[] = []; + +export function registerHoverProvider(context: ExtensionContext) { + const hoverProvider = new ClientHoverProvider(); + context.subscriptions.push(languages.registerHoverProvider('java', hoverProvider)); +} + +function registerHoverCommand(callback: provideHoverCommandFn): void { + hoverCommandRegistry.push(callback); +} + +function createClientHoverProvider(languageClient: LanguageClient): JavaHoverProvider { + const hoverProvider: JavaHoverProvider = new JavaHoverProvider(languageClient); + registerHoverCommand(async (params: TextDocumentPositionParams, token: CancellationToken) => { + const command = await provideHoverCommand(languageClient, params, token); + return command; + }); + + return hoverProvider; +} + +function encodeBase64(text: string): string { + return Buffer.from(text).toString('base64'); +} + +async function provideHoverCommand( + languageClient: LanguageClient, + params: TextDocumentPositionParams, + token: CancellationToken, +): Promise { + const response = await languageClient.sendRequest( + FindLinks.type, + { + type: 'superImplementation', + position: params, + }, + token, + ); + if (response && response.length) { + const location = response[0]; + let tooltip; + if (location.kind === 'method') { + tooltip = `Go to super method '${location.displayName}'`; + } else { + tooltip = `Go to super implementation '${location.displayName}'`; + } + + return [ + { + title: 'Go to Super Implementation', + command: javaCommands.NAVIGATE_TO_SUPER_IMPLEMENTATION_COMMAND, + tooltip, + arguments: [ + { + uri: encodeBase64(location.uri), + range: location.range, + }, + ], + }, + ]; + } +} + +class ClientHoverProvider implements HoverProvider { + private delegateProvider: any; + + async provideHover( + document: TextDocument, + position: Position, + token: CancellationToken, + ): Promise { + const languageClient: LanguageClient | undefined = await getActiveJavaLanguageClient(); + + if (!languageClient) { + return undefined; + } + + if (javaServerMode === ServerMode.STANDARD) { + if (!this.delegateProvider) { + this.delegateProvider = createClientHoverProvider(languageClient); + } + return this.delegateProvider.provideHover(document, position, token); + } else { + const params = { + textDocument: languageClient.code2ProtocolConverter.asTextDocumentIdentifier(document), + position: languageClient.code2ProtocolConverter.asPosition(position), + }; + const hoverResponse = await languageClient.sendRequest(HoverRequest.type, params, token); + return languageClient.protocol2CodeConverter.asHover(hoverResponse); + } + } +} + +export class JavaHoverProvider implements HoverProvider { + constructor(readonly languageClient: LanguageClient) { + this.languageClient = languageClient; + } + + async provideHover( + document: TextDocument, + position: Position, + token: CancellationToken, + ): Promise { + let contents: MarkedString[] = []; + let range; + + const params = { + textDocument: this.languageClient.code2ProtocolConverter.asTextDocumentIdentifier(document), + position: this.languageClient.code2ProtocolConverter.asPosition(position), + }; + + // get javs doc convent from server + const hoverResponse = await this.languageClient.sendRequest(HoverRequest.type, params, token); + + // parse for stripe api hover content + let stripeApiHoverContent; + if (hoverResponse && hoverResponse.contents && Array.isArray(hoverResponse.contents)) { + const stripeFullClassPath = Object.entries(hoverResponse.contents[0]) + .filter((item) => item[0] === 'value') + .filter((item) => item[1].includes('com.stripe.model')); + if (stripeFullClassPath.length > 0) { + const stripeMethod = stripeFullClassPath[0][1].split(' ')[1].split('(')[0]; + const url = getJavaApiDocLink(stripeMethod); + if (url) { + stripeApiHoverContent = new MarkdownString( + 'See this method in the [Stripe API Reference](' + url + ')', + ); + stripeApiHoverContent.isTrusted = true; + } + } + } + + if (!!stripeApiHoverContent) { + contents = contents.concat([stripeApiHoverContent] as MarkedString[]); + } + + // get contributed hover commands from third party extensions. + const contributedCommands: Command[] = await this.getContributedHoverCommands(params, token); + + if (contributedCommands.length > 0) { + const contributedContent = new MarkdownString( + contributedCommands.map((command) => this.convertCommandToMarkdown(command)).join(' | '), + ); + contributedContent.isTrusted = true; + contents = contents.concat([contributedContent] as MarkedString[]); + } + + // combine all hover contents with java docs from server + const serverHover = this.languageClient.protocol2CodeConverter.asHover(hoverResponse); + if (serverHover && serverHover.contents) { + contents = contents.concat(serverHover.contents); + range = serverHover.range; + } + + return new Hover(contents, range); + } + + async getContributedHoverCommands( + params: TextDocumentPositionParams, + token: CancellationToken, + ): Promise { + const contributedCommands: Command[] = []; + for (const provideFn of hoverCommandRegistry) { + try { + if (token.isCancellationRequested) { + break; + } + + // eslint-disable-next-line no-await-in-loop + const commands = (await provideFn(params, token)) || []; + commands.forEach((command: Command) => { + contributedCommands.push(command); + }); + } catch (error) { + return []; + } + } + + return contributedCommands; + } + + private convertCommandToMarkdown(command: Command): string { + return `[${command.title}](command:${command.command}?${encodeURIComponent( + JSON.stringify(command.arguments || []), + )} "${command.tooltip || command.command}")`; + } +} diff --git a/src/stripeJavaLanguageClient/javaServerStarter.ts b/src/stripeJavaLanguageClient/javaServerStarter.ts new file mode 100644 index 00000000..eaf4d371 --- /dev/null +++ b/src/stripeJavaLanguageClient/javaServerStarter.ts @@ -0,0 +1,169 @@ +/* eslint-disable no-warning-comments */ +/* eslint-disable no-sync */ +/** + * Inspired by https://github.com/redhat-developer/vscode-java/blob/master/src/javaServerStarter.ts + */ +import * as fs from 'fs'; +import * as path from 'path'; +import {Executable, ExecutableOptions} from 'vscode-languageclient'; +import {ExtensionContext, OutputChannel} from 'vscode'; +import { + JDKInfo, + checkPathExists, + deleteDirectory, + ensureExists, + getJavaEncoding, + getServerLauncher, + getTimestamp, + startedFromSources, + startedInDebugMode, +} from './utils'; +import {Telemetry} from '../telemetry'; + +// javaServerPath has to match 'extractedTo' in src/stripeJavaLanguageServer/extractServer.ts +export const javaServerPath = '../dist/stripeJavaLanguageServer'; + +export function prepareExecutable( + jdkInfo: JDKInfo, + workspacePath: string, + context: ExtensionContext, + isSyntaxServer: boolean, + outputChannel: OutputChannel, + telemetry: Telemetry, +): Executable { + try { + const executable: Executable = Object.create(null); + const options: ExecutableOptions = Object.create(null); + options.env = Object.assign({syntaxserver: isSyntaxServer}, process.env); + executable.options = options; + executable.command = path.resolve(jdkInfo.javaHome + '/bin/java'); + executable.args = prepareParams(jdkInfo, workspacePath, context, isSyntaxServer, outputChannel, telemetry); + console.log(`Starting Java server with: ${executable.command} ${executable.args.join(' ')}`); + return executable; + } catch (e) { + const serverType = isSyntaxServer ? 'Syntax' : 'Standard'; + throw new Error(`Failed to start Java ${serverType} server. ${e}`); + } +} + +/** + * See https://www.eclipse.org/community/eclipse_newsletter/2017/may/article4.php + * for required paramters to run the Eclipse JDT server + */ +export function prepareParams( + jdkInfo: JDKInfo, + workspacePath: string, + context: ExtensionContext, + isSyntaxServer: boolean, + outputChannel: OutputChannel, + telemetry: Telemetry, +): string[] { + const params: string[] = []; + const inDebug = startedInDebugMode(); + + if (inDebug) { + const port = isSyntaxServer ? 1047 : 1046; + params.push(`-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=${port},quiet=y`); + } + + if (jdkInfo.javaVersion > 8) { + params.push( + '--add-modules=ALL-SYSTEM', + '--add-opens', + 'java.base/java.util=ALL-UNNAMED', + '--add-opens', + 'java.base/java.lang=ALL-UNNAMED', + ); + } + + params.push( + '-Declipse.application=org.eclipse.jdt.ls.core.id1', + '-Dosgi.bundles.defaultStartLevel=4', + '-Declipse.product=org.eclipse.jdt.ls.core.product', + ); + + if (inDebug) { + params.push('-Dlog.level=ALL'); + } + + const encodingKey = '-Dfile.encoding='; + params.push(encodingKey + getJavaEncoding()); + + const serverHome: string = path.resolve(__dirname, javaServerPath); + const launchersFound: Array = getServerLauncher(serverHome); + + if (launchersFound && launchersFound.length) { + params.push('-jar'); + params.push(path.resolve(serverHome, launchersFound[0])); + } else { + throw new Error('Server jar not found'); + } + + // select configuration directory according to OS + let configDir = isSyntaxServer ? 'config_ss_win' : 'config_win'; + if (process.platform === 'darwin') { + configDir = isSyntaxServer ? 'config_ss_mac' : 'config_mac'; + } else if (process.platform === 'linux') { + configDir = isSyntaxServer ? 'config_ss_linux' : 'config_linux'; + } + + params.push('-configuration'); + if (startedFromSources()) { + // dev mode + params.push(path.resolve(__dirname, javaServerPath, configDir)); + } else { + const config = resolveConfiguration(context, configDir, outputChannel, telemetry); + if (config) { + params.push(config); + } else { + throw new Error('Failed to get server configuration file.'); + } + } + + params.push('-data'); + params.push(workspacePath); + + return params; +} + +export function resolveConfiguration( + context: ExtensionContext, + configDir: string, + outputChannel: OutputChannel, + telemetry: Telemetry +): string { + ensureExists(context.globalStoragePath); + let version = '0.0.0'; + try { + const extensionPath = path.resolve(context.extensionPath, 'package.json'); + const packageFile = JSON.parse(fs.readFileSync(extensionPath, 'utf8')); + if (packageFile) { + version = packageFile.version; + } + } catch { + outputChannel.appendLine('Cannot locate package.json to parse for extension version. Default to 0.0.0'); + telemetry.sendEvent('cannotParseForExtensionVersion'); + } + + let configuration = path.resolve(context.globalStoragePath, version); + ensureExists(configuration); + configuration = path.resolve(configuration, configDir); + ensureExists(configuration); + + const configIniName = 'config.ini'; + const configIni = path.resolve(configuration, configIniName); + const ini = path.resolve(__dirname, javaServerPath, configDir, configIniName); + if (!checkPathExists(configIni)) { + fs.copyFileSync(ini, configIni); + } else { + const configIniTime = getTimestamp(configIni); + const iniTime = getTimestamp(ini); + if (iniTime > configIniTime) { + deleteDirectory(configuration); + resolveConfiguration(context, configDir, outputChannel, telemetry); + } + } + + return configuration; +} + diff --git a/src/stripeJavaLanguageClient/standardLanguageClient.ts b/src/stripeJavaLanguageClient/standardLanguageClient.ts new file mode 100644 index 00000000..359e930d --- /dev/null +++ b/src/stripeJavaLanguageClient/standardLanguageClient.ts @@ -0,0 +1,76 @@ +import {ClientStatus, EXTENSION_NAME_STANDARD, ServerMode, StatusNotification, StatusReport} from './utils'; +import {LanguageClient, LanguageClientOptions, ServerOptions} from 'vscode-languageclient'; +import {updateServerMode} from '../languageServerClient'; + +/** + * Standard java client based off generic language client + * Inspired by https://github.com/redhat-developer/vscode-java/blob/master/src/standardLanguageClient.ts + */ +export class StandardLanguageClient { + private languageClient: LanguageClient | undefined; + private status: ClientStatus = ClientStatus.Uninitialized; + + public initialize( + clientOptions: LanguageClientOptions, + serverOptions: ServerOptions, + ) { + if (!serverOptions || this.status !== ClientStatus.Uninitialized) { + return; + } + + this.languageClient = new LanguageClient( + 'java', + EXTENSION_NAME_STANDARD, + serverOptions, + clientOptions, + ); + + this.languageClient.onReady().then(() => { + if (!this.languageClient) { + return; + } + + this.languageClient.onNotification(StatusNotification.type, (report: StatusReport) => { + switch (report.type) { + case 'ServiceReady': + updateServerMode(ServerMode.STANDARD); + break; + case 'Started': + this.status = ClientStatus.Started; + break; + case 'Error': + this.status = ClientStatus.Error; + break; + case 'Starting': + case 'Message': + // message goes to progress report instead + break; + } + }); + }); + + this.status = ClientStatus.Initialized; + } + + public start(): void { + if (this.languageClient && this.status === ClientStatus.Initialized) { + this.languageClient.start(); + this.status = ClientStatus.Starting; + } + } + + public stop() { + if (this.languageClient) { + this.languageClient.stop(); + this.status = ClientStatus.Stopping; + } + } + + public getClient(): LanguageClient | undefined { + return this.languageClient; + } + + public getClientStatus(): ClientStatus { + return this.status; + } +} diff --git a/src/stripeJavaLanguageClient/syntaxLanguageClient.ts b/src/stripeJavaLanguageClient/syntaxLanguageClient.ts new file mode 100644 index 00000000..eafc237b --- /dev/null +++ b/src/stripeJavaLanguageClient/syntaxLanguageClient.ts @@ -0,0 +1,80 @@ +import {ClientStatus, EXTENSION_NAME_SYNTAX, StatusNotification} from './utils'; +import {CloseAction, ErrorAction, LanguageClient, LanguageClientOptions, ServerOptions} from 'vscode-languageclient'; + +/** + * Syntax java client based off generic language client + * Inspired by https://github.com/redhat-developer/vscode-java/blob/master/src/syntaxLanguageClient.ts + */ +export class SyntaxLanguageClient { + private languageClient: LanguageClient | undefined; + private status: ClientStatus = ClientStatus.Uninitialized; + + public initialize( + clientOptions: LanguageClientOptions, + serverOptions: ServerOptions, + ) { + if (!serverOptions) { + return; + } + + const newClientOptions: LanguageClientOptions = Object.assign({}, clientOptions, { + errorHandler: { + error: (error: string, message: string) => { + console.log(message); + console.log(error); + + return ErrorAction.Continue; + }, + closed: () => CloseAction.DoNotRestart, + }, + }); + + this.languageClient = new LanguageClient( + 'java', + EXTENSION_NAME_SYNTAX, + serverOptions, + newClientOptions, + ); + + this.languageClient.onReady().then(() => { + if (this.languageClient) { + this.languageClient.onNotification(StatusNotification.type, (report: {type: any}) => { + switch (report.type) { + case 'Started': + this.status = ClientStatus.Started; + break; + case 'Error': + this.status = ClientStatus.Error; + break; + default: + break; + } + }); + } + }); + + this.status = ClientStatus.Initialized; + } + + public start(): void { + if (this.languageClient) { + this.languageClient.start(); + this.status = ClientStatus.Starting; + } + } + + public stop() { + this.status = ClientStatus.Stopping; + if (this.languageClient) { + this.languageClient.stop(); + } + } + + public isAlive(): boolean { + return !!this.languageClient && this.status !== ClientStatus.Stopping; + } + + public getClient(): LanguageClient | undefined { + return this.languageClient; + } +} diff --git a/src/stripeJavaLanguageClient/utils.ts b/src/stripeJavaLanguageClient/utils.ts new file mode 100644 index 00000000..4a04aab1 --- /dev/null +++ b/src/stripeJavaLanguageClient/utils.ts @@ -0,0 +1,510 @@ +/* eslint-disable no-sync */ +import * as cp from 'child_process'; +import * as fs from 'fs'; +import * as fse from 'fs-extra'; +import * as glob from 'glob'; +import * as path from 'path'; +import { + ConfigurationTarget, + ExtensionContext, + OutputChannel, + TextDocument, + Uri, + WorkspaceConfiguration, + env, + window, + workspace, +} from 'vscode'; +import { + Location, + NotificationType, + RequestType, + TextDocumentPositionParams, +} from 'vscode-languageclient'; +import javaPatterns from '../../config/api_ref/patterns_java.json'; + +const expandHomeDir = require('expand-home-dir'); + +const isWindows: boolean = process.platform.indexOf('win') === 0; +const JAVAC_FILENAME = 'javac' + (isWindows ? '.exe' : ''); +const JAVA_FILENAME = 'java' + (isWindows ? '.exe' : ''); + +export const ACTIVE_BUILD_TOOL_STATE = 'java.activeBuildTool'; +export const DEBUG_VSCODE_JAVA = 'DEBUG_VSCODE_JAVA'; +export const EXTENSION_NAME_STANDARD = 'stripeJavaLanguageServer (Standard)'; +export const EXTENSION_NAME_SYNTAX = 'stripeJavaLanguageServer (Syntax)'; +export const IS_WORKSPACE_JDK_ALLOWED = 'java.ls.isJdkAllowed'; + +export interface JDKInfo { + javaHome: string; + javaVersion: number; +} + +export interface StatusReport { + message: string; + type: string; +} + +export enum ClientStatus { + Uninitialized = 'Uninitialized', + Initialized = 'Initialized', + Starting = 'Starting', + Started = 'Started', + Error = 'Error', + Stopping = 'Stopping', +} + +export enum ServerMode { + STANDARD = 'Standard', + LIGHTWEIGHT = 'LightWeight', + HYBRID = 'Hybrid', +} + +export namespace StatusNotification { + export const type = new NotificationType('language/status'); +} + +export interface FindLinksParams { + type: string; + position: TextDocumentPositionParams; +} + +export interface LinkLocation extends Location { + displayName: string; + kind: string; +} + +export namespace FindLinks { + export const type = new RequestType('java/findLinks'); +} + +export function getJavaConfiguration(): WorkspaceConfiguration { + return workspace.getConfiguration('java'); +} + +export function getJavaServerLaunchMode(): ServerMode { + return workspace.getConfiguration().get('java.server.launchMode') || ServerMode.HYBRID; +} + +export async function hasNoBuildToolConflicts( + context: ExtensionContext, +): Promise { + const isMavenEnabled: boolean = + getJavaConfiguration().get('import.maven.enabled') || false; + const isGradleEnabled: boolean = + getJavaConfiguration().get('import.gradle.enabled') || false; + if (isMavenEnabled && isGradleEnabled) { + const activeBuildTool: string | undefined = context.workspaceState.get(ACTIVE_BUILD_TOOL_STATE); + if (!activeBuildTool) { + if (!(await hasBuildToolConflicts())) { + return true; + } + return false; + } + } + + return true; +} + +async function hasBuildToolConflicts(): Promise { + const projectConfigurationUris: Uri[] = await getBuildFilesInWorkspace(); + const projectConfigurationFsPaths: string[] = projectConfigurationUris.map((uri) => uri.fsPath); + const eclipseDirectories = getDirectoriesByBuildFile(projectConfigurationFsPaths, [], '.project'); + // ignore the folders that already has .project file (already imported before) + const gradleDirectories = getDirectoriesByBuildFile( + projectConfigurationFsPaths, + eclipseDirectories, + '.gradle', + ); + const gradleDirectoriesKts = getDirectoriesByBuildFile( + projectConfigurationFsPaths, + eclipseDirectories, + '.gradle.kts', + ); + gradleDirectories.concat(gradleDirectoriesKts); + const mavenDirectories = getDirectoriesByBuildFile( + projectConfigurationFsPaths, + eclipseDirectories, + 'pom.xml', + ); + return gradleDirectories.some((gradleDir) => { + return mavenDirectories.includes(gradleDir); + }); +} + +async function getBuildFilesInWorkspace(): Promise { + const buildFiles: Uri[] = []; + const inclusionFilePatterns: string[] = getBuildFilePatterns(); + inclusionFilePatterns.push('**/.project'); + const inclusionFolderPatterns: string[] = getInclusionPatternsFromNegatedExclusion(); + // Since VS Code API does not support put negated exclusion pattern in findFiles(), + // here we first parse the negated exclusion to inclusion and do the search. + if (inclusionFilePatterns.length > 0 && inclusionFolderPatterns.length > 0) { + buildFiles.push( + ...(await workspace.findFiles( + convertToGlob(inclusionFilePatterns, inclusionFolderPatterns), + null, + )), + ); + } + + const inclusionBlob: string = convertToGlob(inclusionFilePatterns); + const exclusionBlob: string = getExclusionBlob(); + if (inclusionBlob) { + buildFiles.push(...(await workspace.findFiles(inclusionBlob, exclusionBlob))); + } + + return buildFiles; +} + +function getDirectoriesByBuildFile( + inclusions: string[], + exclusions: string[], + fileName: string, +): string[] { + return inclusions + .filter((fsPath) => fsPath.endsWith(fileName)) + .map((fsPath) => { + return path.dirname(fsPath); + }) + .filter((inclusion) => { + return !exclusions.includes(inclusion); + }); +} + +export function getBuildFilePatterns(): string[] { + const config = getJavaConfiguration(); + const isMavenImporterEnabled: boolean = config.get('import.maven.enabled') || false; + const isGradleImporterEnabled: boolean = config.get('import.gradle.enabled') || false; + const patterns: string[] = []; + if (isMavenImporterEnabled) { + patterns.push('**/pom.xml'); + } + if (isGradleImporterEnabled) { + patterns.push('**/*.gradle'); + patterns.push('**/*.gradle.kts'); + } + + return patterns; +} + +export function getInclusionPatternsFromNegatedExclusion(): string[] { + const config = getJavaConfiguration(); + const exclusions: string[] = config.get('import.exclusions', []); + const patterns: string[] = []; + for (const exclusion of exclusions) { + if (exclusion.startsWith('!')) { + patterns.push(exclusion.substr(1)); + } + } + return patterns; +} + +export function convertToGlob(filePatterns: string[], basePatterns?: string[]): string { + if (!filePatterns || filePatterns.length === 0) { + return ''; + } + + if (!basePatterns || basePatterns.length === 0) { + return parseToStringGlob(filePatterns); + } + + const patterns: string[] = []; + for (const basePattern of basePatterns) { + for (const filePattern of filePatterns) { + patterns.push(path.join(basePattern, `/${filePattern}`).replace(/\\/g, '/')); + } + } + return parseToStringGlob(patterns); +} + +export function getExclusionBlob(): string { + const config = getJavaConfiguration(); + const exclusions: string[] = config.get('import.exclusions', []); + const patterns: string[] = []; + for (const exclusion of exclusions) { + if (exclusion.startsWith('!')) { + continue; + } + + patterns.push(exclusion); + } + return parseToStringGlob(patterns); +} + +function parseToStringGlob(patterns: string[]): string { + if (!patterns || patterns.length === 0) { + return ''; + } + + return `{${patterns.join(',')}}`; +} + +export function ensureExists(folder: string) { + if (!fs.existsSync(folder)) { + fs.mkdirSync(folder); + } +} + +export function deleteDirectory(dir: string) { + if (fs.existsSync(dir)) { + fs.readdirSync(dir).forEach((child) => { + const entry = path.join(dir, child); + if (fs.lstatSync(entry).isDirectory()) { + deleteDirectory(entry); + } else { + fs.unlinkSync(entry); + } + }); + fs.rmdirSync(dir); + } +} + +export function getTimestamp(file: string) { + if (!fs.existsSync(file)) { + return -1; + } + const stat = fs.statSync(file); + return stat.mtimeMs; +} + +// see https://github.com/redhat-developer/vscode-java/blob/master/src/extension.ts +export function makeRandomHexString(length: number) { + const chars = [ + '0', + '1', + '2', + '3', + '4', + '5', + '6', + '6', + '7', + '8', + '9', + 'a', + 'b', + 'c', + 'd', + 'e', + 'f', + ]; + let result = ''; + for (let i = 0; i < length; i++) { + const idx = Math.floor(chars.length * Math.random()); + result += chars[idx]; + } + return result; +} + +export function getJavaFilePathOfTextDocument(document: TextDocument): string | undefined { + if (document) { + const resource = document.uri; + if (resource.scheme === 'file' && resource.fsPath.endsWith('.java')) { + return path.normalize(resource.fsPath); + } + } + + return undefined; +} + +export function isPrefix(parentPath: string, childPath: string): boolean { + if (!childPath) { + return false; + } + const relative = path.relative(parentPath, childPath); + return !!relative && !relative.startsWith('..') && !path.isAbsolute(relative); +} + +export async function getJavaSDKInfo( + context: ExtensionContext, + outputChannel: OutputChannel, +): Promise { + let source: string; + let javaVersion: number = 0; + let javaHome = (await checkJavaPreferences(context)) || ''; + if (javaHome) { + source = `java.home variable defined in ${env.appName} settings`; + javaHome = expandHomeDir(javaHome); + if (!(await fse.pathExists(javaHome))) { + outputChannel.appendLine( + `The ${source} points to a missing or inaccessible folder (${javaHome})`, + ); + } else if (!(await fse.pathExists(path.resolve(javaHome, 'bin', JAVAC_FILENAME)))) { + let msg: string; + if (await fse.pathExists(path.resolve(javaHome, JAVAC_FILENAME))) { + msg = `'bin' should be removed from the ${source} (${javaHome})`; + } else { + msg = `The ${source} (${javaHome}) does not point to a JDK.`; + } + outputChannel.appendLine(msg); + } + javaVersion = (await getJavaVersion(javaHome)) || 0; + } + return {javaHome, javaVersion}; +} + +async function checkJavaPreferences(context: ExtensionContext) { + const allow = 'Allow'; + const disallow = 'Disallow'; + let inspect = workspace.getConfiguration().inspect('java.home'); + let javaHome = inspect && inspect.workspaceValue; + let isVerified = javaHome === undefined || javaHome === null; + if (isVerified) { + javaHome = getJavaConfiguration().get('home'); + } + const key = getKey(IS_WORKSPACE_JDK_ALLOWED, context.storagePath, javaHome); + const globalState = context.globalState; + if (!isVerified) { + isVerified = globalState.get(key) || false; + if (isVerified === undefined) { + await window + .showErrorMessage( + `Do you allow this workspace to set the java.home variable? \n java.home: ${javaHome}`, + disallow, + allow, + ) + .then(async (selection) => { + if (selection === allow) { + globalState.update(key, true); + } else if (selection === disallow) { + globalState.update(key, false); + await workspace + .getConfiguration() + .update('java.home', undefined, ConfigurationTarget.Workspace); + } + }); + isVerified = globalState.get(key) || false; + } + } + + if (!isVerified) { + inspect = workspace.getConfiguration().inspect('java.home'); + javaHome = inspect && inspect.globalValue; + } + + return javaHome; +} + +async function getJavaVersion(javaHome: string): Promise { + let javaVersion = await checkVersionInReleaseFile(javaHome); + if (!javaVersion) { + javaVersion = await checkVersionByCLI(javaHome); + } + return javaVersion; +} + +/** + * Get version by checking file JAVA_HOME/release + * see https://github.com/redhat-developer/vscode-java/blob/master/src/requirements.ts + */ +async function checkVersionInReleaseFile(javaHome: string): Promise { + const releaseFile = path.join(javaHome, 'release'); + + try { + const content = await fse.readFile(releaseFile); + const regexp = /^JAVA_VERSION="(.*)"/gm; + const match = regexp.exec(content.toString()); + if (!match) { + return 0; + } + const majorVersion = parseMajorVersion(match[1]); + return majorVersion; + } catch (error) { + // ignore + } + return 0; +} + +/** + * Get version by parsing `JAVA_HOME/bin/java -version` + * see https://github.com/redhat-developer/vscode-java/blob/master/src/requirements.ts + */ +function checkVersionByCLI(javaHome: string): Promise { + return new Promise((resolve, reject) => { + const javaBin = path.join(javaHome, 'bin', JAVA_FILENAME); + cp.execFile(javaBin, ['-version'], {}, (error: any, stdout: any, stderr: string) => { + const regexp = /version "(.*)"/g; + const match = regexp.exec(stderr); + if (!match) { + return resolve(0); + } + const javaVersion = parseMajorVersion(match[1]); + resolve(javaVersion); + }); + }); +} + +function parseMajorVersion(version: string): number { + if (!version) { + return 0; + } + // Ignore '1.' prefix for legacy Java versions + if (version.startsWith('1.')) { + version = version.substring(2); + } + // look into the interesting bits now + const regexp = /\d+/g; + const match = regexp.exec(version); + let javaVersion = 0; + if (match) { + javaVersion = parseInt(match[0], 10); + } + return javaVersion; +} + +function getKey(prefix: string, storagePath: any, value: any) { + const workspacePath = path.resolve(storagePath + '/jdt_ws'); + if (workspace.name !== undefined) { + return `${prefix}::${workspacePath}::${value}`; + } else { + return `${prefix}::${value}`; + } +} + +export function getJavaEncoding(): string { + const config = workspace.getConfiguration(); + const languageConfig: any = config.get('[java]'); + let javaEncoding = null; + if (languageConfig) { + javaEncoding = languageConfig['files.encoding']; + } + if (!javaEncoding) { + javaEncoding = config.get('files.encoding', 'UTF-8'); + } + return javaEncoding; +} + +export function getJavaApiDocLink(namespace: string): string { + const baseUrl = 'https://stripe.com/docs/api'; + const patterns = Object.entries(javaPatterns); + const found = patterns.filter((item) => item[0] === namespace); + if (found) { + const apiUrl = found[0][1]; + return baseUrl + apiUrl; + } + return ''; + } + + export function getServerLauncher(serverHome: string): Array { + return glob.sync('**/plugins/org.eclipse.equinox.launcher_*.jar', { + cwd: serverHome, + }); +} + +export function checkPathExists(filepath: string) { + return fs.existsSync(filepath); +} + +export function startedInDebugMode(): boolean { + const args = (process as any).execArgv as string[]; + if (args) { + // See https://nodejs.org/en/docs/guides/debugging-getting-started/ + return args.some((arg) => /^--inspect/.test(arg) || /^--debug/.test(arg)); + } + return false; +} + +export function startedFromSources(): boolean { + return process.env[DEBUG_VSCODE_JAVA] === 'true'; +} diff --git a/src/stripeJavaLanguageServer/extractServer.ts b/src/stripeJavaLanguageServer/extractServer.ts new file mode 100644 index 00000000..e784dd9d --- /dev/null +++ b/src/stripeJavaLanguageServer/extractServer.ts @@ -0,0 +1,99 @@ +/* eslint-disable no-sync */ + +'use strict'; + +// Import +const request = require('superagent'); +const fs = require('fs'); +const tar = require('tar-fs'); +const zlib = require('zlib'); + +// Eclipse JDT source +// https://www.eclipse.org/community/eclipse_newsletter/2017/may/article4.php +const href = 'http://download.eclipse.org/jdtls/snapshots/'; +const tarFile = 'jdt-language-server-latest.tar.gz'; +const latestText = 'latest.txt'; + +const tarPath = `./${tarFile}`; +const source = `${href}/${tarFile}`; + +// extractedTo has to match 'javaServerPath' in src/stripeJavaLanguageClient/javaServerStarter.ts +const extractTo = './dist/stripeJavaLanguageServer'; + +try { + if (fs.existsSync(tarPath)) { + // Java jdt server already downloaded: + // verify that it is the latest version released + // if yes, untar existing server tar + // if not, remove existing server tar and re-download + // + console.log('JDT Server tar already downloaded.'); + + const downloadedVersion = getDownloadedDateStamp(tarPath); + console.log(`Local JDT server date stamp: ${downloadedVersion}`); + + request + .get(`${href}/${latestText}`) + .on('error', function (error: any) { + console.log(error); + }) + .pipe(fs.createWriteStream(latestText)) + .on('finish', function () { + fs.readFile(latestText, 'utf-8', (err: any, data: any) => { + if (err) {return err;} + // data format: jdt-language-server-1.6.0-202110200520.tar.gz + const latestVersion = data.split('-').slice(-1)[0].split('.')[0].slice(0, 8); + if (downloadedVersion < latestVersion) { + console.log('Local JDT server version is not latest. Remove local copy and download latest.'); + fs.unlinkSync(tarPath); + downloadAndUntarLatestServerFile(); + } else { + // the tarred plugins do not have a reliable way to verify versions or ensure not tampered + // we will always untar the server tar to overwrite existing plugins + untarServerFile(); + } + fs.unlinkSync(latestText); + }); + }); + } else { + // Java jdt server does not exist: download latest and untar + downloadAndUntarLatestServerFile(); + } +} catch (err) { + console.error(err); +} + +function downloadAndUntarLatestServerFile() { + request + .get(source) + .on('error', function (error: any) { + console.log(error); + }) + .pipe(fs.createWriteStream(tarFile)) + .on('finish', function () { + console.log('Download finished.'); + untarServerFile(); + }); +} + +function untarServerFile() { + console.log('Untar started...'); + + fs.createReadStream(tarFile).pipe(zlib.createGunzip()).pipe(tar.extract(extractTo)); + + console.log('Untar finished.'); +} + +function getDownloadedDateStamp(file: string) { + const stat = fs.statSync(file); + const epochMs = stat.mtimeMs; + + const date = new Date(epochMs); + const yearStr = `${date.getFullYear()}`; + const month = date.getMonth() + 1; // month is from 0 to 11 + const monthStr = month >= 10 ? `${month}` : `0${month}`; + const day = date.getDate(); + const dayStr = day >= 10 ? `${day}` : `0${day}`; + + return `${yearStr}${monthStr}${dayStr}`; +} diff --git a/test/mocks/vscode.ts b/test/mocks/vscode.ts index e36580a5..3a9fafef 100644 --- a/test/mocks/vscode.ts +++ b/test/mocks/vscode.ts @@ -1,4 +1,5 @@ import * as vscode from 'vscode'; +import {Executable, ExecutableOptions, LanguageClientOptions} from 'vscode-languageclient'; export class TestMemento implements vscode.Memento { storage: Map; @@ -29,4 +30,22 @@ export const mocks = { globalStoragePath: '', logPath: '', }, + + javaClientOptions: { + documentSelector: [{scheme: 'file', language: 'java'}], + synchronize: { + configurationSection: ['java', 'editor.insertSpaces', 'editor.tabSize'], + }, + revealOutputChannelOn: 4, + }, }; + +export function getMockJavaServerOptions(): Executable { + const executable: Executable = Object.create(null); + const options: ExecutableOptions = Object.create(null); + options.env = Object.assign({syntaxserver: false}, process.env); + executable.options = options; + executable.command = '/path/to/java/home/bin/java'; + executable.args = []; + return executable; +} diff --git a/test/suite/hoverProvider.test.ts b/test/suite/hoverProvider.test.ts new file mode 100644 index 00000000..dad9434c --- /dev/null +++ b/test/suite/hoverProvider.test.ts @@ -0,0 +1,106 @@ +import * as assert from 'assert'; +import * as javaClientUtils from '../../src/stripeJavaLanguageClient/utils'; +import {CancellationToken, MarkdownString, Position, TextDocument} from 'vscode'; +import {getMockJavaServerOptions, mocks} from '../mocks/vscode'; +import {JavaHoverProvider} from '../../src/stripeJavaLanguageClient/hoverProvider'; +import {LanguageClient} from 'vscode-languageclient'; +import sinon from 'ts-sinon'; + +const getServerResponse = (isClientSdk: boolean): any => { + if (isClientSdk) { + return { + contents: [{value: 'FileLink com.stripe.model.FileLink.create()'}], + }; + } + return { + contents: [{value: 'com.stripe.net.xxx'}], + }; +}; + +suite('JavaHoverProvider', () => { + let sandbox: sinon.SinonSandbox; + + setup(() => { + sandbox = sinon.createSandbox(); + }); + + teardown(() => { + sandbox.restore(); + }); + + suite('provideHover', () => { + let languageClient: LanguageClient; + let document: TextDocument; + let position: Position; + let token: CancellationToken; + + setup(() => { + languageClient = new LanguageClient( + 'java', + 'Test Language Client', + getMockJavaServerOptions(), + mocks.javaClientOptions, + ); + sandbox.stub(languageClient.code2ProtocolConverter, 'asTextDocumentIdentifier'); + sandbox.stub(languageClient.code2ProtocolConverter, 'asPosition'); + }); + + test('retrieve api deep link for stripe java method', async () => { + const javaHoverProvider = new JavaHoverProvider(languageClient); + const expectedApiDocLink = 'https://stripe.com/docs/api/file_links/create'; + + sandbox.stub(languageClient, 'sendRequest').returns(Promise.resolve(getServerResponse(true))); + sandbox.stub(javaClientUtils, 'getJavaApiDocLink').returns(expectedApiDocLink); + sandbox.stub(javaHoverProvider, 'getContributedHoverCommands').returns(Promise.resolve([])); + sandbox.stub(languageClient.protocol2CodeConverter, 'asHover').returns({contents: []}); + const hoverContent = await javaHoverProvider.provideHover(document, position, token); + + if (!!hoverContent) { + const contentMarktdown: MarkdownString = hoverContent.contents[0] as MarkdownString; + assert.strictEqual( + contentMarktdown.value, + `See this method in the [Stripe API Reference](${expectedApiDocLink})`, + ); + } else { + throw new assert.AssertionError(); + } + }); + + test('no api deep link for internal stripe method', async () => { + const javaHoverProvider = new JavaHoverProvider(languageClient); + const apiDocLink = 'https://stripe.com/docs/api/file_links/create'; + + sandbox + .stub(languageClient, 'sendRequest') + .returns(Promise.resolve(getServerResponse(false))); + sandbox.stub(javaClientUtils, 'getJavaApiDocLink').returns(apiDocLink); + sandbox.stub(javaHoverProvider, 'getContributedHoverCommands').returns(Promise.resolve([])); + sandbox.stub(languageClient.protocol2CodeConverter, 'asHover').returns({contents: []}); + const hoverContent = await javaHoverProvider.provideHover(document, position, token); + + if (!!hoverContent) { + assert.strictEqual(hoverContent.contents.length, 0); + } else { + throw new assert.AssertionError(); + } + }); + + test('no api deep link for unknown stripe method', async () => { + const javaHoverProvider = new JavaHoverProvider(languageClient); + + sandbox + .stub(languageClient, 'sendRequest') + .returns(Promise.resolve(getServerResponse(true))); + sandbox.stub(javaClientUtils, 'getJavaApiDocLink').returns(''); + sandbox.stub(javaHoverProvider, 'getContributedHoverCommands').returns(Promise.resolve([])); + sandbox.stub(languageClient.protocol2CodeConverter, 'asHover').returns({contents: []}); + const hoverContent = await javaHoverProvider.provideHover(document, position, token); + + if (!!hoverContent) { + assert.strictEqual(hoverContent.contents.length, 0); + } else { + throw new assert.AssertionError(); + } + }); + }); +}); diff --git a/test/suite/javaServerStarter.test.ts b/test/suite/javaServerStarter.test.ts new file mode 100644 index 00000000..915c8912 --- /dev/null +++ b/test/suite/javaServerStarter.test.ts @@ -0,0 +1,182 @@ +import * as assert from 'assert'; +import * as javaClientUtils from '../../src/stripeJavaLanguageClient/utils'; +import * as javaServerStarter from '../../src/stripeJavaLanguageClient/javaServerStarter'; +import * as sinon from 'sinon'; +import * as vscode from 'vscode'; +import {NoOpTelemetry, Telemetry} from '../../src/telemetry'; +import {mocks} from '../mocks/vscode'; + +suite('JavaServerStarter', () => { + let sandbox: sinon.SinonSandbox; + + setup(() => { + sandbox = sinon.createSandbox(); + }); + + teardown(() => { + sandbox.restore(); + }); + + suite('prepareExecutable', () => { + const isSyntaxServer = true; + const workspacePath = '/path/to/workspace'; + const extensionContext = {...mocks.extensionContextMock}; + const outputChannel: Partial = {appendLine: (value: string) => {}, show: () => {}}; + const telemetry: Telemetry = new NoOpTelemetry(); + + setup(() => { + sandbox.stub(javaClientUtils, 'getJavaEncoding').returns('utf8'); + sandbox.stub(javaClientUtils, 'getServerLauncher').returns(['/path/to/server.jar']); + sandbox.stub(javaClientUtils, 'ensureExists'); + sandbox.stub(javaClientUtils, 'checkPathExists').returns(true); + sandbox.stub(javaClientUtils, 'getTimestamp').returns(-1); + }); + + test('get params for jdk version > 8, not in debug or dev mode', () => { + const jdkInfo = {javaHome: '/path/to/java', javaVersion: 11}; + sandbox.stub(javaClientUtils, 'startedInDebugMode').returns(false); + sandbox.stub(javaClientUtils, 'startedFromSources').returns(false); + + const exec = javaServerStarter.prepareExecutable( + jdkInfo, + workspacePath, + extensionContext, + isSyntaxServer, + outputChannel, + telemetry, + ); + + assert.strictEqual(exec.args?.length, 15); + assert.strictEqual(exec.args?.includes('--add-modules=ALL-SYSTEM'), true); + assert.strictEqual(exec.args?.includes('--add-opens'), true); + assert.strictEqual(exec.args?.includes('java.base/java.util=ALL-UNNAMED'), true); + assert.strictEqual(exec.args?.includes('java.base/java.lang=ALL-UNNAMED'), true); + assert.strictEqual( + exec.args?.includes('-Declipse.application=org.eclipse.jdt.ls.core.id1'), + true, + ); + assert.strictEqual(exec.args?.includes('-Dosgi.bundles.defaultStartLevel=4'), true); + assert.strictEqual( + exec.args?.includes('-Declipse.product=org.eclipse.jdt.ls.core.product'), + true, + ); + assert.strictEqual(exec.args?.includes('-Dosgi.bundles.defaultStartLevel=4'), true); + assert.strictEqual(exec.args?.includes('-Dfile.encoding=utf8'), true); + assert.strictEqual(exec.args?.includes('-jar'), true); + assert.strictEqual(exec.args?.includes('-configuration'), true); + assert.strictEqual(exec.args?.includes('-data'), true); + assert.strictEqual(exec.args?.includes(workspacePath), true); + }); + + test('get params for jdk version > 8, in debug mode', () => { + const jdkInfo = {javaHome: '/path/to/java', javaVersion: 11}; + sandbox.stub(javaClientUtils, 'startedInDebugMode').returns(true); + sandbox.stub(javaClientUtils, 'startedFromSources').returns(false); + + const exec = javaServerStarter.prepareExecutable( + jdkInfo, + workspacePath, + extensionContext, + isSyntaxServer, + outputChannel, + telemetry, + ); + + assert.strictEqual(exec.args?.length, 17); + assert.strictEqual( + exec.args?.includes( + '-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=1047,quiet=y', + ), + true, + ); + assert.strictEqual(exec.args?.includes('--add-modules=ALL-SYSTEM'), true); + assert.strictEqual(exec.args?.includes('--add-opens'), true); + assert.strictEqual(exec.args?.includes('java.base/java.util=ALL-UNNAMED'), true); + assert.strictEqual(exec.args?.includes('java.base/java.lang=ALL-UNNAMED'), true); + assert.strictEqual( + exec.args?.includes('-Declipse.application=org.eclipse.jdt.ls.core.id1'), + true, + ); + assert.strictEqual(exec.args?.includes('-Dosgi.bundles.defaultStartLevel=4'), true); + assert.strictEqual( + exec.args?.includes('-Declipse.product=org.eclipse.jdt.ls.core.product'), + true, + ); + assert.strictEqual(exec.args?.includes('-Dosgi.bundles.defaultStartLevel=4'), true); + assert.strictEqual(exec.args?.includes('-Dlog.level=ALL'), true); + assert.strictEqual(exec.args?.includes('-Dfile.encoding=utf8'), true); + assert.strictEqual(exec.args?.includes('-jar'), true); + assert.strictEqual(exec.args?.includes('-configuration'), true); + assert.strictEqual(exec.args?.includes('-data'), true); + assert.strictEqual(exec.args?.includes(workspacePath), true); + }); + + test('get params for jdk version > 8, in dev mode', () => { + const jdkInfo = {javaHome: '/path/to/java', javaVersion: 11}; + sandbox.stub(javaClientUtils, 'startedInDebugMode').returns(false); + sandbox.stub(javaClientUtils, 'startedFromSources').returns(true); + + const exec = javaServerStarter.prepareExecutable( + jdkInfo, + workspacePath, + extensionContext, + isSyntaxServer, + outputChannel, + telemetry, + ); + + assert.strictEqual(exec.args?.length, 15); + assert.strictEqual(exec.args?.includes('--add-modules=ALL-SYSTEM'), true); + assert.strictEqual(exec.args?.includes('--add-opens'), true); + assert.strictEqual(exec.args?.includes('java.base/java.util=ALL-UNNAMED'), true); + assert.strictEqual(exec.args?.includes('java.base/java.lang=ALL-UNNAMED'), true); + assert.strictEqual( + exec.args?.includes('-Declipse.application=org.eclipse.jdt.ls.core.id1'), + true, + ); + assert.strictEqual(exec.args?.includes('-Dosgi.bundles.defaultStartLevel=4'), true); + assert.strictEqual( + exec.args?.includes('-Declipse.product=org.eclipse.jdt.ls.core.product'), + true, + ); + assert.strictEqual(exec.args?.includes('-Dosgi.bundles.defaultStartLevel=4'), true); + assert.strictEqual(exec.args?.includes('-Dfile.encoding=utf8'), true); + assert.strictEqual(exec.args?.includes('-jar'), true); + assert.strictEqual(exec.args?.includes('-configuration'), true); + assert.strictEqual(exec.args?.includes('-data'), true); + assert.strictEqual(exec.args?.includes(workspacePath), true); + }); + + test('get params for jdk version < 8, not in debug or dev mode', () => { + const jdkInfo = {javaHome: '/path/to/java', javaVersion: 7}; + sandbox.stub(javaClientUtils, 'startedInDebugMode').returns(false); + sandbox.stub(javaClientUtils, 'startedFromSources').returns(false); + + const exec = javaServerStarter.prepareExecutable( + jdkInfo, + workspacePath, + extensionContext, + isSyntaxServer, + outputChannel, + telemetry, + ); + + assert.strictEqual(exec.args?.length, 10); + assert.strictEqual( + exec.args?.includes('-Declipse.application=org.eclipse.jdt.ls.core.id1'), + true, + ); + assert.strictEqual(exec.args?.includes('-Dosgi.bundles.defaultStartLevel=4'), true); + assert.strictEqual( + exec.args?.includes('-Declipse.product=org.eclipse.jdt.ls.core.product'), + true, + ); + assert.strictEqual(exec.args?.includes('-Dosgi.bundles.defaultStartLevel=4'), true); + assert.strictEqual(exec.args?.includes('-Dfile.encoding=utf8'), true); + assert.strictEqual(exec.args?.includes('-jar'), true); + assert.strictEqual(exec.args?.includes('-configuration'), true); + assert.strictEqual(exec.args?.includes('-data'), true); + assert.strictEqual(exec.args?.includes(workspacePath), true); + }); + }); +}); diff --git a/test/suite/languageServerClient.test.ts b/test/suite/languageServerClient.test.ts index 75262f66..4baf3530 100644 --- a/test/suite/languageServerClient.test.ts +++ b/test/suite/languageServerClient.test.ts @@ -1,4 +1,6 @@ import * as assert from 'assert'; +import * as javaClientUtils from '../../src/stripeJavaLanguageClient/utils'; +import * as javaServerStarter from '../../src/stripeJavaLanguageClient/javaServerStarter'; import * as sinon from 'sinon'; import * as utils from '../../src/utils'; import * as vscode from 'vscode'; @@ -11,6 +13,16 @@ const proxyquire = require('proxyquire'); const modulePath = '../../src/languageServerClient'; const setupProxies = (proxies: any) => proxyquire(modulePath, proxies); +const activeTextEditor = (fileExt: string): any => { + return { + document: { + uri: { + scheme: 'file', + fsPath: `test.${fileExt}` + } + } + }; +}; class TestLanguageClient extends BaseLanguageClient { constructor() { @@ -236,4 +248,88 @@ suite('languageServerClient', function () { assert.deepStrictEqual(projectFiles, [slnFile.fsPath]); }); }); + + suite('activateJavaServer', () => { + const module = setupProxies({'vscode-languageclient': vscodeStub}); + const jdkInfo = {javaHome: '/path/to/java', javaVersion: 11}; + + test('hybrid mode starts correct servers with correct workspace paths', async () => { + sandbox.stub(javaClientUtils, 'getJavaServerLaunchMode').returns(javaClientUtils.ServerMode.HYBRID); + sandbox.stub(javaClientUtils, 'hasNoBuildToolConflicts').returns(Promise.resolve(true)); + const connectToServerSpy = sandbox.stub(javaServerStarter, 'prepareExecutable'); + + await module.StripeLanguageClient.activateJavaServer( + extensionContext, + jdkInfo, + outputChannel, + ['file.java'], + telemetry, + ); + + assert.strictEqual(connectToServerSpy.callCount, 2); + + let isSyntaxServer = true; + assert.deepStrictEqual(connectToServerSpy.calledWith(sinon.match.any, sinon.match('ss_ws'), sinon.match.any, isSyntaxServer), true); + + isSyntaxServer = false; + assert.deepStrictEqual(connectToServerSpy.calledWith(sinon.match.any, sinon.match('jdt_ws'), sinon.match.any, isSyntaxServer), true); + }); + + test('syntax mode starts syntax servers with syntax workspace paths', async () => { + sandbox.stub(javaClientUtils, 'getJavaServerLaunchMode').returns(javaClientUtils.ServerMode.LIGHTWEIGHT); + const connectToServerSpy = sandbox.stub(javaServerStarter, 'prepareExecutable'); + + await module.StripeLanguageClient.activateJavaServer( + extensionContext, + jdkInfo, + outputChannel, + ['file.java'], + telemetry, + ); + + assert.strictEqual(connectToServerSpy.callCount, 1); + + const isSyntaxServer = true; + assert.deepStrictEqual(connectToServerSpy.calledWith(sinon.match.any, sinon.match('ss_ws'), sinon.match.any, isSyntaxServer), true); + }); + + test('standard mode starts standard servers with standard workspace paths', async () => { + sandbox.stub(javaClientUtils, 'getJavaServerLaunchMode').returns(javaClientUtils.ServerMode.STANDARD); + sandbox.stub(javaClientUtils, 'hasNoBuildToolConflicts').returns(Promise.resolve(true)); + const connectToServerSpy = sandbox.stub(javaServerStarter, 'prepareExecutable'); + + await module.StripeLanguageClient.activateJavaServer( + extensionContext, + jdkInfo, + outputChannel, + ['file.java'], + telemetry, + ); + + assert.strictEqual(connectToServerSpy.callCount, 1); + + const isSyntaxServer = false; + assert.deepStrictEqual(connectToServerSpy.calledWith(sinon.match.any, sinon.match('jdt_ws'), sinon.match.any, isSyntaxServer), true); + }); + }); + + suite('getJavaProjectFiles', () => { + test('returns empty when no active text editor open', async () => { + sandbox.stub(vscode.window, 'activeTextEditor').value(undefined); + const projectFiles = await StripeLanguageClient.getJavaProjectFiles(); + assert.deepStrictEqual(projectFiles, []); + }); + + test('returns empty when active open document does not end with .java', async () => { + sandbox.stub(vscode.window, 'activeTextEditor').value(activeTextEditor('random')); + const projectFiles = await StripeLanguageClient.getJavaProjectFiles(); + assert.deepStrictEqual(projectFiles, []); + }); + + test('returns java file when active open document end with .java', async () => { + sandbox.stub(vscode.window, 'activeTextEditor').value(activeTextEditor('java')); + const projectFiles = await StripeLanguageClient.getJavaProjectFiles(); + assert.deepStrictEqual(projectFiles, ['file:///test.java']); + }); + }); });