From d8c8903d82ed0303c62bd08e1b323dfc19af85e3 Mon Sep 17 00:00:00 2001 From: Fred Bricon Date: Mon, 4 May 2020 17:44:13 +0200 Subject: [PATCH] Run an executable version of LemMinX Download a binary lemminx, check its integrity, then run it. Includes: * Setting to specify a binary, `xml.server.binary.path` * Setting to specify args for the binary, `xml.server.binary.args` Defaults to java server in cases such as: * The binary can't be downloaded * The file containing the expected hash of the binary is missing * The hash of the binary doesn't match the expected hash * Binary specified in setting can't be located and the above three fail Signed-off-by: David Thompson --- .editorconfig | 9 + .vscodeignore | 5 + package.json | 22 ++ src/binaryServerStarter.ts | 150 +++++++++++++ src/extension.ts | 434 ++++++++++++++++++------------------- src/javaServerStarter.ts | 31 +-- src/requirements.ts | 40 ++-- src/serverStarter.ts | 57 +++++ 8 files changed, 489 insertions(+), 259 deletions(-) create mode 100644 .editorconfig create mode 100644 .vscodeignore create mode 100644 src/binaryServerStarter.ts create mode 100644 src/serverStarter.ts diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..0b23a411 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*.ts] +[*.js] +indent_style = space +indent_size = 2 +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = false \ No newline at end of file diff --git a/.vscodeignore b/.vscodeignore new file mode 100644 index 00000000..cc37b5f5 --- /dev/null +++ b/.vscodeignore @@ -0,0 +1,5 @@ +.vscode +node_modules +src/ +tsconfig.json +webpack.config.js \ No newline at end of file diff --git a/package.json b/package.json index 99521997..2549f564 100644 --- a/package.json +++ b/package.json @@ -108,6 +108,28 @@ "markdownDescription": "Set a custom folder path for cached XML Schemas. An absolute path is expected, although the `~` prefix (for the user home directory) is supported. Default is `~/.lemminx`. Please refer to the [cache documentation](command:xml.open.docs?%5B%7B%22page%22%3A%22Preferences%22%2C%22section%22%3A%22server-cache-path%22%7D%5D) for more information.", "scope": "window" }, + "xml.server.preferBinary": { + "type": "boolean", + "default": false, + "description": "By default, vscode-xml tries to run the Java version of the XML Language Server. If no Java is detected, vscode-xml runs the binary XML language server. When this setting is enabled, the binary will also be used even if Java is installed. If there are additions to the XML Language Server provided by other extensions, Java will be used (if available) even if this settings is enabled.", + "scope": "window" + }, + "xml.server.silenceExtensionWarning": { + "type": "boolean", + "default": false, + "markdownDescription": "The XML Language server allows other VSCode extensions to extend its functionality. It requires Java-specific features in order to do this. If extensions to the XML language server are detected, but a binary XML language server is run because Java is missing, a warning will appear. This setting can be used to disable this warning.", + "scope": "window" + }, + "xml.server.binary.path": { + "type": "string", + "description": "The path to the server binary to run. Will be ignored if `xml.server.binary.enabled` is not set. A binary will be downloaded if this is not set.", + "scope": "machine" + }, + "xml.server.binary.args": { + "type": "string", + "markdownDescription": "Command line arguments to supply to the server binary when the server binary is being used. Takes into effect after relaunching VSCode. Please refer to [this website for the available options](https://www.graalvm.org/reference-manual/native-image/HostedvsRuntimeOptions/). For example, you can increase the maximum memory that the server can use to 1 GB by adding `-Xmx1g`", + "scope": "machine" + }, "xml.trace.server": { "type": "string", "enum": [ diff --git a/src/binaryServerStarter.ts b/src/binaryServerStarter.ts new file mode 100644 index 00000000..1952ad5c --- /dev/null +++ b/src/binaryServerStarter.ts @@ -0,0 +1,150 @@ +import { createHash, Hash } from 'crypto'; +import * as fs from 'fs'; +import * as http from 'http'; +import * as os from 'os'; +import * as path from 'path'; +import { ExtensionContext, window, WorkspaceConfiguration } from "vscode"; +import { Executable } from "vscode-languageclient"; +import { getXMLConfiguration } from "./settings"; +const glob = require('glob'); + +/** + * Returns the executable to launch LemMinX (the XML Language Server) as a binary + * + * @param context the extension context + * @throws if the binary doesn't exist and can't be downloaded, or if the binary is not trusted + * @returns Returns the executable to launch LemMinX (the XML Language Server) as a binary + */ +export async function prepareBinaryExecutable(context: ExtensionContext): Promise { + const binaryOptions: string = getXMLConfiguration().get("server.binary.args"); + let binaryExecutable: Executable; + return getServerBinaryPath() + .then((binaryPath: string) => { + binaryExecutable = { + args: [binaryOptions], + command: binaryPath + } as Executable; + return binaryPath; + }) + .then(checkBinaryHash) + .then((hashOk: boolean) => { + if (hashOk) { + return binaryExecutable; + } else { + return new Promise((resolve, reject) => { + return window.showErrorMessage(`The server binary ${binaryExecutable.command} is not trusted. ` + + 'Running the file poses a threat to your system\'s security. ' + + 'Run anyways?', 'Yes', 'No') + .then((val: string) => { + if (val === 'Yes') { + resolve(binaryExecutable); + } + reject("The binary XML language server is not trusted"); + }); + }); + } + }); +} + +/** + * Returns the path to the LemMinX binary + * + * Downloads it if it is missing + * + * @returns The path to the LemMinX binary + * @throws If the LemMinX binary can't be located or downloaded + */ +async function getServerBinaryPath(): Promise { + const config: WorkspaceConfiguration = getXMLConfiguration(); + let binaryPath: string = config.get("server.binary.path"); + if (binaryPath) { + if (fs.existsSync(binaryPath)) { + return Promise.resolve(binaryPath); + } + window.showErrorMessage('The specified XML language server binary could not be found. Using the default binary...'); + } + let server_home: string = path.resolve(__dirname, '../server'); + let binaries: Array = glob.sync(`**/lemminx-${os.platform()}*`, { cwd: server_home }); + const JAR_AND_HASH_REJECTOR: RegExp = /(\.jar)|(hash)$/; + binaries = binaries.filter((path) => { return !JAR_AND_HASH_REJECTOR.test(path) }); + if (binaries.length) { + return new Promise((resolve, reject) => { + resolve(path.resolve(server_home, binaries[0])); + }); + } + // Download it, then return the downloaded binary's location + return downloadBinary(); +} + +/** + * Downloads LemMinX binary under the `server` directory and returns the path to the binary as a Promise + * + * @returns The path to the LemMinX binary + * @throws If the LemMinX binary download fails + */ +async function downloadBinary(): Promise { + window.setStatusBarMessage('Downloading XML language server binary...', 2000); + return new Promise((resolve, reject) => { + const handleResponse = (response: http.IncomingMessage) => { + const statusCode = response.statusCode; + if (statusCode === 303) { + http.get(response.headers.location, handleResponse); + } else if (statusCode === 200) { + // This is probably the problem in Theia TODO: + const serverBinaryPath = path.resolve(__dirname, '../server', getServerBinaryName()); + const serverBinaryFileStream = fs.createWriteStream(serverBinaryPath); + response.pipe(serverBinaryFileStream); + serverBinaryFileStream.on('finish', () => { + serverBinaryFileStream.on('close', () => { + fs.chmodSync(serverBinaryPath, "766"); + resolve(serverBinaryPath); + }); + serverBinaryFileStream.close(); + }); + } else { + reject('Server binary download failed'); + } + } + http.get(`http://localhost:8080/lemminx-redirect/${os.platform()}`, handleResponse) + .on('error', () => { + reject('Server binary download failed'); + }); + }); +} + +/** + * Returns true if the hash of the binary matches the expected hash and false otherwise + * + * @param binaryPath the path to the binary to check + * @returns true if the hash of the binary matches the expected hash and false otherwise + */ +async function checkBinaryHash(binaryPath: string): Promise { + const hash: Hash = createHash('sha256'); + return new Promise((resolve, reject) => { + fs.readFile(path.resolve(binaryPath), (err, fileContents) => { + if (err) { + reject(err) + }; + resolve(fileContents); + }); + }) + .then((fileContents: string) => { + hash.update(fileContents); + const hashDigest: string = hash.digest('hex').toLowerCase(); + const expectedHashPath: string = path.resolve(__dirname, '../server', `lemminx-${os.platform}-hash`); + const expectedHash = fs.readFileSync(expectedHashPath).toString('utf-8').toLowerCase().split(' ')[0]; + return hashDigest === expectedHash; + }) + .catch((err: any) => { + return false; + }); +} + +/** + * Returns the name of the LemMinX server binary executable file + * + * @return the name of the LemMinX server binary executable file + */ +function getServerBinaryName(): string { + return `lemminx-${os.platform()}${os.platform() === 'win32' ? ".exe" : ""}`; +} \ No newline at end of file diff --git a/src/extension.ts b/src/extension.ts index ea0dba8c..b93dc4e4 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -10,32 +10,17 @@ * Microsoft Corporation - Auto Closing Tags */ -import { prepareExecutable } from './javaServerStarter'; -import { - LanguageClientOptions, - RevealOutputChannelOn, - LanguageClient, - DidChangeConfigurationNotification, - RequestType, - TextDocumentPositionParams, - ReferencesRequest, - NotificationType, - MessageType, - ConfigurationRequest, - ConfigurationParams, - ExecuteCommandParams, - CancellationToken, - ExecuteCommandRequest, TextDocumentIdentifier -} from 'vscode-languageclient'; -import * as requirements from './requirements'; -import { languages, IndentAction, workspace, window, commands, ExtensionContext, TextDocument, Position, LanguageConfiguration, Uri, extensions, Command, TextEditor } from "vscode"; -import * as path from 'path'; import * as os from 'os'; -import { activateTagClosing, AutoCloseResult } from './tagClosing'; +import * as path from 'path'; +import { Command, commands, ExtensionContext, extensions, IndentAction, LanguageConfiguration, languages, Position, TextDocument, TextEditor, Uri, window, workspace } from "vscode"; +import { CancellationToken, ConfigurationParams, ConfigurationRequest, DidChangeConfigurationNotification, Executable, ExecuteCommandParams, ExecuteCommandRequest, LanguageClient, LanguageClientOptions, MessageType, NotificationType, ReferencesRequest, RequestType, RevealOutputChannelOn, TextDocumentIdentifier, TextDocumentPositionParams } from 'vscode-languageclient'; import { Commands } from './commands'; -import { getXMLConfiguration, onConfigurationChange, subscribeJDKChangeConfiguration } from './settings'; -import { collectXmlJavaExtensions, onExtensionChange } from './plugin'; import { markdownPreviewProvider } from "./markdownPreviewProvider"; +import { collectXmlJavaExtensions, onExtensionChange } from './plugin'; +import * as requirements from './requirements'; +import { prepareExecutable } from './serverStarter'; +import { getXMLConfiguration, onConfigurationChange, subscribeJDKChangeConfiguration } from './settings'; +import { activateTagClosing, AutoCloseResult } from './tagClosing'; export interface ScopeInfo { scope: "default" | "global" | "workspace" | "folder"; @@ -182,216 +167,213 @@ export function activate(context: ExtensionContext) { } let logfile = path.resolve(storagePath + '/lemminx.log'); - return requirements.resolveRequirements(context).catch(error => { - //show error - window.showErrorMessage(error.message, error.label).then((selection) => { - if (error.label && error.label === selection && error.openUrl) { - commands.executeCommand('vscode.open', error.openUrl); - } - }); - // rethrow to disrupt the chain. - throw error; - }).then(requirements => { - - let clientOptions: LanguageClientOptions = { - // Register the server for xml and xsl - documentSelector: [ - { scheme: 'file', language: 'xml' }, - { scheme: 'file', language: 'xsl' }, - { scheme: 'untitled', language: 'xml' }, - { scheme: 'untitled', language: 'xsl' } - ], - revealOutputChannelOn: RevealOutputChannelOn.Never, - //wrap with key 'settings' so it can be handled same a DidChangeConfiguration - initializationOptions: { - settings: getXMLSettings(requirements.java_home), - extendedClientCapabilities: { - codeLens: { - codeLensKind: { - valueSet: [ - 'references' - ] + return requirements.resolveRequirements(context) + .catch(error => { + // continue with blank requirements to signal that there is no java + return {} as requirements.RequirementsData; + }) + .then(requirements => { + + let clientOptions: LanguageClientOptions = { + // Register the server for xml and xsl + documentSelector: [ + { scheme: 'file', language: 'xml' }, + { scheme: 'file', language: 'xsl' }, + { scheme: 'untitled', language: 'xml' }, + { scheme: 'untitled', language: 'xsl' } + ], + revealOutputChannelOn: RevealOutputChannelOn.Never, + //wrap with key 'settings' so it can be handled same a DidChangeConfiguration + initializationOptions: { + settings: getXMLSettings(requirements.java_home), + extendedClientCapabilities: { + codeLens: { + codeLensKind: { + valueSet: [ + 'references' + ] + } + }, + actionableNotificationSupport: true, + openSettingsCommandSupport: true + } + }, + synchronize: { + //preferences starting with these will trigger didChangeConfiguration + configurationSection: ['xml', '[xml]', 'files.trimFinalNewlines', 'files.trimTrailingWhitespace', 'files.insertFinalNewline'] + }, + middleware: { + workspace: { + didChangeConfiguration: () => { + languageClient.sendNotification(DidChangeConfigurationNotification.type, { settings: getXMLSettings(requirements.java_home) }); + onConfigurationChange(); } - }, - actionableNotificationSupport: true, - openSettingsCommandSupport: true - } - }, - synchronize: { - //preferences starting with these will trigger didChangeConfiguration - configurationSection: ['xml', '[xml]', 'files.trimFinalNewlines', 'files.trimTrailingWhitespace', 'files.insertFinalNewline'] - }, - middleware: { - workspace: { - didChangeConfiguration: () => { - languageClient.sendNotification(DidChangeConfigurationNotification.type, { settings: getXMLSettings(requirements.java_home) }); - onConfigurationChange(); } } } - } - - let serverOptions = prepareExecutable(requirements, collectXmlJavaExtensions(extensions.all, getXMLConfiguration().get("extension.jars", [])), context); - languageClient = new LanguageClient('xml', 'XML Support', serverOptions, clientOptions); - let toDispose = context.subscriptions; - let disposable = languageClient.start(); - toDispose.push(disposable); - - languages.setLanguageConfiguration('xml', getIndentationRules()); - languages.setLanguageConfiguration('xsl', getIndentationRules()); - - return languageClient.onReady().then(() => { - //Detect JDK configuration changes - disposable = subscribeJDKChangeConfiguration(); - toDispose.push(disposable); - - // Code Lens actions - context.subscriptions.push(commands.registerCommand(Commands.SHOW_REFERENCES, (uriString: string, position: Position) => { - const uri = Uri.parse(uriString); - workspace.openTextDocument(uri).then(document => { - // Consume references service from the XML Language Server - let param = languageClient.code2ProtocolConverter.asTextDocumentPositionParams(document, position); - languageClient.sendRequest(ReferencesRequest.type, param).then(locations => { - commands.executeCommand(Commands.EDITOR_SHOW_REFERENCES, uri, languageClient.protocol2CodeConverter.asPosition(position), locations.map(languageClient.protocol2CodeConverter.asLocation)); - }) - }) - })); - - setupActionableNotificationListener(languageClient); - - // Handler for 'xml/executeClientCommand` request message that executes a command on the client - languageClient.onRequest(ExecuteClientCommandRequest.type, async (params: ExecuteCommandParams) => { - return await commands.executeCommand(params.command, ...params.arguments); - }); - - // Register custom XML commands - context.subscriptions.push(commands.registerCommand(Commands.VALIDATE_CURRENT_FILE, async (params) => { - const uri = window.activeTextEditor.document.uri; - const identifier = TextDocumentIdentifier.create(uri.toString()); - commands.executeCommand(Commands.EXECUTE_WORKSPACE_COMMAND, Commands.VALIDATE_CURRENT_FILE, identifier). - then(() => { - window.showInformationMessage('The current XML file was successfully validated.'); - }, error => { - window.showErrorMessage('Error during XML validation ' + error.message); - }); - })); - context.subscriptions.push(commands.registerCommand(Commands.VALIDATE_ALL_FILES, async () => { - commands.executeCommand(Commands.EXECUTE_WORKSPACE_COMMAND, Commands.VALIDATE_ALL_FILES). - then(() => { - window.showInformationMessage('All open XML files were successfully validated.'); - }, error => { - window.showErrorMessage('Error during XML validation: ' + error.message); - }); - })); - - // Register client command to execute custom XML Language Server command - context.subscriptions.push(commands.registerCommand(Commands.EXECUTE_WORKSPACE_COMMAND, (command, ...rest) => { - let token: CancellationToken; - let commandArgs: any[] = rest; - if (rest && rest.length && CancellationToken.is(rest[rest.length - 1])) { - token = rest[rest.length - 1]; - commandArgs = rest.slice(0, rest.length - 1); - } - const params: ExecuteCommandParams = { - command, - arguments: commandArgs - }; - if (token) { - return languageClient.sendRequest(ExecuteCommandRequest.type, params, token); - } else { - return languageClient.sendRequest(ExecuteCommandRequest.type, params); - } - })); - - context.subscriptions.push(commands.registerCommand(Commands.OPEN_SETTINGS, async (settingId?: string) => { - commands.executeCommand('workbench.action.openSettings', settingId); - })); - - - // Setup autoCloseTags - const tagProvider = (document: TextDocument, position: Position) => { - let param = languageClient.code2ProtocolConverter.asTextDocumentPositionParams(document, position); - let text = languageClient.sendRequest(TagCloseRequest.type, param); - return text; - }; - context.subscriptions.push(activateTagClosing(tagProvider, { xml: true, xsl: true }, Commands.AUTO_CLOSE_TAGS)); - - if (extensions.onDidChange) {// Theia doesn't support this API yet - context.subscriptions.push(extensions.onDidChange(() => { - onExtensionChange(extensions.all, getXMLConfiguration().get("extension.jars", [])); - })); - } - // Copied from: - // https://github.com/redhat-developer/vscode-java/pull/1081/files - languageClient.onRequest(ConfigurationRequest.type, (params: ConfigurationParams) => { - const result: any[] = []; - const activeEditor: TextEditor | undefined = window.activeTextEditor; - for (const item of params.items) { - if (activeEditor && activeEditor.document.uri.toString() === Uri.parse(item.scopeUri).toString()) { - if (item.section === "xml.format.insertSpaces") { - result.push(activeEditor.options.insertSpaces); - } else if (item.section === "xml.format.tabSize") { - result.push(activeEditor.options.tabSize); - } - } else { - result.push(workspace.getConfiguration(null, Uri.parse(item.scopeUri)).get(item.section)); - } - } - return result; - }); - - const api: XMLExtensionApi = { - // add API set catalogs to internal memory - addXMLCatalogs: (catalogs: string[]) => { - const externalXmlCatalogs = externalXmlSettings.xmlCatalogs; - catalogs.forEach(element => { - if (!externalXmlCatalogs.includes(element)) { - externalXmlCatalogs.push(element); - } - }); - languageClient.sendNotification(DidChangeConfigurationNotification.type, { settings: getXMLSettings(requirements.java_home) }); - onConfigurationChange(); - }, - // remove API set catalogs to internal memory - removeXMLCatalogs: (catalogs: string[]) => { - catalogs.forEach(element => { - const externalXmlCatalogs = externalXmlSettings.xmlCatalogs; - if (externalXmlCatalogs.includes(element)) { - const itemIndex = externalXmlCatalogs.indexOf(element); - externalXmlCatalogs.splice(itemIndex, 1); - } - }); - languageClient.sendNotification(DidChangeConfigurationNotification.type, { settings: getXMLSettings(requirements.java_home) }); - onConfigurationChange(); - }, - // add API set fileAssociations to internal memory - addXMLFileAssociations: (fileAssociations: XMLFileAssociation[]) => { - const externalfileAssociations = externalXmlSettings.xmlFileAssociations; - fileAssociations.forEach(element => { - if (!externalfileAssociations.some(fileAssociation => fileAssociation.systemId === element.systemId)) { - externalfileAssociations.push(element); - } - }); - languageClient.sendNotification(DidChangeConfigurationNotification.type, { settings: getXMLSettings(requirements.java_home) }); - onConfigurationChange(); - }, - // remove API set fileAssociations to internal memory - removeXMLFileAssociations: (fileAssociations: XMLFileAssociation[]) => { - const externalfileAssociations = externalXmlSettings.xmlFileAssociations; - fileAssociations.forEach(element => { - const itemIndex = externalfileAssociations.findIndex(fileAssociation => fileAssociation.systemId === element.systemId) //returns -1 if item not found - if (itemIndex > -1) { - externalfileAssociations.splice(itemIndex, 1); + prepareExecutable(requirements, collectXmlJavaExtensions(extensions.all, getXMLConfiguration().get("extension.jars", [])), context) + .then((serverOptions: Executable) => { + languageClient = new LanguageClient('xml', 'XML Support', serverOptions, clientOptions); + let toDispose = context.subscriptions; + let disposable = languageClient.start(); + toDispose.push(disposable); + + languages.setLanguageConfiguration('xml', getIndentationRules()); + languages.setLanguageConfiguration('xsl', getIndentationRules()); + + return languageClient.onReady().then(() => { + //Detect JDK configuration changes + disposable = subscribeJDKChangeConfiguration(); + toDispose.push(disposable); + + // Code Lens actions + context.subscriptions.push(commands.registerCommand(Commands.SHOW_REFERENCES, (uriString: string, position: Position) => { + const uri = Uri.parse(uriString); + workspace.openTextDocument(uri).then(document => { + // Consume references service from the XML Language Server + let param = languageClient.code2ProtocolConverter.asTextDocumentPositionParams(document, position); + languageClient.sendRequest(ReferencesRequest.type, param).then(locations => { + commands.executeCommand(Commands.EDITOR_SHOW_REFERENCES, uri, languageClient.protocol2CodeConverter.asPosition(position), locations.map(languageClient.protocol2CodeConverter.asLocation)); + }) + }) + })); + + setupActionableNotificationListener(languageClient); + + // Handler for 'xml/executeClientCommand` request message that executes a command on the client + languageClient.onRequest(ExecuteClientCommandRequest.type, async (params: ExecuteCommandParams) => { + return await commands.executeCommand(params.command, ...params.arguments); + }); + + // Register custom XML commands + context.subscriptions.push(commands.registerCommand(Commands.VALIDATE_CURRENT_FILE, async (params) => { + const uri = window.activeTextEditor.document.uri; + const identifier = TextDocumentIdentifier.create(uri.toString()); + commands.executeCommand(Commands.EXECUTE_WORKSPACE_COMMAND, Commands.VALIDATE_CURRENT_FILE, identifier). + then(() => { + window.showInformationMessage('The current XML file was successfully validated.'); + }, error => { + window.showErrorMessage('Error during XML validation ' + error.message); + }); + })); + context.subscriptions.push(commands.registerCommand(Commands.VALIDATE_ALL_FILES, async () => { + commands.executeCommand(Commands.EXECUTE_WORKSPACE_COMMAND, Commands.VALIDATE_ALL_FILES). + then(() => { + window.showInformationMessage('All open XML files were successfully validated.'); + }, error => { + window.showErrorMessage('Error during XML validation: ' + error.message); + }); + })); + + // Register client command to execute custom XML Language Server command + context.subscriptions.push(commands.registerCommand(Commands.EXECUTE_WORKSPACE_COMMAND, (command, ...rest) => { + let token: CancellationToken; + let commandArgs: any[] = rest; + if (rest && rest.length && CancellationToken.is(rest[rest.length - 1])) { + token = rest[rest.length - 1]; + commandArgs = rest.slice(0, rest.length - 1); + } + const params: ExecuteCommandParams = { + command, + arguments: commandArgs + }; + if (token) { + return languageClient.sendRequest(ExecuteCommandRequest.type, params, token); + } else { + return languageClient.sendRequest(ExecuteCommandRequest.type, params); + } + })); + + context.subscriptions.push(commands.registerCommand(Commands.OPEN_SETTINGS, async (settingId?: string) => { + commands.executeCommand('workbench.action.openSettings', settingId); + })); + + // Setup autoCloseTags + const tagProvider = (document: TextDocument, position: Position) => { + let param = languageClient.code2ProtocolConverter.asTextDocumentPositionParams(document, position); + let text = languageClient.sendRequest(TagCloseRequest.type, param); + return text; + }; + context.subscriptions.push(activateTagClosing(tagProvider, { xml: true, xsl: true }, Commands.AUTO_CLOSE_TAGS)); + + if (extensions.onDidChange) {// Theia doesn't support this API yet + context.subscriptions.push(extensions.onDidChange(() => { + onExtensionChange(extensions.all, getXMLConfiguration().get("extension.jars", [])); + })); } + + // Copied from: + // https://github.com/redhat-developer/vscode-java/pull/1081/files + languageClient.onRequest(ConfigurationRequest.type, (params: ConfigurationParams) => { + const result: any[] = []; + const activeEditor: TextEditor | undefined = window.activeTextEditor; + for (const item of params.items) { + if (activeEditor && activeEditor.document.uri.toString() === Uri.parse(item.scopeUri).toString()) { + if (item.section === "xml.format.insertSpaces") { + result.push(activeEditor.options.insertSpaces); + } else if (item.section === "xml.format.tabSize") { + result.push(activeEditor.options.tabSize); + } + } else { + result.push(workspace.getConfiguration(null, Uri.parse(item.scopeUri)).get(item.section)); + } + } + return result; + }); + + const api: XMLExtensionApi = { + // add API set catalogs to internal memory + addXMLCatalogs: (catalogs: string[]) => { + const externalXmlCatalogs = externalXmlSettings.xmlCatalogs; + catalogs.forEach(element => { + if (!externalXmlCatalogs.includes(element)) { + externalXmlCatalogs.push(element); + } + }); + languageClient.sendNotification(DidChangeConfigurationNotification.type, { settings: getXMLSettings(requirements.java_home) }); + onConfigurationChange(); + }, + // remove API set catalogs to internal memory + removeXMLCatalogs: (catalogs: string[]) => { + catalogs.forEach(element => { + const externalXmlCatalogs = externalXmlSettings.xmlCatalogs; + if (externalXmlCatalogs.includes(element)) { + const itemIndex = externalXmlCatalogs.indexOf(element); + externalXmlCatalogs.splice(itemIndex, 1); + } + }); + languageClient.sendNotification(DidChangeConfigurationNotification.type, { settings: getXMLSettings(requirements.java_home) }); + onConfigurationChange(); + }, + // add API set fileAssociations to internal memory + addXMLFileAssociations: (fileAssociations: XMLFileAssociation[]) => { + const externalfileAssociations = externalXmlSettings.xmlFileAssociations; + fileAssociations.forEach(element => { + if (!externalfileAssociations.some(fileAssociation => fileAssociation.systemId === element.systemId)) { + externalfileAssociations.push(element); + } + }); + languageClient.sendNotification(DidChangeConfigurationNotification.type, { settings: getXMLSettings(requirements.java_home) }); + onConfigurationChange(); + }, + // remove API set fileAssociations to internal memory + removeXMLFileAssociations: (fileAssociations: XMLFileAssociation[]) => { + const externalfileAssociations = externalXmlSettings.xmlFileAssociations; + fileAssociations.forEach(element => { + const itemIndex = externalfileAssociations.findIndex(fileAssociation => fileAssociation.systemId === element.systemId) //returns -1 if item not found + if (itemIndex > -1) { + externalfileAssociations.splice(itemIndex, 1); + } + }); + languageClient.sendNotification(DidChangeConfigurationNotification.type, { settings: getXMLSettings(requirements.java_home) }); + onConfigurationChange(); + } + }; + return api; }); - languageClient.sendNotification(DidChangeConfigurationNotification.type, { settings: getXMLSettings(requirements.java_home) }); - onConfigurationChange(); - } - }; - return api; + }); }); - }); /** * Returns a json object with key 'xml' and a json object value that @@ -401,7 +383,7 @@ export function activate(context: ExtensionContext) { * 'xml': {...} * } */ - function getXMLSettings(javaHome: string): JSON { + function getXMLSettings(javaHome: string | undefined): JSON { let configXML = workspace.getConfiguration().get('xml'); let xml; if (!configXML) { //Set default preferences if not provided diff --git a/src/javaServerStarter.ts b/src/javaServerStarter.ts index eb1ca67f..e1c98cb2 100644 --- a/src/javaServerStarter.ts +++ b/src/javaServerStarter.ts @@ -1,24 +1,25 @@ -import { workspace, ExtensionContext } from 'vscode' -import { Executable, ExecutableOptions } from 'vscode-languageclient'; -import { RequirementsData } from './requirements'; import * as os from 'os'; import * as path from 'path'; -import { xmlServerVmargs, getJavaagentFlag, getKey, IS_WORKSPACE_VMARGS_XML_ALLOWED, getXMLConfiguration } from './settings'; +import { ExtensionContext, workspace } from 'vscode'; +import { Executable } from 'vscode-languageclient'; +import { RequirementsData } from './requirements'; +import { getJavaagentFlag, getKey, getXMLConfiguration, IS_WORKSPACE_VMARGS_XML_ALLOWED, xmlServerVmargs } from './settings'; const glob = require('glob'); declare var v8debug; const DEBUG = (typeof v8debug === 'object') || startedInDebugMode(); -export function prepareExecutable(requirements: RequirementsData, xmlJavaExtensions: string[], context: ExtensionContext): Executable { - let executable: Executable = Object.create(null); - let options: ExecutableOptions = Object.create(null); - options.env = process.env; - options.stdio = 'pipe'; - executable.options = options; - executable.command = path.resolve(requirements.java_home + '/bin/java'); - executable.args = prepareParams(requirements, xmlJavaExtensions, context); - return executable; +export async function prepareJavaExecutable( + context: ExtensionContext, + requirements: RequirementsData, + xmlJavaExtensions: string[] +): Promise { + + return { + command: path.resolve(requirements.java_home + '/bin/java'), + args: prepareParams(requirements, xmlJavaExtensions, context) + } as Executable; } function prepareParams(requirements: RequirementsData, xmlJavaExtensions: string[], context: ExtensionContext): string[] { @@ -69,7 +70,7 @@ function prepareParams(requirements: RequirementsData, xmlJavaExtensions: string if (xmlJavaExtensions.length > 0) { const pathSeparator = os.platform() == 'win32' ? ';' : ':'; xmlJavaExtensionsClasspath = pathSeparator + xmlJavaExtensions.join(pathSeparator); - } + } params.push('-cp'); params.push(path.resolve(server_home, launchersFound[0]) + xmlJavaExtensionsClasspath); params.push('org.eclipse.lemminx.XMLServerLauncher'); } else { @@ -86,7 +87,7 @@ function startedInDebugMode(): boolean { function hasDebugFlag(args: string[]): boolean { if (args) { // See https://nodejs.org/en/docs/guides/debugging-getting-started/ - return args.some( arg => /^--inspect/.test(arg) || /^--debug/.test(arg)); + return args.some(arg => /^--inspect/.test(arg) || /^--debug/.test(arg)); } return false; } diff --git a/src/requirements.ts b/src/requirements.ts index 04069a33..9b6f7a2c 100644 --- a/src/requirements.ts +++ b/src/requirements.ts @@ -1,15 +1,15 @@ 'use strict'; -import { window, workspace, Uri, ExtensionContext, ConfigurationTarget, env } from 'vscode'; import * as cp from 'child_process'; import * as path from 'path'; -import { IS_WORKSPACE_JDK_XML_ALLOWED, getKey, IS_WORKSPACE_VMARGS_XML_ALLOWED, getJavaagentFlag, IS_WORKSPACE_JDK_ALLOWED, getXMLConfiguration, getJavaConfiguration, xmlServerVmargs } from './settings'; +import { ConfigurationTarget, env, ExtensionContext, Uri, window, workspace } from 'vscode'; +import { getJavaagentFlag, getJavaConfiguration, getKey, getXMLConfiguration, IS_WORKSPACE_JDK_ALLOWED, IS_WORKSPACE_JDK_XML_ALLOWED, IS_WORKSPACE_VMARGS_XML_ALLOWED, xmlServerVmargs } from './settings'; const pathExists = require('path-exists'); const expandHomeDir = require('expand-home-dir'); const findJavaHome = require('find-java-home'); const isWindows = process.platform.indexOf('win') === 0; -const JAVA_FILENAME = 'java' + (isWindows?'.exe':''); +const JAVA_FILENAME = 'java' + (isWindows ? '.exe' : ''); export interface RequirementsData { java_home: string; @@ -32,12 +32,12 @@ interface ErrorData { export async function resolveRequirements(context: ExtensionContext): Promise { const javaHome = await checkJavaRuntime(context); const javaVersion = await checkJavaVersion(javaHome); - return Promise.resolve({ 'java_home': javaHome, 'java_version': javaVersion}); + return Promise.resolve({ 'java_home': javaHome, 'java_version': javaVersion }); } function checkJavaRuntime(context: ExtensionContext): Promise { return new Promise(async (resolve, reject) => { - let source : string; + let source: string; let javaHome = await readXMLJavaHomeConfig(context); if (javaHome) { source = 'The xml.java.home variable defined in VS Code settings'; @@ -55,19 +55,19 @@ function checkJavaRuntime(context: ExtensionContext): Promise { } } } - + if (javaHome) { javaHome = expandHomeDir(javaHome); if (!pathExists.sync(javaHome)) { - openJDKDownload(reject, source+' points to a missing folder'); - } else if (!pathExists.sync(path.resolve(javaHome, 'bin', JAVA_FILENAME))){ - openJDKDownload(reject, source+ ' does not point to a Java runtime.'); + openJDKDownload(reject, source + ' points to a missing folder'); + } else if (!pathExists.sync(path.resolve(javaHome, 'bin', JAVA_FILENAME))) { + openJDKDownload(reject, source + ' does not point to a Java runtime.'); } return resolve(javaHome); } //No settings, let's try to detect as last resort. findJavaHome({ allowJre: true }, function (err, home) { - if (err){ + if (err) { openJDKDownload(reject, 'Java runtime could not be located.'); } else { @@ -142,7 +142,7 @@ async function readJavaHomeConfig(context: ExtensionContext) { return workspace.getConfiguration().inspect('java.home').globalValue; } } - + function checkJavaVersion(java_home: string): Promise { return new Promise((resolve, reject) => { cp.execFile(java_home + '/bin/java', ['-version'], {}, (error, stdout, stderr) => { @@ -157,7 +157,7 @@ function checkJavaVersion(java_home: string): Promise { }); } -export function parseMajorVersion(content:string):number { +export function parseMajorVersion(content: string): number { let regexp = /version "(.*)"/g; let match = regexp.exec(content); if (!match) { @@ -179,15 +179,19 @@ export function parseMajorVersion(content:string):number { return javaVersion; } -function openJDKDownload(reject, cause : string) { - let jdkUrl = 'https://developers.redhat.com/products/openjdk/download/?sc_cid=701f2000000RWTnAAO'; - if (process.platform === 'darwin') { - jdkUrl = 'https://adoptopenjdk.net/releases.html'; - } +function openJDKDownload(reject, cause: string) { reject({ message: cause, label: 'Get the Java runtime', - openUrl: Uri.parse(jdkUrl), + openUrl: getOpenJDKDownloadLink(), replaceClose: false }); } + +export function getOpenJDKDownloadLink(): Uri { + let jdkUrl = 'https://developers.redhat.com/products/openjdk/download/?sc_cid=701f2000000RWTnAAO'; + if (process.platform === 'darwin') { + jdkUrl = 'https://adoptopenjdk.net/releases.html'; + } + return Uri.parse(jdkUrl); +} diff --git a/src/serverStarter.ts b/src/serverStarter.ts new file mode 100644 index 00000000..28b61ff8 --- /dev/null +++ b/src/serverStarter.ts @@ -0,0 +1,57 @@ +import { commands, ConfigurationTarget, ExtensionContext, window } from "vscode"; +import { Executable } from "vscode-languageclient"; +import { prepareBinaryExecutable } from "./binaryServerStarter"; +import { prepareJavaExecutable } from "./javaServerStarter"; +import { getOpenJDKDownloadLink, RequirementsData } from "./requirements"; +import { getXMLConfiguration } from "./settings"; + +/** + * Returns the executable to use to launch LemMinX (the XML Language Server) + * + * @param requirements the java information, or an empty object if there is no java + * @param xmlJavaExtensions a list of all the java extension jars + * @param context the extensions context + * @throws if neither the binary nor the java version of the extension can be launched + * @returns the executable to launch LemMinX with (the XML language server) + */ +export async function prepareExecutable( + requirements: RequirementsData, + xmlJavaExtensions: string[], + context: ExtensionContext): Promise { + + const hasJava: boolean = requirements.java_home !== undefined; + const hasExtensions: boolean = xmlJavaExtensions.length !== 0; + const preferBinary: boolean = getXMLConfiguration().get("server.preferBinary", false); + const silenceExtensionWarning: boolean = getXMLConfiguration().get("server.silenceExtensionWarning", false); + + const useBinary: boolean = (!hasJava) || (preferBinary && !hasExtensions); + + if (hasExtensions && !hasJava && !silenceExtensionWarning) { + const DOWNLOAD_JAVA: string = 'Get Java'; + const CONFIGURE_JAVA: string = 'More Info'; + const DISABLE_WARNING: string = 'Disable Warning'; + window.showInformationMessage('Extensions to the XML language server were detected, but no Java was found. ' + + 'In order to use these extensions, please install and configure a Java runtime (Java 8 or more recent).', + DOWNLOAD_JAVA, CONFIGURE_JAVA, DISABLE_WARNING) + .then((selection: string) => { + if (selection === DOWNLOAD_JAVA) { + commands.executeCommand('vscode.open', getOpenJDKDownloadLink()); + } else if (selection === CONFIGURE_JAVA) { + commands.executeCommand('xml.open.docs', { page: 'Preferences.md', section: 'java-home' }); + } else if (selection === DISABLE_WARNING) { + getXMLConfiguration().update('server.silenceExtensionWarning', true, ConfigurationTarget.Global); + } + }); + } + + if (useBinary) { + return prepareBinaryExecutable(context) + .catch(() => { + if (!hasJava) { + throw new Error("Failed to launch binary XML language server and no Java is installed"); + } + return prepareJavaExecutable(context, requirements, xmlJavaExtensions); + }); + } + return prepareJavaExecutable(context, requirements, xmlJavaExtensions); +}