diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e8edf075..727c8bb1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,7 +12,7 @@ jobs: fail-fast: false matrix: os: [macos-latest, ubuntu-latest, windows-latest] - ghc: [8.10.7, 9.4.8, 9.6.4, 9.8.2] + ghc: [8.10.7, 9.6.7, 9.8.4, 9.12.2] runs-on: ${{ matrix.os }} steps: - name: Checkout diff --git a/eslint.config.mjs b/eslint.config.mjs index 32aa338c..5c88b3b4 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,14 +1,31 @@ import globals from 'globals'; -import pluginJs from '@eslint/js'; +import eslint from '@eslint/js'; import tseslint from 'typescript-eslint'; -export default [ +export default tseslint.config( { files: ['**/*.{js,mjs,cjs,ts}'] }, - { languageOptions: { globals: globals.node } }, { - ...pluginJs.configs.recommended, + languageOptions: { + globals: globals.node, + parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname }, + }, + }, + { + // disables type checking for this file only + files: ['eslint.config.mjs'], + extends: [tseslint.configs.disableTypeChecked], + }, + eslint.configs.recommended, + tseslint.configs.recommendedTypeChecked, + { rules: { + // turn off these lints as we access workspaceConfiguration fields. + // So far, there was no bug found with these unsafe accesses. + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + // Sometimes, the 'any' just saves too much time. '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-floating-promises': 'error', '@typescript-eslint/no-unused-vars': [ 'error', { @@ -23,5 +40,4 @@ export default [ ], }, }, - ...tseslint.configs.recommended, -]; +); diff --git a/package.json b/package.json index 96fad689..a286f987 100644 --- a/package.json +++ b/package.json @@ -1310,9 +1310,9 @@ }, "commands": [ { - "command": "haskell.commands.importIdentifier", - "title": "Haskell: Import identifier", - "description": "Imports a function or type based on a Hoogle search" + "command": "haskell.commands.restartExtension", + "title": "Haskell: Restart vscode-haskell extension", + "description": "Restart the vscode-haskell extension. Reloads configuration." }, { "command": "haskell.commands.restartServer", diff --git a/src/commands/constants.ts b/src/commands/constants.ts index 660ec8a3..55806a19 100644 --- a/src/commands/constants.ts +++ b/src/commands/constants.ts @@ -1,4 +1,6 @@ -export const ImportIdentifierCommandName = 'haskell.commands.importIdentifier'; +export const RestartExtensionCommandName = 'haskell.commands.restartExtension'; export const RestartServerCommandName = 'haskell.commands.restartServer'; export const StartServerCommandName = 'haskell.commands.startServer'; export const StopServerCommandName = 'haskell.commands.stopServer'; +export const OpenLogsCommandName = 'haskell.commands.openLogs'; +export const ShowExtensionVersions = 'haskell.commands.showVersions'; diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 00000000..914a51fe --- /dev/null +++ b/src/config.ts @@ -0,0 +1,141 @@ +import { OutputChannel, Uri, window, WorkspaceConfiguration, WorkspaceFolder } from 'vscode'; +import { expandHomeDir, IEnvVars } from './utils'; +import * as path from 'path'; +import { Logger } from 'vscode-languageclient'; +import { ExtensionLogger } from './logger'; +import { GHCupConfig } from './ghcup'; + +export type LogLevel = 'off' | 'messages' | 'verbose'; +export type ClientLogLevel = 'off' | 'error' | 'info' | 'debug'; + +export type Config = { + /** + * Unique name per workspace folder (useful for multi-root workspaces). + */ + langName: string; + logLevel: LogLevel; + clientLogLevel: ClientLogLevel; + logFilePath?: string; + workingDir: string; + outputChannel: OutputChannel; + serverArgs: string[]; + serverEnvironment: IEnvVars; + ghcupConfig: GHCupConfig; +}; + +export function initConfig(workspaceConfig: WorkspaceConfiguration, uri: Uri, folder?: WorkspaceFolder): Config { + // Set a unique name per workspace folder (useful for multi-root workspaces). + const langName = 'Haskell' + (folder ? ` (${folder.name})` : ''); + const currentWorkingDir = folder ? folder.uri.fsPath : path.dirname(uri.fsPath); + + const logLevel = getLogLevel(workspaceConfig); + const clientLogLevel = getClientLogLevel(workspaceConfig); + + const logFile = getLogFile(workspaceConfig); + const logFilePath = resolveLogFilePath(logFile, currentWorkingDir); + + const outputChannel: OutputChannel = window.createOutputChannel(langName); + const serverArgs = getServerArgs(workspaceConfig, logLevel, logFilePath); + + return { + langName: langName, + logLevel: logLevel, + clientLogLevel: clientLogLevel, + logFilePath: logFilePath, + workingDir: currentWorkingDir, + outputChannel: outputChannel, + serverArgs: serverArgs, + serverEnvironment: workspaceConfig.serverEnvironment, + ghcupConfig: { + metadataUrl: workspaceConfig.metadataURL as string, + upgradeGHCup: workspaceConfig.get('upgradeGHCup') as boolean, + executablePath: workspaceConfig.get('ghcupExecutablePath') as string, + }, + }; +} + +export function initLoggerFromConfig(config: Config): ExtensionLogger { + return new ExtensionLogger('client', config.clientLogLevel, config.outputChannel, config.logFilePath); +} + +export function logConfig(logger: Logger, config: Config) { + if (config.logFilePath) { + logger.info(`Writing client log to file ${config.logFilePath}`); + } + logger.log('Environment variables:'); + Object.entries(process.env).forEach(([key, value]: [string, string | undefined]) => { + // only list environment variables that we actually care about. + // this makes it safe for users to just paste the logs to whoever, + // and avoids leaking secrets. + if (['PATH'].includes(key)) { + logger.log(` ${key}: ${value}`); + } + }); +} + +function getLogFile(workspaceConfig: WorkspaceConfiguration) { + const logFile_: unknown = workspaceConfig.logFile; + let logFile: string | undefined; + if (typeof logFile_ === 'string') { + logFile = logFile_ !== '' ? logFile_ : undefined; + } + return logFile; +} + +function getClientLogLevel(workspaceConfig: WorkspaceConfiguration): ClientLogLevel { + const clientLogLevel_: unknown = workspaceConfig.trace.client; + let clientLogLevel; + if (typeof clientLogLevel_ === 'string') { + switch (clientLogLevel_) { + case 'off': + case 'error': + case 'info': + case 'debug': + clientLogLevel = clientLogLevel_; + break; + default: + throw new Error("Option \"haskell.trace.client\" is expected to be one of 'off', 'error', 'info', 'debug'."); + } + } else { + throw new Error('Option "haskell.trace.client" is expected to be a string'); + } + return clientLogLevel; +} + +function getLogLevel(workspaceConfig: WorkspaceConfiguration): LogLevel { + const logLevel_: unknown = workspaceConfig.trace.server; + let logLevel; + if (typeof logLevel_ === 'string') { + switch (logLevel_) { + case 'off': + case 'messages': + case 'verbose': + logLevel = logLevel_; + break; + default: + throw new Error("Option \"haskell.trace.server\" is expected to be one of 'off', 'messages', 'verbose'."); + } + } else { + throw new Error('Option "haskell.trace.server" is expected to be a string'); + } + return logLevel; +} + +function resolveLogFilePath(logFile: string | undefined, currentWorkingDir: string): string | undefined { + return logFile !== undefined ? path.resolve(currentWorkingDir, expandHomeDir(logFile)) : undefined; +} + +function getServerArgs(workspaceConfig: WorkspaceConfiguration, logLevel: LogLevel, logFilePath?: string): string[] { + const serverArgs = ['--lsp'] + .concat(logLevel === 'messages' ? ['-d'] : []) + .concat(logFilePath !== undefined ? ['-l', logFilePath] : []); + + const rawExtraArgs: unknown = workspaceConfig.serverExtraArgs; + if (typeof rawExtraArgs === 'string' && rawExtraArgs !== '') { + const e = rawExtraArgs.split(' '); + serverArgs.push(...e); + } + + // We don't want empty strings in our args + return serverArgs.map((x) => x.trim()).filter((x) => x !== ''); +} diff --git a/src/docsBrowser.ts b/src/docsBrowser.ts index 0d9ec779..aed1e196 100644 --- a/src/docsBrowser.ts +++ b/src/docsBrowser.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import { dirname } from 'path'; import { CancellationToken, @@ -51,7 +50,7 @@ async function showDocumentation({ const bytes = await workspace.fs.readFile(Uri.parse(localPath)); const addBase = ` - + `; panel.webview.html = ` @@ -63,8 +62,10 @@ async function showDocumentation({ `; - } catch (e: any) { - await window.showErrorMessage(e); + } catch (e) { + if (e instanceof Error) { + await window.showErrorMessage(e.message); + } } return panel; } @@ -87,8 +88,10 @@ async function openDocumentationOnHackage({ if (inWebView) { await commands.executeCommand('workbench.action.closeActiveEditor'); } - } catch (e: any) { - await window.showErrorMessage(e); + } catch (e) { + if (e instanceof Error) { + await window.showErrorMessage(e.message); + } } } @@ -154,11 +157,9 @@ function processLink(ms: MarkdownString | MarkedString): string | MarkdownString cmd = 'command:haskell.showDocumentation?' + encoded; } return `[${title}](${cmd})`; - } else if (title === 'Source') { - hackageUri = `https://hackage.haskell.org/package/${packageName}/docs/src/${fileAndAnchor.replace( - /-/gi, - '.', - )}`; + } else if (title === 'Source' && typeof fileAndAnchor === 'string') { + const moduleLocation = fileAndAnchor.replace(/-/gi, '.'); + hackageUri = `https://hackage.haskell.org/package/${packageName}/docs/src/${moduleLocation}`; const encoded = encodeURIComponent(JSON.stringify({ title, localPath, hackageUri })); let cmd: string; if (openSourceInHackage) { @@ -174,7 +175,7 @@ function processLink(ms: MarkdownString | MarkedString): string | MarkdownString ); } if (typeof ms === 'string') { - return transform(ms as string); + return transform(ms); } else if (ms instanceof MarkdownString) { const mstr = new MarkdownString(transform(ms.value)); mstr.isTrusted = true; diff --git a/src/errors.ts b/src/errors.ts index 5bcb92d1..25ed6edc 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -20,8 +20,6 @@ export class MissingToolError extends HlsError { prettyTool = 'GHCup'; break; case 'haskell-language-server': - prettyTool = 'HLS'; - break; case 'hls': prettyTool = 'HLS'; break; diff --git a/src/extension.ts b/src/extension.ts index 480ba77a..b3ef1e11 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,15 +1,4 @@ -import * as path from 'path'; -import { - commands, - env, - ExtensionContext, - OutputChannel, - TextDocument, - Uri, - window, - workspace, - WorkspaceFolder, -} from 'vscode'; +import { commands, env, ExtensionContext, TextDocument, Uri, window, workspace, WorkspaceFolder } from 'vscode'; import { ExecutableOptions, LanguageClient, @@ -18,68 +7,111 @@ import { RevealOutputChannelOn, ServerOptions, } from 'vscode-languageclient/node'; -import { RestartServerCommandName, StartServerCommandName, StopServerCommandName } from './commands/constants'; +import * as constants from './commands/constants'; import * as DocsBrowser from './docsBrowser'; import { HlsError, MissingToolError, NoMatchingHls } from './errors'; -import { callAsync, findHaskellLanguageServer, IEnvVars } from './hlsBinaries'; -import { addPathToProcessPath, comparePVP, expandHomeDir, ExtensionLogger } from './utils'; +import { findHaskellLanguageServer, HlsExecutable, IEnvVars } from './hlsBinaries'; +import { addPathToProcessPath, comparePVP, callAsync } from './utils'; +import { Config, initConfig, initLoggerFromConfig, logConfig } from './config'; +import { HaskellStatusBar } from './statusBar'; + +/** + * Global information about the running clients. + */ +type Client = { + client: LanguageClient; + config: Config; +}; // The current map of documents & folders to language servers. // It may be null to indicate that we are in the process of launching a server, // in which case don't try to launch another one for that uri -const clients: Map = new Map(); +const clients: Map = new Map(); // This is the entrypoint to our extension export async function activate(context: ExtensionContext) { + const statusBar = new HaskellStatusBar(context.extension.packageJSON.version as string | undefined); + context.subscriptions.push(statusBar); + // (Possibly) launch the language server every time a document is opened, so // it works across multiple workspace folders. Eventually, haskell-lsp should // just support // https://microsoft.github.io/language-server-protocol/specifications/specification-3-15/#workspace_workspaceFolders // and then we can just launch one server - workspace.onDidOpenTextDocument(async (document: TextDocument) => await activeServer(context, document)); - workspace.textDocuments.forEach(async (document: TextDocument) => await activeServer(context, document)); + workspace.onDidOpenTextDocument(async (document: TextDocument) => await activateServer(context, document)); + for (const document of workspace.textDocuments) { + await activateServer(context, document); + } // Stop the server from any workspace folders that are removed. - workspace.onDidChangeWorkspaceFolders((event) => { + workspace.onDidChangeWorkspaceFolders(async (event) => { for (const folder of event.removed) { const client = clients.get(folder.uri.toString()); if (client) { const uri = folder.uri.toString(); - client.info(`Deleting folder for clients: ${uri}`); + client.client.info(`Deleting folder for clients: ${uri}`); clients.delete(uri); - client.info('Stopping the server'); - client.stop(); + client.client.info('Stopping the server'); + await client.client.stop(); } } }); // Register editor commands for HIE, but only register the commands once at activation. - const restartCmd = commands.registerCommand(RestartServerCommandName, async () => { + const restartCmd = commands.registerCommand(constants.RestartServerCommandName, async () => { for (const langClient of clients.values()) { - langClient?.info('Stopping the server'); - await langClient?.stop(); - langClient?.info('Starting the server'); - langClient?.start(); + langClient?.client.info('Stopping the server'); + await langClient?.client.stop(); + langClient?.client.info('Starting the server'); + await langClient?.client.start(); } }); context.subscriptions.push(restartCmd); - const stopCmd = commands.registerCommand(StopServerCommandName, async () => { + const openLogsCmd = commands.registerCommand(constants.OpenLogsCommandName, () => { + for (const langClient of clients.values()) { + langClient?.config.outputChannel.show(); + } + }); + + context.subscriptions.push(openLogsCmd); + + const restartExtensionCmd = commands.registerCommand(constants.RestartExtensionCommandName, async () => { + for (const langClient of clients.values()) { + langClient?.client.info('Stopping the server'); + await langClient?.client.stop(); + } + clients.clear(); + + for (const document of workspace.textDocuments) { + await activateServer(context, document); + } + }); + + context.subscriptions.push(restartExtensionCmd); + + const showVersionsCmd = commands.registerCommand(constants.ShowExtensionVersions, () => { + void window.showInformationMessage(`Extension Version: ${context.extension.packageJSON.version ?? ''}`); + }); + + context.subscriptions.push(showVersionsCmd); + + const stopCmd = commands.registerCommand(constants.StopServerCommandName, async () => { for (const langClient of clients.values()) { - langClient?.info('Stopping the server'); - await langClient?.stop(); - langClient?.info('Server stopped'); + langClient?.client.info('Stopping the server'); + await langClient?.client.stop(); + langClient?.client.info('Server stopped'); } }); context.subscriptions.push(stopCmd); - const startCmd = commands.registerCommand(StartServerCommandName, async () => { + const startCmd = commands.registerCommand(constants.StartServerCommandName, async () => { for (const langClient of clients.values()) { - langClient?.info('Starting the server'); - langClient?.start(); - langClient?.info('Server started'); + langClient?.client.info('Starting the server'); + await langClient?.client.start(); + langClient?.client.info('Server started'); } }); @@ -91,9 +123,12 @@ export async function activate(context: ExtensionContext) { const openOnHackageDisposable = DocsBrowser.registerDocsOpenOnHackage(); context.subscriptions.push(openOnHackageDisposable); + + statusBar.refresh(); + statusBar.show(); } -async function activeServer(context: ExtensionContext, document: TextDocument) { +async function activateServer(context: ExtensionContext, document: TextDocument) { // We are only interested in Haskell files. if ( (document.languageId !== 'haskell' && @@ -107,114 +142,48 @@ async function activeServer(context: ExtensionContext, document: TextDocument) { const uri = document.uri; const folder = workspace.getWorkspaceFolder(uri); - activateServerForFolder(context, uri, folder); + await activateServerForFolder(context, uri, folder); } async function activateServerForFolder(context: ExtensionContext, uri: Uri, folder?: WorkspaceFolder) { const clientsKey = folder ? folder.uri.toString() : uri.toString(); - // Set a unique name per workspace folder (useful for multi-root workspaces). - const langName = 'Haskell' + (folder ? ` (${folder.name})` : ''); - // If the client already has an LSP server for this uri/folder, then don't start a new one. if (clients.has(clientsKey)) { return; } - - const currentWorkingDir = folder ? folder.uri.fsPath : path.dirname(uri.fsPath); - // Set the key to null to prevent multiple servers being launched at once clients.set(clientsKey, null); - const logLevel = workspace.getConfiguration('haskell', uri).trace.server; - const clientLogLevel = workspace.getConfiguration('haskell', uri).trace.client; - const logFile: string = workspace.getConfiguration('haskell', uri).logFile; + const config = initConfig(workspace.getConfiguration('haskell', uri), uri, folder); + const logger: Logger = initLoggerFromConfig(config); - const outputChannel: OutputChannel = window.createOutputChannel(langName); + logConfig(logger, config); - const logFilePath = logFile !== '' ? path.resolve(currentWorkingDir, expandHomeDir(logFile)) : undefined; - const logger: Logger = new ExtensionLogger('client', clientLogLevel, outputChannel, logFilePath); - if (logFilePath) { - logger.info(`Writing client log to file ${logFilePath}`); - } - logger.log('Environment variables:'); - Object.entries(process.env).forEach(([key, value]: [string, string | undefined]) => { - // only list environment variables that we actually care about. - // this makes it safe for users to just paste the logs to whoever, - // and avoids leaking secrets. - if (['PATH'].includes(key)) { - logger.log(` ${key}: ${value}`); - } - }); - - let serverExecutable: string; - let addInternalServerPath: string | undefined; // if we download HLS, add that bin dir to PATH + let hlsExecutable: HlsExecutable; try { - [serverExecutable, addInternalServerPath] = await findHaskellLanguageServer( - context, - logger, - currentWorkingDir, - folder, - ); - if (!serverExecutable) { - return; - } + hlsExecutable = await findHaskellLanguageServer(context, logger, config.ghcupConfig, config.workingDir, folder); } catch (e) { - if (e instanceof MissingToolError) { - const link = e.installLink(); - if (link) { - if (await window.showErrorMessage(e.message, `Install ${e.tool}`)) { - env.openExternal(link); - } - } else { - await window.showErrorMessage(e.message); - } - } else if (e instanceof HlsError) { - logger.error(`General HlsError: ${e.message}`); - window.showErrorMessage(e.message); - } else if (e instanceof NoMatchingHls) { - const link = e.docLink(); - logger.error(`${e.message}`); - if (await window.showErrorMessage(e.message, 'Open documentation')) { - env.openExternal(link); - } - } else if (e instanceof Error) { - logger.error(`Internal Error: ${e.message}`); - window.showErrorMessage(e.message); - } - if (e instanceof Error) { - // general stack trace printing - if (e.stack) { - logger.error(`${e.stack}`); - } - } + await handleInitializationError(e, logger); return; } - let args: string[] = ['--lsp']; - - if (logLevel === 'messages') { - args = args.concat(['-d']); - } - - if (logFile !== '') { - args = args.concat(['-l', logFile]); - } - - const extraArgs: string = workspace.getConfiguration('haskell', uri).serverExtraArgs; - if (extraArgs !== '') { - args = args.concat(extraArgs.split(' ')); - } + const serverEnvironment: IEnvVars = initServerEnvironment(config, hlsExecutable); + const exeOptions: ExecutableOptions = { + cwd: config.workingDir, + env: { ...process.env, ...serverEnvironment }, + }; - const cabalFileSupport: 'automatic' | 'enable' | 'disable' = workspace.getConfiguration( - 'haskell', - uri, - ).supportCabalFiles; - logger.info(`Support for '.cabal' files: ${cabalFileSupport}`); + // For our intents and purposes, the server should be launched the same way in + // both debug and run mode. + const serverOptions: ServerOptions = { + run: { command: hlsExecutable.location, args: config.serverArgs, options: exeOptions }, + debug: { command: hlsExecutable.location, args: config.serverArgs, options: exeOptions }, + }; // If we're operating on a standalone file (i.e. not in a folder) then we need // to launch the server in a reasonable current directory. Otherwise the cradle // guessing logic in hie-bios will be wrong! - let cwdMsg = `Activating the language server in working dir: ${currentWorkingDir}`; + let cwdMsg = `Activating the language server in working dir: ${config.workingDir}`; if (folder) { cwdMsg += ' (the workspace folder)'; } else { @@ -222,31 +191,8 @@ async function activateServerForFolder(context: ExtensionContext, uri: Uri, fold } logger.info(cwdMsg); - let serverEnvironment: IEnvVars = await workspace.getConfiguration('haskell', uri).serverEnvironment; - if (addInternalServerPath !== undefined) { - const newPath = await addPathToProcessPath(addInternalServerPath); - serverEnvironment = { - ...serverEnvironment, - ...{ PATH: newPath }, - }; - } - const exeOptions: ExecutableOptions = { - cwd: folder ? folder.uri.fsPath : path.dirname(uri.fsPath), - env: { ...process.env, ...serverEnvironment }, - }; - - // We don't want empty strings in our args - args = args.map((x) => x.trim()).filter((x) => x !== ''); - - // For our intents and purposes, the server should be launched the same way in - // both debug and run mode. - const serverOptions: ServerOptions = { - run: { command: serverExecutable, args, options: exeOptions }, - debug: { command: serverExecutable, args, options: exeOptions }, - }; - - logger.info(`run command: ${serverExecutable} ${args.join(' ')}`); - logger.info(`debug command: ${serverExecutable} ${args.join(' ')}`); + logger.info(`run command: ${hlsExecutable.location} ${config.serverArgs.join(' ')}`); + logger.info(`debug command: ${hlsExecutable.location} ${config.serverArgs.join(' ')}`); if (exeOptions.cwd) { logger.info(`server cwd: ${exeOptions.cwd}`); } @@ -268,13 +214,19 @@ async function activateServerForFolder(context: ExtensionContext, uri: Uri, fold const documentSelector = [...haskellDocumentSelector]; + const cabalFileSupport: 'automatic' | 'enable' | 'disable' = workspace.getConfiguration( + 'haskell', + uri, + ).supportCabalFiles; + logger.info(`Support for '.cabal' files: ${cabalFileSupport}`); + switch (cabalFileSupport) { - case 'automatic': + case 'automatic': { const hlsVersion = await callAsync( - serverExecutable, + hlsExecutable.location, ['--numeric-version'], logger, - currentWorkingDir, + config.workingDir, undefined /* this command is very fast, don't show anything */, false, serverEnvironment, @@ -284,6 +236,7 @@ async function activateServerForFolder(context: ExtensionContext, uri: Uri, fold documentSelector.push(cabalDocumentSelector); } break; + } case 'enable': documentSelector.push(cabalDocumentSelector); break; @@ -301,10 +254,10 @@ async function activateServerForFolder(context: ExtensionContext, uri: Uri, fold // Synchronize the setting section 'haskell' to the server. configurationSection: 'haskell', }, - diagnosticCollectionName: langName, + diagnosticCollectionName: config.langName, revealOutputChannelOn: RevealOutputChannelOn.Never, - outputChannel, - outputChannelName: langName, + outputChannel: config.outputChannel, + outputChannelName: config.langName, middleware: { provideHover: DocsBrowser.hoverLinksMiddlewareHook, provideCompletionItem: DocsBrowser.completionLinksMiddlewareHook, @@ -314,15 +267,67 @@ async function activateServerForFolder(context: ExtensionContext, uri: Uri, fold }; // Create the LSP client. - const langClient = new LanguageClient('haskell', langName, serverOptions, clientOptions); + const langClient = new LanguageClient('haskell', config.langName, serverOptions, clientOptions); // Register ClientCapabilities for stuff like window/progress langClient.registerProposedFeatures(); // Finally start the client and add it to the list of clients. logger.info('Starting language server'); - langClient.start(); - clients.set(clientsKey, langClient); + clients.set(clientsKey, { + client: langClient, + config, + }); + await langClient.start(); +} + +/** + * Handle errors the extension may throw. Errors are expected to be fatal. + * + * @param e Error thrown during the extension initialization. + * @param logger + */ +async function handleInitializationError(e: unknown, logger: Logger) { + if (e instanceof MissingToolError) { + const link = e.installLink(); + if (link) { + if (await window.showErrorMessage(e.message, `Install ${e.tool}`)) { + env.openExternal(link); + } + } else { + await window.showErrorMessage(e.message); + } + } else if (e instanceof HlsError) { + logger.error(`General HlsError: ${e.message}`); + window.showErrorMessage(e.message); + } else if (e instanceof NoMatchingHls) { + const link = e.docLink(); + logger.error(`${e.message}`); + if (await window.showErrorMessage(e.message, 'Open documentation')) { + env.openExternal(link); + } + } else if (e instanceof Error) { + logger.error(`Internal Error: ${e.message}`); + window.showErrorMessage(e.message); + } + if (e instanceof Error) { + // general stack trace printing + if (e.stack) { + logger.error(`${e.stack}`); + } + } +} + +function initServerEnvironment(config: Config, hlsExecutable: HlsExecutable) { + let serverEnvironment: IEnvVars = config.serverEnvironment; + if (hlsExecutable.tag === 'ghcup') { + const newPath = addPathToProcessPath(hlsExecutable.binaryDirectory); + serverEnvironment = { + ...serverEnvironment, + ...{ PATH: newPath }, + }; + } + return serverEnvironment; } /* @@ -332,7 +337,7 @@ export async function deactivate() { const promises: Thenable[] = []; for (const client of clients.values()) { if (client) { - promises.push(client.stop()); + promises.push(client.client.stop()); } } await Promise.all(promises); diff --git a/src/ghcup.ts b/src/ghcup.ts new file mode 100644 index 00000000..b759d58c --- /dev/null +++ b/src/ghcup.ts @@ -0,0 +1,171 @@ +import * as path from 'path'; +import * as os from 'os'; +import * as process from 'process'; +import { WorkspaceFolder } from 'vscode'; +import { Logger } from 'vscode-languageclient'; +import { MissingToolError } from './errors'; +import { resolvePathPlaceHolders, executableExists, callAsync, ProcessCallback, IEnvVars } from './utils'; +import { match } from 'ts-pattern'; + +export type Tool = 'hls' | 'ghc' | 'cabal' | 'stack'; + +export type ToolConfig = Map; + +export function initDefaultGHCup(config: GHCupConfig, logger: Logger, folder?: WorkspaceFolder): GHCup { + const ghcupLoc = findGHCup(logger, config.executablePath, folder); + return new GHCup(logger, ghcupLoc, config, { + // omit colourful output because the logs are uglier + NO_COLOR: '1', + }); +} + +export type GHCupConfig = { + metadataUrl?: string; + upgradeGHCup: boolean; + executablePath?: string; +}; + +export class GHCup { + constructor( + readonly logger: Logger, + readonly location: string, + readonly config: GHCupConfig, + readonly environment: IEnvVars, + ) {} + + /** + * Most generic way to run the `ghcup` binary. + * @param args Arguments to run the `ghcup` binary with. + * @param title Displayed to the user for long-running tasks. + * @param cancellable Whether this invocation can be cancelled by the user. + * @param callback Handle success or failures. + * @returns The output of the `ghcup` invocation. If no {@link callback} is given, this is the stdout. Otherwise, whatever {@link callback} produces. + */ + public async call( + args: string[], + title?: string, + cancellable?: boolean, + callback?: ProcessCallback, + ): Promise { + const metadataUrl = this.config.metadataUrl; // ; + return await callAsync( + this.location, + ['--no-verbose'].concat(metadataUrl ? ['-s', metadataUrl] : []).concat(args), + this.logger, + undefined, + title, + cancellable, + this.environment, + callback, + ); + } + + /** + * Upgrade the `ghcup` binary unless this option was disabled by the user. + */ + public async upgrade(): Promise { + const upgrade = this.config.upgradeGHCup; // workspace.getConfiguration('haskell').get('upgradeGHCup') as boolean; + if (upgrade) { + await this.call(['upgrade'], 'Upgrading ghcup', true); + } + } + + /** + * Find the latest version of a {@link Tool} that we can find in GHCup. + * Prefer already installed versions, but fall back to all available versions, if there aren't any. + * @param tool Tool you want to know the latest version of. + * @returns The latest installed or generally available version of the {@link tool} + */ + public async getLatestVersion(tool: Tool): Promise { + // these might be custom/stray/compiled, so we try first + const installedVersions = await this.call(['list', '-t', tool, '-c', 'installed', '-r'], undefined, false); + const latestInstalled = installedVersions.split(/\r?\n/).pop(); + if (latestInstalled) { + return latestInstalled.split(/\s+/)[1]; + } + + return this.getLatestAvailableVersion(tool); + } + + /** + * Find the latest available version that we can find in GHCup with a certain {@link tag}. + * Corresponds to the `ghcup list -t -c available -r` command. + * The tag can be used to further filter the list of versions, for example you can provide + * @param tool Tool you want to know the latest version of. + * @param tag The tag to filter the available versions with. By default `"latest"`. + * @returns The latest available version filtered by {@link tag}. + */ + public async getLatestAvailableVersion(tool: Tool, tag: string = 'latest'): Promise { + // fall back to installable versions + const availableVersions = await this.call(['list', '-t', tool, '-c', 'available', '-r'], undefined, false).then( + (s) => s.split(/\r?\n/), + ); + + let latestAvailable: string | null = null; + availableVersions.forEach((ver) => { + if (ver.split(/\s+/)[2].split(',').includes(tag)) { + latestAvailable = ver.split(/\s+/)[1]; + } + }); + if (!latestAvailable) { + throw new Error(`Unable to find ${tag} tool ${tool}`); + } else { + return latestAvailable; + } + } +} + +function findGHCup(logger: Logger, exePath?: string, folder?: WorkspaceFolder): string { + logger.info('Checking for ghcup installation'); + if (exePath) { + logger.info(`Trying to find the ghcup executable in: ${exePath}`); + exePath = resolvePathPlaceHolders(exePath, folder); + logger.log(`Location after path variables substitution: ${exePath}`); + if (executableExists(exePath)) { + return exePath; + } else { + throw new Error(`Could not find a ghcup binary at ${exePath}!`); + } + } else { + const localGHCup = ['ghcup'].find(executableExists); + if (!localGHCup) { + logger.info(`probing for GHCup binary`); + const ghcupExe: string | null = match(process.platform) + .with('win32', () => { + const ghcupPrefix = process.env.GHCUP_INSTALL_BASE_PREFIX; + if (ghcupPrefix) { + return path.join(ghcupPrefix, 'ghcup', 'bin', 'ghcup.exe'); + } else { + return path.join('C:\\', 'ghcup', 'bin', 'ghcup.exe'); + } + }) + .otherwise(() => { + const useXDG = process.env.GHCUP_USE_XDG_DIRS; + if (useXDG) { + const xdgBin = process.env.XDG_BIN_HOME; + if (xdgBin) { + return path.join(xdgBin, 'ghcup'); + } else { + return path.join(os.homedir(), '.local', 'bin', 'ghcup'); + } + } else { + const ghcupPrefix = process.env.GHCUP_INSTALL_BASE_PREFIX; + if (ghcupPrefix) { + return path.join(ghcupPrefix, '.ghcup', 'bin', 'ghcup'); + } else { + return path.join(os.homedir(), '.ghcup', 'bin', 'ghcup'); + } + } + }); + if (ghcupExe !== null && executableExists(ghcupExe)) { + return ghcupExe; + } else { + logger.warn(`ghcup at ${ghcupExe} does not exist`); + throw new MissingToolError('ghcup'); + } + } else { + logger.info(`found ghcup at ${localGHCup}`); + return localGHCup; + } + } +} diff --git a/src/hlsBinaries.ts b/src/hlsBinaries.ts index 65ea68c7..51329c94 100644 --- a/src/hlsBinaries.ts +++ b/src/hlsBinaries.ts @@ -1,155 +1,66 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import * as child_process from 'child_process'; import * as fs from 'fs'; -import { stat } from 'fs/promises'; -import * as https from 'https'; import * as path from 'path'; -import * as os from 'os'; -import { match } from 'ts-pattern'; -import { promisify } from 'util'; -import { ConfigurationTarget, ExtensionContext, ProgressLocation, window, workspace, WorkspaceFolder } from 'vscode'; +import { ConfigurationTarget, ExtensionContext, window, workspace, WorkspaceFolder } from 'vscode'; import { Logger } from 'vscode-languageclient'; import { HlsError, MissingToolError, NoMatchingHls } from './errors'; import { addPathToProcessPath, + callAsync, comparePVP, executableExists, - httpsGetSilently, IEnvVars, resolvePathPlaceHolders, - resolveServerEnvironmentPATH, } from './utils'; +import { ToolConfig, Tool, initDefaultGHCup, GHCup, GHCupConfig } from './ghcup'; +import { getHlsMetadata } from './metadata'; export { IEnvVars }; -type Tool = 'hls' | 'ghc' | 'cabal' | 'stack'; - -type ToolConfig = Map; - -type ManageHLS = 'GHCup' | 'PATH'; -let manageHLS = workspace.getConfiguration('haskell').get('manageHLS') as ManageHLS; - -// On Windows the executable needs to be stored somewhere with an .exe extension -const exeExt = process.platform === 'win32' ? '.exe' : ''; +export type Context = { + manageHls: ManageHLS; + storagePath: string; + serverExecutable?: HlsExecutable; + logger: Logger; +}; /** - * Callback invoked on process termination. + * Global configuration for this extension. */ -type ProcessCallback = ( - error: child_process.ExecFileException | null, - stdout: string, - stderr: string, - resolve: (value: string | PromiseLike) => void, - reject: (reason?: HlsError | Error | string) => void, -) => void; +const haskellConfig = workspace.getConfiguration('haskell'); /** - * Call a process asynchronously. - * While doing so, update the windows with progress information. - * If you need to run a process, consider preferring this over running - * the command directly. - * - * @param binary Name of the binary to invoke. - * @param args Arguments passed directly to the binary. - * @param dir Directory in which the process shall be executed. - * @param logger Logger for progress updates. - * @param title Title of the action, shown to users if available. - * @param cancellable Can the user cancel this process invocation? - * @param envAdd Extra environment variables for this process only. - * @param callback Upon process termination, execute this callback. If given, must resolve promise. On error, stderr and stdout are logged regardless of whether the callback has been specified. - * @returns Stdout of the process invocation, trimmed off newlines, or whatever the `callback` resolved to. + * On Windows the executable needs to be stored somewhere with an .exe extension */ -export async function callAsync( - binary: string, - args: string[], - logger: Logger, - dir?: string, - title?: string, - cancellable?: boolean, - envAdd?: IEnvVars, - callback?: ProcessCallback, -): Promise { - let newEnv: IEnvVars = resolveServerEnvironmentPATH( - workspace.getConfiguration('haskell').get('serverEnvironment') || {}, - ); - newEnv = { ...(process.env as IEnvVars), ...newEnv, ...(envAdd || {}) }; - return window.withProgress( - { - location: ProgressLocation.Notification, - title, - cancellable, - }, - async (_, token) => { - return new Promise((resolve, reject) => { - const command: string = binary + ' ' + args.join(' '); - logger.info(`Executing '${command}' in cwd '${dir ? dir : process.cwd()}'`); - token.onCancellationRequested(() => { - logger.warn(`User canceled the execution of '${command}'`); - }); - // Need to set the encoding to 'utf8' in order to get back a string - // We execute the command in a shell for windows, to allow use .cmd or .bat scripts - const childProcess = child_process - .execFile( - process.platform === 'win32' ? `"${binary}"` : binary, - args, - { encoding: 'utf8', cwd: dir, shell: process.platform === 'win32', env: newEnv }, - (err, stdout, stderr) => { - if (err) { - logger.error(`Error executing '${command}' with error code ${err.code}`); - logger.error(`stderr: ${stderr}`); - if (stdout) { - logger.error(`stdout: ${stdout}`); - } - } - if (callback) { - callback(err, stdout, stderr, resolve, reject); - } else { - if (err) { - reject( - Error(`\`${command}\` exited with exit code ${err.code}. - Consult the [Extensions Output](https://github.com/haskell/vscode-haskell#investigating-and-reporting-problems) - for details.`), - ); - } else { - resolve(stdout?.trim()); - } - } - }, - ) - .on('exit', (code, signal) => { - const msg = - `Execution of '${command}' terminated with code ${code}` + (signal ? `and signal ${signal}` : ''); - logger.log(msg); - }) - .on('error', (err) => { - if (err) { - logger.error(`Error executing '${command}': name = ${err.name}, message = ${err.message}`); - reject(err); - } - }); - token.onCancellationRequested(() => childProcess.kill()); - }); - }, - ); -} +const exeExt = process.platform === 'win32' ? '.exe' : ''; -/** Gets serverExecutablePath and fails if it's not set. +type ManageHLS = 'GHCup' | 'PATH'; +let manageHLS = haskellConfig.get('manageHLS') as ManageHLS; + +/** + * Gets serverExecutablePath and fails if it's not set. + * @param logger Log progress. + * @param folder Workspace folder. Used for resolving variables in the `serverExecutablePath`. + * @returns Path to an HLS executable binary. */ -async function findServerExecutable(logger: Logger, folder?: WorkspaceFolder): Promise { - let exePath = workspace.getConfiguration('haskell').get('serverExecutablePath') as string; - logger.info(`Trying to find the server executable in: ${exePath}`); - exePath = resolvePathPlaceHolders(exePath, folder); - logger.log(`Location after path variables substitution: ${exePath}`); - if (executableExists(exePath)) { - return exePath; +function findServerExecutable(logger: Logger, folder?: WorkspaceFolder): string { + const rawExePath = haskellConfig.get('serverExecutablePath') as string; + logger.info(`Trying to find the server executable in: ${rawExePath}`); + const resolvedExePath = resolvePathPlaceHolders(rawExePath, folder); + logger.log(`Location after path variables substitution: ${resolvedExePath}`); + if (executableExists(resolvedExePath)) { + return resolvedExePath; } else { - const msg = `Could not find a HLS binary at ${exePath}! Consider installing HLS via ghcup or change "haskell.manageHLS" in your settings.`; - throw new Error(msg); + const msg = `Could not find a HLS binary at ${resolvedExePath}! Consider installing HLS via ghcup or change "haskell.manageHLS" in your settings.`; + throw new HlsError(msg); } } -/** Searches the PATH. Fails if nothing is found. +/** + * Searches the `PATH` for `haskell-language-server` or `haskell-language-server-wrapper` binary. + * Fails if nothing is found. + * @param logger Log all the stuff! + * @returns Location of the `haskell-language-server` or `haskell-language-server-wrapper` binary if found. */ -async function findHLSinPATH(_context: ExtensionContext, logger: Logger): Promise { +function findHlsInPath(logger: Logger): string { // try PATH const exes: string[] = ['haskell-language-server-wrapper', 'haskell-language-server']; logger.info(`Searching for server executables ${exes.join(',')} in $PATH`); @@ -163,73 +74,79 @@ async function findHLSinPATH(_context: ExtensionContext, logger: Logger): Promis throw new MissingToolError('hls'); } +export type HlsExecutable = HlsOnPath | HlsViaVSCodeConfig | HlsViaGhcup; + +export type HlsOnPath = { + location: string; + tag: 'path'; +}; + +export type HlsViaVSCodeConfig = { + location: string; + tag: 'config'; +}; + +export type HlsViaGhcup = { + location: string; + /** + * if we download HLS, add that bin dir to PATH + */ + binaryDirectory: string; + tag: 'ghcup'; +}; + /** - * Downloads the latest haskell-language-server binaries via GHCup. - * Makes sure that either `ghcup` is available locally, otherwise installs - * it into an isolated location. - * If we figure out the correct GHC version, but it isn't compatible with - * the latest HLS executables, we download the latest compatible HLS binaries - * as a fallback. + * Find and setup the Haskell Language Server. + * + * We support three ways of finding the HLS binary: + * + * 1. Let the user provide a location via `haskell.serverExecutablePath` option. + * 2. Find a `haskell-language-server` binary on the `$PATH` if the user wants to do that. + * 3. Use GHCup to install and locate HLS and other required tools, such as cabal, stack and ghc. * * @param context Context of the extension, required for metadata. * @param logger Logger for progress updates. * @param workingDir Working directory in VSCode. - * @returns Path to haskell-language-server-wrapper + * @param folder Optional workspace folder. If given, will be preferred over {@link workingDir} for finding configuration entries. + * @returns Path to haskell-language-server, paired with additional data required for setting up. */ export async function findHaskellLanguageServer( context: ExtensionContext, logger: Logger, + ghcupConfig: GHCupConfig, workingDir: string, folder?: WorkspaceFolder, -): Promise<[string, string | undefined]> { +): Promise { logger.info('Finding haskell-language-server'); - if (workspace.getConfiguration('haskell').get('serverExecutablePath') as string) { - const exe = await findServerExecutable(logger, folder); - return [exe, undefined]; + const hasConfigForExecutable = haskellConfig.get('serverExecutablePath') as string; + if (hasConfigForExecutable) { + const exe = findServerExecutable(logger, folder); + return { + location: exe, + tag: 'config', + }; } - const storagePath: string = await getStoragePath(context); - + const storagePath: string = getStoragePath(context); if (!fs.existsSync(storagePath)) { fs.mkdirSync(storagePath); } - // first plugin initialization - if (manageHLS !== 'GHCup' && (!context.globalState.get('pluginInitialized') as boolean | null)) { - const promptMessage = `How do you want the extension to manage/discover HLS and the relevant toolchain? - - Choose "Automatically" if you're in doubt. - `; - - const popup = window.showInformationMessage( - promptMessage, - { modal: true }, - 'Automatically via GHCup', - 'Manually via PATH', - ); - - const decision = (await popup) || null; - if (decision === 'Automatically via GHCup') { - manageHLS = 'GHCup'; - } else if (decision === 'Manually via PATH') { - manageHLS = 'PATH'; - } else { - window.showWarningMessage( - "Choosing default PATH method for HLS discovery. You can change this via 'haskell.manageHLS' in the settings.", - ); - manageHLS = 'PATH'; - } - workspace.getConfiguration('haskell').update('manageHLS', manageHLS, ConfigurationTarget.Global); - context.globalState.update('pluginInitialized', true); - } + // first extension initialization + manageHLS = await promptUserForManagingHls(context, manageHLS); + // based on the user-decision if (manageHLS === 'PATH') { - const exe = await findHLSinPATH(context, logger); - return [exe, undefined]; + const exe = findHlsInPath(logger); + return { + location: exe, + tag: 'path', + }; } else { // we manage HLS, make sure ghcup is installed/available - await upgradeGHCup(context, logger); + const ghcup = initDefaultGHCup(ghcupConfig, logger, folder); + await ghcup.upgrade(); // boring init let latestHLS: string | undefined | null; @@ -240,9 +157,7 @@ export async function findHaskellLanguageServer( let projectGhc: string | undefined | null; // support explicit toolchain config - const toolchainConfig = new Map( - Object.entries(workspace.getConfiguration('haskell').get('toolchain') as any), - ) as ToolConfig; + const toolchainConfig = new Map(Object.entries(haskellConfig.get('toolchain') as ToolConfig)) as ToolConfig; if (toolchainConfig) { latestHLS = toolchainConfig.get('hls'); latestCabal = toolchainConfig.get('cabal'); @@ -257,38 +172,36 @@ export async function findHaskellLanguageServer( // (we need HLS and cabal/stack and ghc as fallback), // later we may install a different toolchain that's more project-specific if (latestHLS === undefined) { - latestHLS = await getLatestToolFromGHCup(context, logger, 'hls'); + latestHLS = await ghcup.getLatestVersion('hls'); } if (latestCabal === undefined) { - latestCabal = await getLatestToolFromGHCup(context, logger, 'cabal'); + latestCabal = await ghcup.getLatestVersion('cabal'); } if (latestStack === undefined) { - latestStack = await getLatestToolFromGHCup(context, logger, 'stack'); + latestStack = await ghcup.getLatestVersion('stack'); } if (recGHC === undefined) { - recGHC = !executableExists('ghc') - ? await getLatestAvailableToolFromGHCup(context, logger, 'ghc', 'recommended') - : null; + recGHC = !executableExists('ghc') ? await ghcup.getLatestAvailableVersion('ghc', 'recommended') : null; } // download popups - const promptBeforeDownloads = workspace.getConfiguration('haskell').get('promptBeforeDownloads') as boolean; + const promptBeforeDownloads = haskellConfig.get('promptBeforeDownloads') as boolean; if (promptBeforeDownloads) { - const hlsInstalled = latestHLS ? await toolInstalled(context, logger, 'hls', latestHLS) : undefined; - const cabalInstalled = latestCabal ? await toolInstalled(context, logger, 'cabal', latestCabal) : undefined; - const stackInstalled = latestStack ? await toolInstalled(context, logger, 'stack', latestStack) : undefined; + const hlsInstalled = latestHLS ? await installationStatusOfGhcupTool(ghcup, 'hls', latestHLS) : undefined; + const cabalInstalled = latestCabal ? await installationStatusOfGhcupTool(ghcup, 'cabal', latestCabal) : undefined; + const stackInstalled = latestStack ? await installationStatusOfGhcupTool(ghcup, 'stack', latestStack) : undefined; const ghcInstalled = executableExists('ghc') - ? new InstalledTool( + ? new ToolStatus( 'ghc', await callAsync(`ghc${exeExt}`, ['--numeric-version'], logger, undefined, undefined, false), ) : // if recGHC is null, that means user disabled automatic handling, recGHC !== null - ? await toolInstalled(context, logger, 'ghc', recGHC) + ? await installationStatusOfGhcupTool(ghcup, 'ghc', recGHC) : undefined; - const toInstall: InstalledTool[] = [hlsInstalled, cabalInstalled, stackInstalled, ghcInstalled].filter( + const toInstall: ToolStatus[] = [hlsInstalled, cabalInstalled, stackInstalled, ghcInstalled].filter( (tool) => tool && !tool.installed, - ) as InstalledTool[]; + ) as ToolStatus[]; if (toInstall.length > 0) { const decision = await window.showInformationMessage( `Need to download ${toInstall.map((t) => t.nameWithVersion).join(', ')}, continue?`, @@ -302,7 +215,7 @@ export async function findHaskellLanguageServer( logger.info( `User accepted download for ${toInstall.map((t) => t.nameWithVersion).join(', ')} and won't be asked again.`, ); - workspace.getConfiguration('haskell').update('promptBeforeDownloads', false); + haskellConfig.update('promptBeforeDownloads', false); } else { toInstall.forEach((tool) => { if (tool !== undefined && !tool.installed) { @@ -322,9 +235,7 @@ export async function findHaskellLanguageServer( } // our preliminary toolchain - const latestToolchainBindir = await callGHCup( - context, - logger, + const latestToolchainBindir = await ghcup.call( [ 'run', ...(latestHLS ? ['--hls', latestHLS] : []), @@ -347,7 +258,7 @@ export async function findHaskellLanguageServer( // now figure out the actual project GHC version and the latest supported HLS version // we need for it (e.g. this might in fact be a downgrade for old GHCs) if (projectHls === undefined || projectGhc === undefined) { - const res = await getLatestProjectHLS(context, logger, workingDir, latestToolchainBindir); + const res = await getLatestProjectHls(ghcup, logger, storagePath, workingDir, latestToolchainBindir); if (projectHls === undefined) { projectHls = res[0]; } @@ -358,11 +269,11 @@ export async function findHaskellLanguageServer( // more download popups if (promptBeforeDownloads) { - const hlsInstalled = projectHls ? await toolInstalled(context, logger, 'hls', projectHls) : undefined; - const ghcInstalled = projectGhc ? await toolInstalled(context, logger, 'ghc', projectGhc) : undefined; - const toInstall: InstalledTool[] = [hlsInstalled, ghcInstalled].filter( + const hlsInstalled = projectHls ? await installationStatusOfGhcupTool(ghcup, 'hls', projectHls) : undefined; + const ghcInstalled = projectGhc ? await installationStatusOfGhcupTool(ghcup, 'ghc', projectGhc) : undefined; + const toInstall: ToolStatus[] = [hlsInstalled, ghcInstalled].filter( (tool) => tool && !tool.installed, - ) as InstalledTool[]; + ) as ToolStatus[]; if (toInstall.length > 0) { const decision = await window.showInformationMessage( `Need to download ${toInstall.map((t) => t.nameWithVersion).join(', ')}, continue?`, @@ -377,7 +288,7 @@ export async function findHaskellLanguageServer( logger.info( `User accepted download for ${toInstall.map((t) => t.nameWithVersion).join(', ')} and won't be asked again.`, ); - workspace.getConfiguration('haskell').update('promptBeforeDownloads', false); + haskellConfig.update('promptBeforeDownloads', false); } else { toInstall.forEach((tool) => { if (!tool.installed) { @@ -393,9 +304,7 @@ export async function findHaskellLanguageServer( } // now install the proper versions - const hlsBinDir = await callGHCup( - context, - logger, + const hlsBinDir = await ghcup.call( [ 'run', ...(projectHls ? ['--hls', projectHls] : []), @@ -417,53 +326,65 @@ export async function findHaskellLanguageServer( ); if (projectHls) { - return [path.join(hlsBinDir, `haskell-language-server-wrapper${exeExt}`), hlsBinDir]; + return { + binaryDirectory: hlsBinDir, + location: path.join(hlsBinDir, `haskell-language-server-wrapper${exeExt}`), + tag: 'ghcup', + }; } else { - const exe = await findHLSinPATH(context, logger); - return [exe, hlsBinDir]; + return { + binaryDirectory: hlsBinDir, + location: findHlsInPath(logger), + tag: 'ghcup', + }; } } } -async function callGHCup( - context: ExtensionContext, - logger: Logger, - args: string[], - title?: string, - cancellable?: boolean, - callback?: ProcessCallback, -): Promise { - const metadataUrl = workspace.getConfiguration('haskell').metadataURL; - - if (manageHLS === 'GHCup') { - const ghcup = await findGHCup(context, logger); - return await callAsync( - ghcup, - ['--no-verbose'].concat(metadataUrl ? ['-s', metadataUrl] : []).concat(args), - logger, - undefined, - title, - cancellable, - { - // omit colourful output because the logs are uglier - NO_COLOR: '1', - }, - callback, +async function promptUserForManagingHls(context: ExtensionContext, manageHlsSetting: ManageHLS): Promise { + if (manageHlsSetting !== 'GHCup' && (!context.globalState.get('pluginInitialized') as boolean | null)) { + const promptMessage = `How do you want the extension to manage/discover HLS and the relevant toolchain? + + Choose "Automatically" if you're in doubt. + `; + + const popup = window.showInformationMessage( + promptMessage, + { modal: true }, + 'Automatically via GHCup', + 'Manually via PATH', ); + + const decision = (await popup) || null; + let howToManage: ManageHLS; + if (decision === 'Automatically via GHCup') { + howToManage = 'GHCup'; + } else if (decision === 'Manually via PATH') { + howToManage = 'PATH'; + } else { + window.showWarningMessage( + "Choosing default PATH method for HLS discovery. You can change this via 'haskell.manageHLS' in the settings.", + ); + howToManage = 'PATH'; + } + haskellConfig.update('manageHLS', howToManage, ConfigurationTarget.Global); + context.globalState.update('pluginInitialized', true); + return howToManage; } else { - throw new HlsError(`Internal error: tried to call ghcup while haskell.manageHLS is set to ${manageHLS}. Aborting!`); + return manageHlsSetting; } } -async function getLatestProjectHLS( - context: ExtensionContext, +async function getLatestProjectHls( + ghcup: GHCup, logger: Logger, + storagePath: string, workingDir: string, toolchainBindir: string, ): Promise<[string, string]> { // get project GHC version, but fallback to system ghc if necessary. const projectGhc = toolchainBindir - ? await getProjectGHCVersion(toolchainBindir, workingDir, logger).catch(async (e) => { + ? await getProjectGhcVersion(toolchainBindir, workingDir, logger).catch(async (e) => { logger.error(`${e}`); window.showWarningMessage( `I had trouble figuring out the exact GHC version for the project. Falling back to using 'ghc${exeExt}'.`, @@ -473,9 +394,9 @@ async function getLatestProjectHLS( : await callAsync(`ghc${exeExt}`, ['--numeric-version'], logger, undefined, undefined, false); // first we get supported GHC versions from available HLS bindists (whether installed or not) - const metadataMap = (await getHLSesfromMetadata(context, logger)) || new Map(); + const metadataMap = (await getHlsMetadata(storagePath, logger)) || new Map(); // then we get supported GHC versions from currently installed HLS versions - const ghcupMap = (await getHLSesFromGHCup(context, logger)) || new Map(); + const ghcupMap = (await findAvailableHlsBinariesFromGHCup(ghcup)) || new Map(); // since installed HLS versions may support a different set of GHC versions than the bindists // (e.g. because the user ran 'ghcup compile hls'), we need to merge both maps, preferring // values from already installed HLSes @@ -496,12 +417,12 @@ async function getLatestProjectHLS( /** * Obtain the project ghc version from the HLS - Wrapper (which must be in PATH now). * Also, serves as a sanity check. - * @param toolchainBindir Path to the toolchainn bin directory (added to PATH) + * @param toolchainBindir Path to the toolchain bin directory (added to PATH) * @param workingDir Directory to run the process, usually the root of the workspace. * @param logger Logger for feedback. * @returns The GHC version, or fail with an `Error`. */ -export async function getProjectGHCVersion( +export async function getProjectGhcVersion( toolchainBindir: string, workingDir: string, logger: Logger, @@ -511,7 +432,7 @@ export async function getProjectGHCVersion( const args = ['--project-ghc-version']; - const newPath = await addPathToProcessPath(toolchainBindir); + const newPath = addPathToProcessPath(toolchainBindir); const environmentNew: IEnvVars = { PATH: newPath, }; @@ -551,75 +472,15 @@ export async function getProjectGHCVersion( ); } -export async function upgradeGHCup(context: ExtensionContext, logger: Logger): Promise { - if (manageHLS === 'GHCup') { - const upgrade = workspace.getConfiguration('haskell').get('upgradeGHCup') as boolean; - if (upgrade) { - await callGHCup(context, logger, ['upgrade'], 'Upgrading ghcup', true); - } - } else { - throw new Error(`Internal error: tried to call ghcup while haskell.manageHLS is set to ${manageHLS}. Aborting!`); - } -} - -export async function findGHCup(_context: ExtensionContext, logger: Logger, folder?: WorkspaceFolder): Promise { - logger.info('Checking for ghcup installation'); - let exePath = workspace.getConfiguration('haskell').get('ghcupExecutablePath') as string; - if (exePath) { - logger.info(`Trying to find the ghcup executable in: ${exePath}`); - exePath = resolvePathPlaceHolders(exePath, folder); - logger.log(`Location after path variables substitution: ${exePath}`); - if (executableExists(exePath)) { - return exePath; - } else { - throw new Error(`Could not find a ghcup binary at ${exePath}!`); - } - } else { - const localGHCup = ['ghcup'].find(executableExists); - if (!localGHCup) { - logger.info(`probing for GHCup binary`); - const ghcupExe = match(process.platform) - .with('win32', () => { - const ghcupPrefix = process.env.GHCUP_INSTALL_BASE_PREFIX; - if (ghcupPrefix) { - return path.join(ghcupPrefix, 'ghcup', 'bin', 'ghcup.exe'); - } else { - return path.join('C:\\', 'ghcup', 'bin', 'ghcup.exe'); - } - }) - .otherwise(() => { - const useXDG = process.env.GHCUP_USE_XDG_DIRS; - if (useXDG) { - const xdgBin = process.env.XDG_BIN_HOME; - if (xdgBin) { - return path.join(xdgBin, 'ghcup'); - } else { - return path.join(os.homedir(), '.local', 'bin', 'ghcup'); - } - } else { - const ghcupPrefix = process.env.GHCUP_INSTALL_BASE_PREFIX; - if (ghcupPrefix) { - return path.join(ghcupPrefix, '.ghcup', 'bin', 'ghcup'); - } else { - return path.join(os.homedir(), '.ghcup', 'bin', 'ghcup'); - } - } - }); - if (ghcupExe != null && executableExists(ghcupExe)) { - return ghcupExe; - } else { - logger.warn(`ghcup at ${ghcupExe} does not exist`); - throw new MissingToolError('ghcup'); - } - } else { - logger.info(`found ghcup at ${localGHCup}`); - return localGHCup; - } - } -} - -export async function getStoragePath(context: ExtensionContext): Promise { - let storagePath: string | undefined = await workspace.getConfiguration('haskell').get('releasesDownloadStoragePath'); +/** + * Find the storage path for the extension. + * If no custom location was given + * + * @param context Extension context for the 'Storage Path'. + * @returns + */ +export function getStoragePath(context: ExtensionContext): string { + let storagePath: string | undefined = haskellConfig.get('releasesDownloadStoragePath'); if (!storagePath) { storagePath = context.globalStorageUri.fsPath; @@ -630,77 +491,24 @@ export async function getStoragePath(context: ExtensionContext): Promise return storagePath; } -// the tool might be installed or not -async function getLatestToolFromGHCup(context: ExtensionContext, logger: Logger, tool: Tool): Promise { - // these might be custom/stray/compiled, so we try first - const installedVersions = await callGHCup( - context, - logger, - ['list', '-t', tool, '-c', 'installed', '-r'], - undefined, - false, - ); - const latestInstalled = installedVersions.split(/\r?\n/).pop(); - if (latestInstalled) { - return latestInstalled.split(/\s+/)[1]; - } - - return getLatestAvailableToolFromGHCup(context, logger, tool); -} - -async function getLatestAvailableToolFromGHCup( - context: ExtensionContext, - logger: Logger, - tool: Tool, - tag?: string, - criteria?: string, -): Promise { - // fall back to installable versions - const availableVersions = await callGHCup( - context, - logger, - ['list', '-t', tool, '-c', criteria ? criteria : 'available', '-r'], - undefined, - false, - ).then((s) => s.split(/\r?\n/)); - - let latestAvailable: string | null = null; - availableVersions.forEach((ver) => { - if ( - ver - .split(/\s+/)[2] - .split(',') - .includes(tag ? tag : 'latest') - ) { - latestAvailable = ver.split(/\s+/)[1]; - } - }); - if (!latestAvailable) { - throw new Error(`Unable to find ${tag ? tag : 'latest'} tool ${tool}`); - } else { - return latestAvailable; - } -} - -// complements getHLSesfromMetadata, by checking possibly locally compiled -// HLS in ghcup -// If 'targetGhc' is omitted, picks the latest 'haskell-language-server-wrapper', -// otherwise ensures the specified GHC is supported. -async function getHLSesFromGHCup(context: ExtensionContext, logger: Logger): Promise | null> { - const hlsVersions = await callGHCup( - context, - logger, - ['list', '-t', 'hls', '-c', 'installed', '-r'], - undefined, - false, - ); +/** + * + * Complements {@link getReleaseMetadata}, by checking possibly locally compiled + * HLS in ghcup + * If 'targetGhc' is omitted, picks the latest 'haskell-language-server-wrapper', + * otherwise ensures the specified GHC is supported. + * + * @param ghcup GHCup wrapper. + * @returns A Map of the locally installed HLS versions and with which `GHC` versions they are compatible. + */ - const bindir = await callGHCup(context, logger, ['whereis', 'bindir'], undefined, false); +async function findAvailableHlsBinariesFromGHCup(ghcup: GHCup): Promise | null> { + const hlsVersions = await ghcup.call(['list', '-t', 'hls', '-c', 'installed', '-r'], undefined, false); - const files = fs.readdirSync(bindir).filter(async (e) => { - return await stat(path.join(bindir, e)) - .then((s) => s.isDirectory()) - .catch(() => false); + const bindir = await ghcup.call(['whereis', 'bindir'], undefined, false); + const files = fs.readdirSync(bindir).filter((e) => { + const stat = fs.statSync(path.join(bindir, e)); + return stat.isFile(); }); const installed = hlsVersions.split(/\r?\n/).map((e) => e.split(/\s+/)[1]); @@ -715,224 +523,24 @@ async function getHLSesFromGHCup(context: ExtensionContext, logger: Logger): Pro }); myMap.set(hls, ghcs); }); - return myMap; } else { return null; } } -async function toolInstalled( - context: ExtensionContext, - logger: Logger, - tool: Tool, - version: string, -): Promise { - const b = await callGHCup(context, logger, ['whereis', tool, version], undefined, false) +async function installationStatusOfGhcupTool(ghcup: GHCup, tool: Tool, version: string): Promise { + const b = await ghcup + .call(['whereis', tool, version], undefined, false) .then(() => true) .catch(() => false); - return new InstalledTool(tool, version, b); -} - -/** - * Metadata of release information. - * - * Example of the expected format: - * - * ``` - * { - * "1.6.1.0": { - * "A_64": { - * "Darwin": [ - * "8.10.6", - * ], - * "Linux_Alpine": [ - * "8.10.7", - * "8.8.4", - * ], - * }, - * "A_ARM": { - * "Linux_UnknownLinux": [ - * "8.10.7" - * ] - * }, - * "A_ARM64": { - * "Darwin": [ - * "8.10.7" - * ], - * "Linux_UnknownLinux": [ - * "8.10.7" - * ] - * } - * } - * } - * ``` - * - * consult [ghcup metadata repo](https://github.com/haskell/ghcup-metadata/) for details. - */ -export type ReleaseMetadata = Map>>; - -/** - * Compute Map of supported HLS versions for this platform. - * Fetches HLS metadata information. - * - * @param context Context of the extension, required for metadata. - * @param logger Logger for feedback - * @returns Map of supported HLS versions or null if metadata could not be fetched. - */ -async function getHLSesfromMetadata(context: ExtensionContext, logger: Logger): Promise | null> { - const storagePath: string = await getStoragePath(context); - const metadata = await getReleaseMetadata(context, storagePath, logger).catch(() => null); - if (!metadata) { - window.showErrorMessage('Could not get release metadata'); - return null; - } - const plat: Platform | null = match(process.platform) - .with('darwin', () => 'Darwin' as Platform) - .with('linux', () => 'Linux_UnknownLinux' as Platform) - .with('win32', () => 'Windows' as Platform) - .with('freebsd', () => 'FreeBSD' as Platform) - .otherwise(() => null); - if (plat === null) { - throw new Error(`Unknown platform ${process.platform}`); - } - const arch: Arch | null = match(process.arch) - .with('arm', () => 'A_ARM' as Arch) - .with('arm64', () => 'A_ARM64' as Arch) - .with('ia32', () => 'A_32' as Arch) - .with('x64', () => 'A_64' as Arch) - .otherwise(() => null); - if (arch === null) { - throw new Error(`Unknown architecture ${process.arch}`); - } - - return findSupportedHlsPerGhc(plat, arch, metadata, logger); -} - -export type Platform = 'Darwin' | 'Linux_UnknownLinux' | 'Windows' | 'FreeBSD'; - -export type Arch = 'A_ARM' | 'A_ARM64' | 'A_32' | 'A_64'; - -/** - * Find all supported GHC versions per HLS version supported on the given - * platform and architecture. - * @param platform Platform of the host. - * @param arch Arch of the host. - * @param metadata HLS Metadata information. - * @param logger Logger. - * @returns Map from HLS version to GHC versions that are supported. - */ -export function findSupportedHlsPerGhc( - platform: Platform, - arch: Arch, - metadata: ReleaseMetadata, - logger: Logger, -): Map { - logger.info(`Platform constants: ${platform}, ${arch}`); - const newMap = new Map(); - metadata.forEach((supportedArch, hlsVersion) => { - const supportedOs = supportedArch.get(arch); - if (supportedOs) { - const ghcSupportedOnOs = supportedOs.get(platform); - if (ghcSupportedOnOs) { - logger.log(`HLS ${hlsVersion} compatible with GHC Versions: ${ghcSupportedOnOs}`); - // copy supported ghc versions to avoid unintended modifications - newMap.set(hlsVersion, [...ghcSupportedOnOs]); - } - } - }); - - return newMap; -} - -/** - * Download GHCUP metadata. - * - * @param _context Extension context. - * @param storagePath Path to put in binary files and caches. - * @param logger Logger for feedback. - * @returns Metadata of releases, or null if the cache can not be found. - */ -async function getReleaseMetadata( - _context: ExtensionContext, - storagePath: string, - logger: Logger, -): Promise { - const releasesUrl = workspace.getConfiguration('haskell').releasesURL - ? new URL(workspace.getConfiguration('haskell').releasesURL) - : undefined; - const opts: https.RequestOptions = releasesUrl - ? { - host: releasesUrl.host, - path: releasesUrl.pathname, - } - : { - host: 'raw.githubusercontent.com', - path: '/haskell/ghcup-metadata/master/hls-metadata-0.0.1.json', - }; - - const offlineCache = path.join(storagePath, 'ghcupReleases.cache.json'); - - /** - * Convert a json value to ReleaseMetadata. - * Assumes the json is well-formed and a valid Release-Metadata. - * @param obj Release Metadata without any typing information but well-formed. - * @returns Typed ReleaseMetadata. - */ - const objectToMetadata = (obj: any): ReleaseMetadata => { - const hlsMetaEntries = Object.entries(obj).map(([hlsVersion, archMap]) => { - const archMetaEntries = Object.entries(archMap as any).map(([arch, supportedGhcVersionsPerOs]) => { - return [arch, new Map(Object.entries(supportedGhcVersionsPerOs as any))] as [string, Map]; - }); - return [hlsVersion, new Map(archMetaEntries)] as [string, Map>]; - }); - return new Map(hlsMetaEntries); - }; - - async function readCachedReleaseData(): Promise { - try { - logger.info(`Reading cached release data at ${offlineCache}`); - const cachedInfo = await promisify(fs.readFile)(offlineCache, { encoding: 'utf-8' }); - // export type ReleaseMetadata = Map>>; - const value: any = JSON.parse(cachedInfo); - return objectToMetadata(value); - } catch (err: any) { - // If file doesn't exist, return null, otherwise consider it a failure - if (err.code === 'ENOENT') { - logger.warn(`No cached release data found at ${offlineCache}`); - return null; - } - throw err; - } - } - - try { - const releaseInfo = await httpsGetSilently(opts); - const releaseInfoParsed = JSON.parse(releaseInfo); - - // Cache the latest successfully fetched release information - await promisify(fs.writeFile)(offlineCache, JSON.stringify(releaseInfoParsed), { encoding: 'utf-8' }); - return objectToMetadata(releaseInfoParsed); - } catch (githubError: any) { - // Attempt to read from the latest cached file - try { - const cachedInfoParsed = await readCachedReleaseData(); - - window.showWarningMessage( - "Couldn't get the latest haskell-language-server releases from GitHub, used local cache instead: " + - githubError.message, - ); - return cachedInfoParsed; - } catch (_fileError) { - throw new Error("Couldn't get the latest haskell-language-server releases from GitHub: " + githubError.message); - } - } + return new ToolStatus(tool, version, b); } /** * Tracks the name, version and installation state of tools we need. */ -class InstalledTool { +class ToolStatus { /** * "\-\" of the installed Tool. */ diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 00000000..e4385f45 --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,70 @@ +import { OutputChannel } from "vscode"; +import { Logger } from "vscode-languageclient"; +import * as fs from 'fs'; + +enum LogLevel { + Off, + Error, + Warn, + Info, + Debug, +} +export class ExtensionLogger implements Logger { + public readonly name: string; + public readonly level: LogLevel; + public readonly channel: OutputChannel; + public readonly logFile: string | undefined; + + constructor(name: string, level: string, channel: OutputChannel, logFile: string | undefined) { + this.name = name; + this.level = this.getLogLevel(level); + this.channel = channel; + this.logFile = logFile; + } + public warn(message: string): void { + this.logLevel(LogLevel.Warn, message); + } + + public info(message: string): void { + this.logLevel(LogLevel.Info, message); + } + + public error(message: string) { + this.logLevel(LogLevel.Error, message); + } + + public log(message: string) { + this.logLevel(LogLevel.Debug, message); + } + + private write(msg: string) { + let now = new Date(); + // Ugly hack to make js date iso format similar to hls one + const offset = now.getTimezoneOffset(); + now = new Date(now.getTime() - offset * 60 * 1000); + const timedMsg = `${new Date().toISOString().replace('T', ' ').replace('Z', '0000')} ${msg}`; + this.channel.appendLine(timedMsg); + if (this.logFile) { + fs.appendFileSync(this.logFile, timedMsg + '\n'); + } + } + + private logLevel(level: LogLevel, msg: string) { + if (level <= this.level) { + this.write(`[${this.name}] ${LogLevel[level].toUpperCase()} ${msg}`); + } + } + + private getLogLevel(level: string) { + switch (level) { + case 'off': + return LogLevel.Off; + case 'error': + return LogLevel.Error; + case 'debug': + return LogLevel.Debug; + default: + return LogLevel.Info; + } + } +} diff --git a/src/metadata.ts b/src/metadata.ts new file mode 100644 index 00000000..fbdc6429 --- /dev/null +++ b/src/metadata.ts @@ -0,0 +1,197 @@ +import * as fs from 'fs'; +import * as https from 'https'; +import * as path from 'path'; +import { match } from 'ts-pattern'; +import { promisify } from 'util'; +import { window, workspace } from 'vscode'; +import { Logger } from 'vscode-languageclient'; +import { httpsGetSilently } from './utils'; + +/** + * Metadata of release information. + * + * Example of the expected format: + * + * ``` + * { + * "1.6.1.0": { + * "A_64": { + * "Darwin": [ + * "8.10.6", + * ], + * "Linux_Alpine": [ + * "8.10.7", + * "8.8.4", + * ], + * }, + * "A_ARM": { + * "Linux_UnknownLinux": [ + * "8.10.7" + * ] + * }, + * "A_ARM64": { + * "Darwin": [ + * "8.10.7" + * ], + * "Linux_UnknownLinux": [ + * "8.10.7" + * ] + * } + * } + * } + * ``` + * + * consult [ghcup metadata repo](https://github.com/haskell/ghcup-metadata/) for details. + */ +export type ReleaseMetadata = Map>>; + +export type Platform = 'Darwin' | 'Linux_UnknownLinux' | 'Windows' | 'FreeBSD'; + +export type Arch = 'A_ARM' | 'A_ARM64' | 'A_32' | 'A_64'; + +/** + * Compute Map of supported HLS versions for this platform. + * Fetches HLS metadata information. + * + * @param storagePath Path to put in binary files and caches. + * @param logger Logger for feedback + * @returns Map of supported HLS versions or null if metadata could not be fetched. + */ +export async function getHlsMetadata(storagePath: string, logger: Logger): Promise | null> { + const metadata = await getReleaseMetadata(storagePath, logger).catch(() => null); + if (!metadata) { + window.showErrorMessage('Could not get release metadata'); + return null; + } + const plat: Platform | null = match(process.platform) + .with('darwin', () => 'Darwin' as Platform) + .with('linux', () => 'Linux_UnknownLinux' as Platform) + .with('win32', () => 'Windows' as Platform) + .with('freebsd', () => 'FreeBSD' as Platform) + .otherwise(() => null); + if (plat === null) { + throw new Error(`Unknown platform ${process.platform}`); + } + const arch: Arch | null = match(process.arch) + .with('arm', () => 'A_ARM' as Arch) + .with('arm64', () => 'A_ARM64' as Arch) + .with('ia32', () => 'A_32' as Arch) + .with('x64', () => 'A_64' as Arch) + .otherwise(() => null); + if (arch === null) { + throw new Error(`Unknown architecture ${process.arch}`); + } + + return findSupportedHlsPerGhc(plat, arch, metadata, logger); +} +/** + * Find all supported GHC versions per HLS version supported on the given + * platform and architecture. + * @param platform Platform of the host. + * @param arch Arch of the host. + * @param metadata HLS Metadata information. + * @param logger Logger. + * @returns Map from HLS version to GHC versions that are supported. + */ +export function findSupportedHlsPerGhc( + platform: Platform, + arch: Arch, + metadata: ReleaseMetadata, + logger: Logger, +): Map { + logger.info(`Platform constants: ${platform}, ${arch}`); + const newMap = new Map(); + metadata.forEach((supportedArch, hlsVersion) => { + const supportedOs = supportedArch.get(arch); + if (supportedOs) { + const ghcSupportedOnOs = supportedOs.get(platform); + if (ghcSupportedOnOs) { + logger.log(`HLS ${hlsVersion} compatible with GHC Versions: ${ghcSupportedOnOs.join(',')}`); + // copy supported ghc versions to avoid unintended modifications + newMap.set(hlsVersion, [...ghcSupportedOnOs]); + } + } + }); + + return newMap; +} + +/** + * Download GHCUP metadata. + * + * @param storagePath Path to put in binary files and caches. + * @param logger Logger for feedback. + * @returns Metadata of releases, or null if the cache can not be found. + */ +async function getReleaseMetadata(storagePath: string, logger: Logger): Promise { + const releasesUrl = workspace.getConfiguration('haskell').releasesURL + ? new URL(workspace.getConfiguration('haskell').releasesURL as string) + : undefined; + const opts: https.RequestOptions = releasesUrl + ? { + host: releasesUrl.host, + path: releasesUrl.pathname, + } + : { + host: 'raw.githubusercontent.com', + path: '/haskell/ghcup-metadata/master/hls-metadata-0.0.1.json', + }; + + const offlineCache = path.join(storagePath, 'ghcupReleases.cache.json'); + + /** + * Convert a json value to ReleaseMetadata. + * Assumes the json is well-formed and a valid Release-Metadata. + * @param someObj Release Metadata without any typing information but well-formed. + * @returns Typed ReleaseMetadata. + */ + const objectToMetadata = (someObj: any): ReleaseMetadata => { + const obj = someObj as [string: [string: [string: string[]]]]; + const hlsMetaEntries = Object.entries(obj).map(([hlsVersion, archMap]) => { + const archMetaEntries = Object.entries(archMap).map(([arch, supportedGhcVersionsPerOs]) => { + return [arch, new Map(Object.entries(supportedGhcVersionsPerOs))] as [string, Map]; + }); + return [hlsVersion, new Map(archMetaEntries)] as [string, Map>]; + }); + return new Map(hlsMetaEntries); + }; + + async function readCachedReleaseData(): Promise { + try { + logger.info(`Reading cached release data at ${offlineCache}`); + const cachedInfo = await promisify(fs.readFile)(offlineCache, { encoding: 'utf-8' }); + // export type ReleaseMetadata = Map>>; + const value: any = JSON.parse(cachedInfo); + return objectToMetadata(value); + } catch (err: any) { + // If file doesn't exist, return null, otherwise consider it a failure + if (err.code === 'ENOENT') { + logger.warn(`No cached release data found at ${offlineCache}`); + return null; + } + throw err; + } + } + + try { + const releaseInfo = await httpsGetSilently(opts); + const releaseInfoParsed = JSON.parse(releaseInfo); + + // Cache the latest successfully fetched release information + await promisify(fs.writeFile)(offlineCache, JSON.stringify(releaseInfoParsed), { encoding: 'utf-8' }); + return objectToMetadata(releaseInfoParsed); + } catch (githubError: any) { + // Attempt to read from the latest cached file + try { + const cachedInfoParsed = await readCachedReleaseData(); + + window.showWarningMessage( + "Couldn't get the latest haskell-language-server releases from GitHub, used local cache instead: " + + githubError.message, + ); + return cachedInfoParsed; + } catch (_fileError) { + throw new Error("Couldn't get the latest haskell-language-server releases from GitHub: " + githubError.message); + } + } +} diff --git a/src/statusBar.ts b/src/statusBar.ts new file mode 100644 index 00000000..1bf40507 --- /dev/null +++ b/src/statusBar.ts @@ -0,0 +1,38 @@ +import * as vscode from 'vscode'; +import * as constants from './commands/constants'; + +export class HaskellStatusBar { + readonly item: vscode.StatusBarItem; + constructor(readonly version?: string) { + // Set up the status bar item. + this.item = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left); + } + + refresh(): void { + const version = this.version ?? ''; + this.item.text = `Haskell`; + + this.item.command = constants.OpenLogsCommandName; + this.item.tooltip = new vscode.MarkdownString('', true); + this.item.tooltip.isTrusted = true; + this.item.tooltip.appendMarkdown( + `[Extension Info](command:${constants.ShowExtensionVersions} "Show Extension Version"): Version ${version}\n\n` + + `---\n\n` + + `[$(terminal) Open Logs](command:${constants.OpenLogsCommandName} "Open the logs of the Server and Extension")\n\n` + + `[$(debug-restart) Restart Server](command:${constants.RestartServerCommandName} "Restart Haskell Language Server")\n\n` + + `[$(refresh) Restart Extension](command:${constants.RestartServerCommandName} "Restart vscode-haskell Extension")\n\n`, + ); + } + + show() { + this.item.show(); + } + + hide() { + this.item.hide(); + } + + dispose() { + this.item.dispose(); + } +} diff --git a/src/utils.ts b/src/utils.ts index 1a68061c..1541ae62 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -2,80 +2,116 @@ import * as child_process from 'child_process'; import * as fs from 'fs'; import * as https from 'https'; import * as os from 'os'; -import { OutputChannel, workspace, WorkspaceFolder } from 'vscode'; +import * as process from 'process'; +import { ProgressLocation, window, workspace, WorkspaceFolder } from 'vscode'; import { Logger } from 'vscode-languageclient'; import * as which from 'which'; +import { HlsError } from './errors'; // Used for environment variables later on -export interface IEnvVars { +export type IEnvVars = { [key: string]: string; -} - -enum LogLevel { - Off, - Error, - Warn, - Info, - Debug, -} -export class ExtensionLogger implements Logger { - public readonly name: string; - public readonly level: LogLevel; - public readonly channel: OutputChannel; - public readonly logFile: string | undefined; - - constructor(name: string, level: string, channel: OutputChannel, logFile: string | undefined) { - this.name = name; - this.level = this.getLogLevel(level); - this.channel = channel; - this.logFile = logFile; - } - public warn(message: string): void { - this.logLevel(LogLevel.Warn, message); - } - - public info(message: string): void { - this.logLevel(LogLevel.Info, message); - } - - public error(message: string) { - this.logLevel(LogLevel.Error, message); - } - - public log(message: string) { - this.logLevel(LogLevel.Debug, message); - } - - private write(msg: string) { - let now = new Date(); - // Ugly hack to make js date iso format similar to hls one - const offset = now.getTimezoneOffset(); - now = new Date(now.getTime() - offset * 60 * 1000); - const timedMsg = `${new Date().toISOString().replace('T', ' ').replace('Z', '0000')} ${msg}`; - this.channel.appendLine(timedMsg); - if (this.logFile) { - fs.appendFileSync(this.logFile, timedMsg + '\n'); - } - } +}; - private logLevel(level: LogLevel, msg: string) { - if (level <= this.level) { - this.write(`[${this.name}] ${LogLevel[level].toUpperCase()} ${msg}`); - } - } +/** + * Callback invoked on process termination. + */ +export type ProcessCallback = ( + error: child_process.ExecFileException | null, + stdout: string, + stderr: string, + resolve: (value: string | PromiseLike) => void, + reject: (reason?: HlsError | Error | string) => void, +) => void; - private getLogLevel(level: string) { - switch (level) { - case 'off': - return LogLevel.Off; - case 'error': - return LogLevel.Error; - case 'debug': - return LogLevel.Debug; - default: - return LogLevel.Info; - } - } +/** + * Call a process asynchronously. + * While doing so, update the windows with progress information. + * If you need to run a process, consider preferring this over running + * the command directly. + * + * @param binary Name of the binary to invoke. + * @param args Arguments passed directly to the binary. + * @param dir Directory in which the process shall be executed. + * @param logger Logger for progress updates. + * @param title Title of the action, shown to users if available. + * @param cancellable Can the user cancel this process invocation? + * @param envAdd Extra environment variables for this process only. + * @param callback Upon process termination, execute this callback. If given, must resolve promise. On error, stderr and stdout are logged regardless of whether the callback has been specified. + * @returns Stdout of the process invocation, trimmed off newlines, or whatever the `callback` resolved to. + */ +export function callAsync( + binary: string, + args: string[], + logger: Logger, + dir?: string, + title?: string, + cancellable?: boolean, + envAdd?: IEnvVars, + callback?: ProcessCallback, +): Thenable { + let newEnv: IEnvVars = resolveServerEnvironmentPATH( + workspace.getConfiguration('haskell').get('serverEnvironment') || {}, + ); + newEnv = { ...(process.env as IEnvVars), ...newEnv, ...(envAdd || {}) }; + return window.withProgress( + { + location: ProgressLocation.Notification, + title, + cancellable, + }, + async (_, token) => { + return new Promise((resolve, reject) => { + const command: string = binary + ' ' + args.join(' '); + logger.info(`Executing '${command}' in cwd '${dir ? dir : process.cwd()}'`); + token.onCancellationRequested(() => { + logger.warn(`User canceled the execution of '${command}'`); + }); + // Need to set the encoding to 'utf8' in order to get back a string + // We execute the command in a shell for windows, to allow use .cmd or .bat scripts + const childProcess = child_process + .execFile( + process.platform === 'win32' ? `"${binary}"` : binary, + args, + { encoding: 'utf8', cwd: dir, shell: process.platform === 'win32', env: newEnv }, + (err, stdout, stderr) => { + if (err) { + logger.error(`Error executing '${command}' with error code ${err.code}`); + logger.error(`stderr: ${stderr}`); + if (stdout) { + logger.error(`stdout: ${stdout}`); + } + } + if (callback) { + callback(err, stdout, stderr, resolve, reject); + } else { + if (err) { + reject( + Error(`\`${command}\` exited with exit code ${err.code}. + Consult the [Extensions Output](https://github.com/haskell/vscode-haskell#investigating-and-reporting-problems) + for details.`), + ); + } else { + resolve(stdout?.trim()); + } + } + }, + ) + .on('exit', (code, signal) => { + const msg = + `Execution of '${command}' terminated with code ${code}` + (signal ? `and signal ${signal}` : ''); + logger.log(msg); + }) + .on('error', (err) => { + if (err) { + logger.error(`Error executing '${command}': name = ${err.name}, message = ${err.message}`); + reject(err); + } + }); + token.onCancellationRequested(() => childProcess.kill()); + }); + }, + ); } /** @@ -159,8 +195,9 @@ export async function httpsGetSilently(options: https.RequestOptions): Promise { +export function addPathToProcessPath(extraPath: string): string { const pathSep = process.platform === 'win32' ? ';' : ':'; - const serverEnvironment: IEnvVars = (await workspace.getConfiguration('haskell').get('serverEnvironment')) || {}; + const serverEnvironment: IEnvVars = workspace.getConfiguration('haskell').get('serverEnvironment') || {}; const path: string[] = serverEnvironment.PATH ? serverEnvironment.PATH.split(pathSep).map((p) => resolvePATHPlaceHolders(p)) : (process.env.PATH?.split(pathSep) ?? []); diff --git a/test/suite/extension.test.ts b/test/suite/extension.test.ts index e3759517..05d311ae 100644 --- a/test/suite/extension.test.ts +++ b/test/suite/extension.test.ts @@ -7,7 +7,7 @@ import * as vscode from 'vscode'; import * as assert from 'assert'; -import path = require('path'); +import * as path from 'path'; import * as fs from 'fs'; import { StopServerCommandName } from '../../src/commands/constants'; diff --git a/tsconfig.json b/tsconfig.json index d21ea434..365fba88 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,10 +1,9 @@ { "compilerOptions": { - "module": "commonjs", - "moduleResolution": "node", - "target": "es6", + "module": "CommonJS", + "target": "es2022", "outDir": "out", - "lib": ["es6"], + "lib": ["es2022"], "sourceMap": true, "rootDir": ".", "noUnusedLocals": true, @@ -15,6 +14,6 @@ "strictNullChecks": true, "strictBuiltinIteratorReturn": false }, - "include": ["./src/**/*.ts", "./test/**/*.ts"], + "include": ["./src/**/*.ts", "./test/**/*.ts", "eslint.config.mjs"], "exclude": ["node_modules", ".vscode", ".vscode-test"] }