From c0f3c3a90af50786f52235cdc36e0ce3f9aca8d3 Mon Sep 17 00:00:00 2001 From: Fendor Date: Sat, 28 Dec 2024 17:28:10 +0100 Subject: [PATCH 01/13] Introduce config type bundling configuration Includes the arguments passed to the server, the logging configuration, working directory, anything that is supposed to stay constant during the execution of a single HLS instance. Note, it is not promised to stay constant over restarts. --- eslint.config.mjs | 1 + src/config.ts | 131 ++++++++++++++++++++++++++++++++++++++++++++++ src/extension.ts | 100 +++++++++-------------------------- src/utils.ts | 2 +- tsconfig.json | 7 ++- 5 files changed, 161 insertions(+), 80 deletions(-) create mode 100644 src/config.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 32aa338c..fb911d4f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -7,6 +7,7 @@ export default [ { languageOptions: { globals: globals.node } }, { ...pluginJs.configs.recommended, + ...tseslint.configs.recommendedTypeChecked, rules: { '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-unused-vars': [ diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 00000000..032aaacc --- /dev/null +++ b/src/config.ts @@ -0,0 +1,131 @@ +import { OutputChannel, Uri, window, WorkspaceConfiguration, WorkspaceFolder } from 'vscode'; +import { expandHomeDir, ExtensionLogger } from './utils'; +import path = require('path'); +import { Logger } from 'vscode-languageclient'; + +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[]; +}; + +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, + }; +} + +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(); + } + } else { + throw new Error(); + } + 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("haskell.trace.server is expected to be one of 'off', 'messages', 'verbose'."); + } + } else { + throw new Error('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/extension.ts b/src/extension.ts index 480ba77a..4e2bcfd2 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, @@ -22,7 +11,8 @@ import { RestartServerCommandName, StartServerCommandName, StopServerCommandName 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 { addPathToProcessPath, comparePVP } from './utils'; +import { initConfig, initLoggerFromConfig, logConfig } from './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, @@ -112,39 +102,17 @@ async function activeServer(context: ExtensionContext, document: TextDocument) { 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); - - 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}`); - } - }); + logConfig(logger, config); let serverExecutable: string; let addInternalServerPath: string | undefined; // if we download HLS, add that bin dir to PATH @@ -152,7 +120,7 @@ async function activateServerForFolder(context: ExtensionContext, uri: Uri, fold [serverExecutable, addInternalServerPath] = await findHaskellLanguageServer( context, logger, - currentWorkingDir, + config.workingDir, folder, ); if (!serverExecutable) { @@ -190,31 +158,10 @@ async function activateServerForFolder(context: ExtensionContext, uri: Uri, fold 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 cabalFileSupport: 'automatic' | 'enable' | 'disable' = workspace.getConfiguration( - 'haskell', - uri, - ).supportCabalFiles; - logger.info(`Support for '.cabal' files: ${cabalFileSupport}`); - // 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 { @@ -231,22 +178,19 @@ async function activateServerForFolder(context: ExtensionContext, uri: Uri, fold }; } const exeOptions: ExecutableOptions = { - cwd: folder ? folder.uri.fsPath : path.dirname(uri.fsPath), + cwd: config.workingDir, 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 }, + run: { command: serverExecutable, args: config.serverArgs, options: exeOptions }, + debug: { command: serverExecutable, args: config.serverArgs, options: exeOptions }, }; - logger.info(`run command: ${serverExecutable} ${args.join(' ')}`); - logger.info(`debug command: ${serverExecutable} ${args.join(' ')}`); + logger.info(`run command: ${serverExecutable} ${config.serverArgs.join(' ')}`); + logger.info(`debug command: ${serverExecutable} ${config.serverArgs.join(' ')}`); if (exeOptions.cwd) { logger.info(`server cwd: ${exeOptions.cwd}`); } @@ -268,13 +212,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': const hlsVersion = await callAsync( serverExecutable, ['--numeric-version'], logger, - currentWorkingDir, + config.workingDir, undefined /* this command is very fast, don't show anything */, false, serverEnvironment, @@ -301,10 +251,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 +264,15 @@ 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); + await langClient.start(); } /* diff --git a/src/utils.ts b/src/utils.ts index 1a68061c..47e5102b 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -7,7 +7,7 @@ import { Logger } from 'vscode-languageclient'; import * as which from 'which'; // Used for environment variables later on -export interface IEnvVars { +export type IEnvVars = { [key: string]: string; } diff --git a/tsconfig.json b/tsconfig.json index d21ea434..e2611621 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, From c187980aa8fe10fe49e4549cbf4d5e545edca458 Mon Sep 17 00:00:00 2001 From: Fendor Date: Mon, 30 Dec 2024 11:39:50 +0100 Subject: [PATCH 02/13] Introduce named type for Hls Binary locations --- src/config.ts | 4 +- src/extension.ts | 48 +++++++++---------- src/hlsBinaries.ts | 115 ++++++++++++++++++++++++++++++--------------- src/utils.ts | 4 +- 4 files changed, 104 insertions(+), 67 deletions(-) diff --git a/src/config.ts b/src/config.ts index 032aaacc..bc410749 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,5 +1,5 @@ import { OutputChannel, Uri, window, WorkspaceConfiguration, WorkspaceFolder } from 'vscode'; -import { expandHomeDir, ExtensionLogger } from './utils'; +import { expandHomeDir, ExtensionLogger, IEnvVars } from './utils'; import path = require('path'); import { Logger } from 'vscode-languageclient'; @@ -17,6 +17,7 @@ export type Config = { workingDir: string; outputChannel: OutputChannel; serverArgs: string[]; + serverEnvironment: IEnvVars; }; export function initConfig(workspaceConfig: WorkspaceConfiguration, uri: Uri, folder?: WorkspaceFolder): Config { @@ -41,6 +42,7 @@ export function initConfig(workspaceConfig: WorkspaceConfiguration, uri: Uri, fo workingDir: currentWorkingDir, outputChannel: outputChannel, serverArgs: serverArgs, + serverEnvironment: workspaceConfig.serverEnvironment, }; } diff --git a/src/extension.ts b/src/extension.ts index 4e2bcfd2..8fe0a982 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -10,9 +10,9 @@ import { import { RestartServerCommandName, StartServerCommandName, StopServerCommandName } from './commands/constants'; import * as DocsBrowser from './docsBrowser'; import { HlsError, MissingToolError, NoMatchingHls } from './errors'; -import { callAsync, findHaskellLanguageServer, IEnvVars } from './hlsBinaries'; +import { callAsync, findHaskellLanguageServer, HlsExecutable, IEnvVars } from './hlsBinaries'; import { addPathToProcessPath, comparePVP } from './utils'; -import { initConfig, initLoggerFromConfig, logConfig } from './config'; +import { Config, initConfig, initLoggerFromConfig, logConfig } from './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, @@ -114,18 +114,9 @@ async function activateServerForFolder(context: ExtensionContext, uri: Uri, fold logConfig(logger, config); - 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, - config.workingDir, - folder, - ); - if (!serverExecutable) { - return; - } + hlsExecutable = await findHaskellLanguageServer(context, logger, config.workingDir, folder); } catch (e) { if (e instanceof MissingToolError) { const link = e.installLink(); @@ -169,14 +160,7 @@ 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 serverEnvironment: IEnvVars = initServerEnvironment(config, hlsExecutable); const exeOptions: ExecutableOptions = { cwd: config.workingDir, env: { ...process.env, ...serverEnvironment }, @@ -185,12 +169,12 @@ async function activateServerForFolder(context: ExtensionContext, uri: Uri, fold // 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: config.serverArgs, options: exeOptions }, - debug: { command: serverExecutable, args: config.serverArgs, options: exeOptions }, + run: { command: hlsExecutable.location, args: config.serverArgs, options: exeOptions }, + debug: { command: hlsExecutable.location, args: config.serverArgs, options: exeOptions }, }; - logger.info(`run command: ${serverExecutable} ${config.serverArgs.join(' ')}`); - logger.info(`debug command: ${serverExecutable} ${config.serverArgs.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}`); } @@ -221,7 +205,7 @@ async function activateServerForFolder(context: ExtensionContext, uri: Uri, fold switch (cabalFileSupport) { case 'automatic': const hlsVersion = await callAsync( - serverExecutable, + hlsExecutable.location, ['--numeric-version'], logger, config.workingDir, @@ -275,6 +259,18 @@ async function activateServerForFolder(context: ExtensionContext, uri: Uri, fold await langClient.start(); } +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; +} + /* * Deactivate each of the LSP servers. */ diff --git a/src/hlsBinaries.ts b/src/hlsBinaries.ts index 65ea68c7..967149f5 100644 --- a/src/hlsBinaries.ts +++ b/src/hlsBinaries.ts @@ -28,6 +28,11 @@ type ToolConfig = Map; type ManageHLS = 'GHCup' | 'PATH'; let manageHLS = workspace.getConfiguration('haskell').get('manageHLS') as ManageHLS; +export type Context = { + manageHls: ManageHLS; + serverResolved?: HlsExecutable; +}; + // On Windows the executable needs to be stored somewhere with an .exe extension const exeExt = process.platform === 'win32' ? '.exe' : ''; @@ -163,6 +168,27 @@ 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 @@ -181,12 +207,15 @@ export async function findHaskellLanguageServer( logger: Logger, 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]; + return { + location: exe, + tag: 'config', + }; } const storagePath: string = await getStoragePath(context); @@ -196,47 +225,24 @@ export async function findHaskellLanguageServer( } // 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); - } + manageHLS = await promptUserForManagingHls(context, manageHLS); if (manageHLS === 'PATH') { const exe = await findHLSinPATH(context, logger); - return [exe, undefined]; + return { + location: exe, + tag: 'path', + }; } else { // we manage HLS, make sure ghcup is installed/available await upgradeGHCup(context, logger); // boring init - let latestHLS: string | undefined | null; + let latestHLS: string | undefined; let latestCabal: string | undefined | null; let latestStack: string | undefined | null; let recGHC: string | undefined | null = 'recommended'; - let projectHls: string | undefined | null; + let projectHls: string | undefined; let projectGhc: string | undefined | null; // support explicit toolchain config @@ -358,7 +364,7 @@ export async function findHaskellLanguageServer( // more download popups if (promptBeforeDownloads) { - const hlsInstalled = projectHls ? await toolInstalled(context, logger, 'hls', projectHls) : undefined; + const hlsInstalled = await toolInstalled(context, logger, 'hls', projectHls); const ghcInstalled = projectGhc ? await toolInstalled(context, logger, 'ghc', projectGhc) : undefined; const toInstall: InstalledTool[] = [hlsInstalled, ghcInstalled].filter( (tool) => tool && !tool.installed, @@ -398,7 +404,7 @@ export async function findHaskellLanguageServer( logger, [ 'run', - ...(projectHls ? ['--hls', projectHls] : []), + ...['--hls', projectHls], ...(latestCabal ? ['--cabal', latestCabal] : []), ...(latestStack ? ['--stack', latestStack] : []), ...(projectGhc ? ['--ghc', projectGhc] : []), @@ -416,12 +422,45 @@ export async function findHaskellLanguageServer( true, ); - 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', + }; + } +} + +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 { - const exe = await findHLSinPATH(context, logger); - return [exe, hlsBinDir]; + window.showWarningMessage( + "Choosing default PATH method for HLS discovery. You can change this via 'haskell.manageHLS' in the settings.", + ); + howToManage = 'PATH'; } + workspace.getConfiguration('haskell').update('manageHLS', howToManage, ConfigurationTarget.Global); + context.globalState.update('pluginInitialized', true); + return howToManage; + } else { + return manageHlsSetting; } } diff --git a/src/utils.ts b/src/utils.ts index 47e5102b..0d1ba295 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -201,9 +201,9 @@ export function resolvePATHPlaceHolders(path: string) { } // also honours serverEnvironment.PATH -export async function addPathToProcessPath(extraPath: string): 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) ?? []); From b1a560577aae28e89ec434da481b5239cdf1c635 Mon Sep 17 00:00:00 2001 From: Fendor Date: Mon, 30 Dec 2024 12:08:02 +0100 Subject: [PATCH 03/13] Group log messages for server activation --- src/extension.ts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index 8fe0a982..3f6f51d6 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -149,17 +149,6 @@ async function activateServerForFolder(context: ExtensionContext, uri: Uri, fold return; } - // 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: ${config.workingDir}`; - if (folder) { - cwdMsg += ' (the workspace folder)'; - } else { - cwdMsg += ` (parent dir of loaded file ${uri.fsPath})`; - } - logger.info(cwdMsg); - const serverEnvironment: IEnvVars = initServerEnvironment(config, hlsExecutable); const exeOptions: ExecutableOptions = { cwd: config.workingDir, @@ -173,6 +162,17 @@ async function activateServerForFolder(context: ExtensionContext, uri: Uri, fold 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: ${config.workingDir}`; + if (folder) { + cwdMsg += ' (the workspace folder)'; + } else { + cwdMsg += ` (parent dir of loaded file ${uri.fsPath})`; + } + logger.info(cwdMsg); + logger.info(`run command: ${hlsExecutable.location} ${config.serverArgs.join(' ')}`); logger.info(`debug command: ${hlsExecutable.location} ${config.serverArgs.join(' ')}`); if (exeOptions.cwd) { From ccec478c93139d64738ed5b2594e2ac2abeb0015 Mon Sep 17 00:00:00 2001 From: Fendor Date: Mon, 30 Dec 2024 12:26:21 +0100 Subject: [PATCH 04/13] WIP: rename and get rid of unused await's --- src/hlsBinaries.ts | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/src/hlsBinaries.ts b/src/hlsBinaries.ts index 967149f5..53bab6c7 100644 --- a/src/hlsBinaries.ts +++ b/src/hlsBinaries.ts @@ -139,22 +139,22 @@ export async function callAsync( /** Gets serverExecutablePath and fails if it's not set. */ -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 = workspace.getConfiguration('haskell').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. */ -async function findHLSinPATH(_context: ExtensionContext, logger: Logger): Promise { +function findHlsInPath(_context: ExtensionContext, 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`); @@ -210,16 +210,16 @@ export async function findHaskellLanguageServer( ): Promise { logger.info('Finding haskell-language-server'); - if (workspace.getConfiguration('haskell').get('serverExecutablePath') as string) { - const exe = await findServerExecutable(logger, folder); + const hasConfigForExecutable = workspace.getConfiguration('haskell').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); } @@ -228,7 +228,7 @@ export async function findHaskellLanguageServer( manageHLS = await promptUserForManagingHls(context, manageHLS); if (manageHLS === 'PATH') { - const exe = await findHLSinPATH(context, logger); + const exe = findHlsInPath(context, logger); return { location: exe, tag: 'path', @@ -512,7 +512,7 @@ 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(context, logger)) || new Map(); // then we get supported GHC versions from currently installed HLS versions const ghcupMap = (await getHLSesFromGHCup(context, logger)) || new Map(); // since installed HLS versions may support a different set of GHC versions than the bindists @@ -657,8 +657,8 @@ export async function findGHCup(_context: ExtensionContext, logger: Logger, fold } } -export async function getStoragePath(context: ExtensionContext): Promise { - let storagePath: string | undefined = await workspace.getConfiguration('haskell').get('releasesDownloadStoragePath'); +export function getStoragePath(context: ExtensionContext): string { + let storagePath: string | undefined = workspace.getConfiguration('haskell').get('releasesDownloadStoragePath'); if (!storagePath) { storagePath = context.globalStorageUri.fsPath; @@ -819,8 +819,8 @@ export type ReleaseMetadata = Map>>; * @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); +async function getHlsMetadata(context: ExtensionContext, logger: Logger): Promise | null> { + const storagePath: string = getStoragePath(context); const metadata = await getReleaseMetadata(context, storagePath, logger).catch(() => null); if (!metadata) { window.showErrorMessage('Could not get release metadata'); From a41e3623994c36e7038d037cf5c9bb80674737d1 Mon Sep 17 00:00:00 2001 From: Fendor Date: Mon, 30 Dec 2024 13:42:37 +0100 Subject: [PATCH 05/13] More typechecking and fix the type errors --- eslint.config.mjs | 29 ++++++++--- src/config.ts | 2 +- src/docsBrowser.ts | 25 ++++----- src/extension.ts | 18 ++++--- src/hlsBinaries.ts | 125 ++++++++++++++++++--------------------------- tsconfig.json | 2 +- 6 files changed, 98 insertions(+), 103 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index fb911d4f..5c88b3b4 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,15 +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, - ...tseslint.configs.recommendedTypeChecked, + 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', { @@ -24,5 +40,4 @@ export default [ ], }, }, - ...tseslint.configs.recommended, -]; +); diff --git a/src/config.ts b/src/config.ts index bc410749..0f3ec34a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,6 @@ import { OutputChannel, Uri, window, WorkspaceConfiguration, WorkspaceFolder } from 'vscode'; import { expandHomeDir, ExtensionLogger, IEnvVars } from './utils'; -import path = require('path'); +import * as path from 'path'; import { Logger } from 'vscode-languageclient'; export type LogLevel = 'off' | 'messages' | 'verbose'; 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/extension.ts b/src/extension.ts index 3f6f51d6..c8d75e2b 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -26,11 +26,13 @@ export async function activate(context: ExtensionContext) { // 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) { @@ -38,7 +40,7 @@ export async function activate(context: ExtensionContext) { client.info(`Deleting folder for clients: ${uri}`); clients.delete(uri); client.info('Stopping the server'); - client.stop(); + await client.stop(); } } }); @@ -49,7 +51,7 @@ export async function activate(context: ExtensionContext) { langClient?.info('Stopping the server'); await langClient?.stop(); langClient?.info('Starting the server'); - langClient?.start(); + await langClient?.start(); } }); @@ -68,7 +70,7 @@ export async function activate(context: ExtensionContext) { const startCmd = commands.registerCommand(StartServerCommandName, async () => { for (const langClient of clients.values()) { langClient?.info('Starting the server'); - langClient?.start(); + await langClient?.start(); langClient?.info('Server started'); } }); @@ -83,7 +85,7 @@ export async function activate(context: ExtensionContext) { context.subscriptions.push(openOnHackageDisposable); } -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' && @@ -97,7 +99,7 @@ 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) { diff --git a/src/hlsBinaries.ts b/src/hlsBinaries.ts index 53bab6c7..9a5bd552 100644 --- a/src/hlsBinaries.ts +++ b/src/hlsBinaries.ts @@ -1,7 +1,5 @@ -/* 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'; @@ -235,7 +233,7 @@ export async function findHaskellLanguageServer( }; } else { // we manage HLS, make sure ghcup is installed/available - await upgradeGHCup(context, logger); + await upgradeGHCup(logger); // boring init let latestHLS: string | undefined; @@ -247,7 +245,7 @@ export async function findHaskellLanguageServer( // support explicit toolchain config const toolchainConfig = new Map( - Object.entries(workspace.getConfiguration('haskell').get('toolchain') as any), + Object.entries(workspace.getConfiguration('haskell').get('toolchain') as ToolConfig), ) as ToolConfig; if (toolchainConfig) { latestHLS = toolchainConfig.get('hls'); @@ -263,26 +261,24 @@ 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 getLatestToolFromGHCup(logger, 'hls'); } if (latestCabal === undefined) { - latestCabal = await getLatestToolFromGHCup(context, logger, 'cabal'); + latestCabal = await getLatestToolFromGHCup(logger, 'cabal'); } if (latestStack === undefined) { - latestStack = await getLatestToolFromGHCup(context, logger, 'stack'); + latestStack = await getLatestToolFromGHCup(logger, 'stack'); } if (recGHC === undefined) { - recGHC = !executableExists('ghc') - ? await getLatestAvailableToolFromGHCup(context, logger, 'ghc', 'recommended') - : null; + recGHC = !executableExists('ghc') ? await getLatestAvailableToolFromGHCup(logger, 'ghc', 'recommended') : null; } // download popups const promptBeforeDownloads = workspace.getConfiguration('haskell').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 toolInstalled(logger, 'hls', latestHLS) : undefined; + const cabalInstalled = latestCabal ? await toolInstalled(logger, 'cabal', latestCabal) : undefined; + const stackInstalled = latestStack ? await toolInstalled(logger, 'stack', latestStack) : undefined; const ghcInstalled = executableExists('ghc') ? new InstalledTool( 'ghc', @@ -290,7 +286,7 @@ export async function findHaskellLanguageServer( ) : // if recGHC is null, that means user disabled automatic handling, recGHC !== null - ? await toolInstalled(context, logger, 'ghc', recGHC) + ? await toolInstalled(logger, 'ghc', recGHC) : undefined; const toInstall: InstalledTool[] = [hlsInstalled, cabalInstalled, stackInstalled, ghcInstalled].filter( (tool) => tool && !tool.installed, @@ -329,7 +325,6 @@ export async function findHaskellLanguageServer( // our preliminary toolchain const latestToolchainBindir = await callGHCup( - context, logger, [ 'run', @@ -364,8 +359,8 @@ export async function findHaskellLanguageServer( // more download popups if (promptBeforeDownloads) { - const hlsInstalled = await toolInstalled(context, logger, 'hls', projectHls); - const ghcInstalled = projectGhc ? await toolInstalled(context, logger, 'ghc', projectGhc) : undefined; + const hlsInstalled = await toolInstalled(logger, 'hls', projectHls); + const ghcInstalled = projectGhc ? await toolInstalled(logger, 'ghc', projectGhc) : undefined; const toInstall: InstalledTool[] = [hlsInstalled, ghcInstalled].filter( (tool) => tool && !tool.installed, ) as InstalledTool[]; @@ -400,7 +395,6 @@ export async function findHaskellLanguageServer( // now install the proper versions const hlsBinDir = await callGHCup( - context, logger, [ 'run', @@ -465,7 +459,6 @@ async function promptUserForManagingHls(context: ExtensionContext, manageHlsSett } async function callGHCup( - context: ExtensionContext, logger: Logger, args: string[], title?: string, @@ -475,7 +468,7 @@ async function callGHCup( const metadataUrl = workspace.getConfiguration('haskell').metadataURL; if (manageHLS === 'GHCup') { - const ghcup = await findGHCup(context, logger); + const ghcup = findGHCup(logger); return await callAsync( ghcup, ['--no-verbose'].concat(metadataUrl ? ['-s', metadataUrl] : []).concat(args), @@ -502,7 +495,7 @@ async function getLatestProjectHLS( ): 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}'.`, @@ -514,7 +507,7 @@ async function getLatestProjectHLS( // first we get supported GHC versions from available HLS bindists (whether installed or not) const metadataMap = (await getHlsMetadata(context, 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(logger)) || 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 @@ -540,7 +533,7 @@ async function getLatestProjectHLS( * @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, @@ -550,7 +543,7 @@ export async function getProjectGHCVersion( const args = ['--project-ghc-version']; - const newPath = await addPathToProcessPath(toolchainBindir); + const newPath = addPathToProcessPath(toolchainBindir); const environmentNew: IEnvVars = { PATH: newPath, }; @@ -590,18 +583,18 @@ export async function getProjectGHCVersion( ); } -export async function upgradeGHCup(context: ExtensionContext, logger: Logger): Promise { +export async function upgradeGHCup(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); + await callGHCup(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 { +export function findGHCup(logger: Logger, folder?: WorkspaceFolder): string { logger.info('Checking for ghcup installation'); let exePath = workspace.getConfiguration('haskell').get('ghcupExecutablePath') as string; if (exePath) { @@ -670,25 +663,18 @@ export function getStoragePath(context: ExtensionContext): string { } // the tool might be installed or not -async function getLatestToolFromGHCup(context: ExtensionContext, logger: Logger, tool: Tool): Promise { +async function getLatestToolFromGHCup(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 installedVersions = await callGHCup(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); + return getLatestAvailableToolFromGHCup(logger, tool); } async function getLatestAvailableToolFromGHCup( - context: ExtensionContext, logger: Logger, tool: Tool, tag?: string, @@ -696,7 +682,6 @@ async function getLatestAvailableToolFromGHCup( ): Promise { // fall back to installable versions const availableVersions = await callGHCup( - context, logger, ['list', '-t', tool, '-c', criteria ? criteria : 'available', '-r'], undefined, @@ -721,25 +706,26 @@ async function getLatestAvailableToolFromGHCup( } } -// 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 context + * @param logger + * @returns + */ + +async function findAvailableHlsBinariesFromGHCup(logger: Logger): Promise | null> { + const hlsVersions = await callGHCup(logger, ['list', '-t', 'hls', '-c', 'installed', '-r'], undefined, false); - const bindir = await callGHCup(context, logger, ['whereis', 'bindir'], undefined, false); + const bindir = await callGHCup(logger, ['whereis', 'bindir'], undefined, false); - const files = fs.readdirSync(bindir).filter(async (e) => { - return await stat(path.join(bindir, e)) - .then((s) => s.isDirectory()) - .catch(() => 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]); @@ -761,13 +747,8 @@ async function getHLSesFromGHCup(context: ExtensionContext, logger: Logger): Pro } } -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 toolInstalled(logger: Logger, tool: Tool, version: string): Promise { + const b = await callGHCup(logger, ['whereis', tool, version], undefined, false) .then(() => true) .catch(() => false); return new InstalledTool(tool, version, b); @@ -821,7 +802,7 @@ export type ReleaseMetadata = Map>>; */ async function getHlsMetadata(context: ExtensionContext, logger: Logger): Promise | null> { const storagePath: string = getStoragePath(context); - const metadata = await getReleaseMetadata(context, storagePath, logger).catch(() => null); + const metadata = await getReleaseMetadata(storagePath, logger).catch(() => null); if (!metadata) { window.showErrorMessage('Could not get release metadata'); return null; @@ -874,7 +855,7 @@ export function findSupportedHlsPerGhc( if (supportedOs) { const ghcSupportedOnOs = supportedOs.get(platform); if (ghcSupportedOnOs) { - logger.log(`HLS ${hlsVersion} compatible with GHC Versions: ${ghcSupportedOnOs}`); + logger.log(`HLS ${hlsVersion} compatible with GHC Versions: ${ghcSupportedOnOs.join(',')}`); // copy supported ghc versions to avoid unintended modifications newMap.set(hlsVersion, [...ghcSupportedOnOs]); } @@ -887,18 +868,13 @@ export function findSupportedHlsPerGhc( /** * 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 { +async function getReleaseMetadata(storagePath: string, logger: Logger): Promise { const releasesUrl = workspace.getConfiguration('haskell').releasesURL - ? new URL(workspace.getConfiguration('haskell').releasesURL) + ? new URL(workspace.getConfiguration('haskell').releasesURL as string) : undefined; const opts: https.RequestOptions = releasesUrl ? { @@ -918,10 +894,11 @@ async function getReleaseMetadata( * @param obj Release Metadata without any typing information but well-formed. * @returns Typed ReleaseMetadata. */ - const objectToMetadata = (obj: any): 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 as any).map(([arch, supportedGhcVersionsPerOs]) => { - return [arch, new Map(Object.entries(supportedGhcVersionsPerOs as any))] as [string, Map]; + 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>]; }); diff --git a/tsconfig.json b/tsconfig.json index e2611621..365fba88 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,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"] } From a7249fd83ac7866a793fad290bc766d1f36661b8 Mon Sep 17 00:00:00 2001 From: Fendor Date: Thu, 2 Jan 2025 17:04:10 +0100 Subject: [PATCH 06/13] Move ghcup specific code into its own file --- src/config.ts | 3 +- src/extension.ts | 4 +- src/ghcup.ts | 143 +++++++++++++++++++++++ src/hlsBinaries.ts | 279 +++------------------------------------------ src/logger.ts | 70 ++++++++++++ src/utils.ts | 171 ++++++++++++++++----------- 6 files changed, 340 insertions(+), 330 deletions(-) create mode 100644 src/ghcup.ts create mode 100644 src/logger.ts diff --git a/src/config.ts b/src/config.ts index 0f3ec34a..3712ccf1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,7 +1,8 @@ import { OutputChannel, Uri, window, WorkspaceConfiguration, WorkspaceFolder } from 'vscode'; -import { expandHomeDir, ExtensionLogger, IEnvVars } from './utils'; +import { expandHomeDir, IEnvVars } from './utils'; import * as path from 'path'; import { Logger } from 'vscode-languageclient'; +import { ExtensionLogger } from './logger'; export type LogLevel = 'off' | 'messages' | 'verbose'; export type ClientLogLevel = 'off' | 'error' | 'info' | 'debug'; diff --git a/src/extension.ts b/src/extension.ts index c8d75e2b..ba5579b5 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -10,8 +10,8 @@ import { import { RestartServerCommandName, StartServerCommandName, StopServerCommandName } from './commands/constants'; import * as DocsBrowser from './docsBrowser'; import { HlsError, MissingToolError, NoMatchingHls } from './errors'; -import { callAsync, findHaskellLanguageServer, HlsExecutable, IEnvVars } from './hlsBinaries'; -import { addPathToProcessPath, comparePVP } from './utils'; +import { findHaskellLanguageServer, HlsExecutable, IEnvVars } from './hlsBinaries'; +import { addPathToProcessPath, comparePVP, callAsync } from './utils'; import { Config, initConfig, initLoggerFromConfig, logConfig } from './config'; // The current map of documents & folders to language servers. diff --git a/src/ghcup.ts b/src/ghcup.ts new file mode 100644 index 00000000..a1e67bda --- /dev/null +++ b/src/ghcup.ts @@ -0,0 +1,143 @@ +import * as path from 'path'; +import * as os from 'os'; +import * as process from 'process'; +import { workspace, WorkspaceFolder } from 'vscode'; +import { Logger } from 'vscode-languageclient'; +import { MissingToolError } from './errors'; +import { resolvePathPlaceHolders, executableExists, callAsync, ProcessCallback } from './utils'; +import { match } from 'ts-pattern'; + +export type Tool = 'hls' | 'ghc' | 'cabal' | 'stack'; + +export type ToolConfig = Map; + +export async function callGHCup( + logger: Logger, + args: string[], + title?: string, + cancellable?: boolean, + callback?: ProcessCallback, +): Promise { + const metadataUrl = workspace.getConfiguration('haskell').metadataURL; + const ghcup = findGHCup(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, + ); +} + +export async function upgradeGHCup(logger: Logger): Promise { + const upgrade = workspace.getConfiguration('haskell').get('upgradeGHCup') as boolean; + if (upgrade) { + await callGHCup(logger, ['upgrade'], 'Upgrading ghcup', true); + } +} + +export function findGHCup(logger: Logger, folder?: WorkspaceFolder): string { + 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: 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; + } + } +} + +// the tool might be installed or not +export async function getLatestToolFromGHCup(logger: Logger, tool: Tool): Promise { + // these might be custom/stray/compiled, so we try first + const installedVersions = await callGHCup(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(logger, tool); +} + +export async function getLatestAvailableToolFromGHCup( + logger: Logger, + tool: Tool, + tag?: string, + criteria?: string, +): Promise { + // fall back to installable versions + const availableVersions = await callGHCup( + 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; + } +} diff --git a/src/hlsBinaries.ts b/src/hlsBinaries.ts index 9a5bd552..3fc8c288 100644 --- a/src/hlsBinaries.ts +++ b/src/hlsBinaries.ts @@ -1,140 +1,36 @@ -import * as child_process from 'child_process'; import * as fs from 'fs'; 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 * as ghcup from './ghcup'; +import { ToolConfig, Tool } from './ghcup'; export { IEnvVars }; -type Tool = 'hls' | 'ghc' | 'cabal' | 'stack'; - -type ToolConfig = Map; - type ManageHLS = 'GHCup' | 'PATH'; let manageHLS = workspace.getConfiguration('haskell').get('manageHLS') as ManageHLS; export type Context = { manageHls: ManageHLS; - serverResolved?: HlsExecutable; + serverExecutable?: HlsExecutable; + logger: Logger; }; // On Windows the executable needs to be stored somewhere with an .exe extension const exeExt = process.platform === 'win32' ? '.exe' : ''; -/** - * Callback invoked on process termination. - */ -type ProcessCallback = ( - error: child_process.ExecFileException | null, - stdout: string, - stderr: string, - resolve: (value: string | PromiseLike) => void, - reject: (reason?: HlsError | Error | string) => void, -) => void; - -/** - * 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 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()); - }); - }, - ); -} - /** Gets serverExecutablePath and fails if it's not set. */ function findServerExecutable(logger: Logger, folder?: WorkspaceFolder): string { @@ -233,7 +129,7 @@ export async function findHaskellLanguageServer( }; } else { // we manage HLS, make sure ghcup is installed/available - await upgradeGHCup(logger); + await ghcup.upgradeGHCup(logger); // boring init let latestHLS: string | undefined; @@ -261,16 +157,18 @@ 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(logger, 'hls'); + latestHLS = await ghcup.getLatestToolFromGHCup(logger, 'hls'); } if (latestCabal === undefined) { - latestCabal = await getLatestToolFromGHCup(logger, 'cabal'); + latestCabal = await ghcup.getLatestToolFromGHCup(logger, 'cabal'); } if (latestStack === undefined) { - latestStack = await getLatestToolFromGHCup(logger, 'stack'); + latestStack = await ghcup.getLatestToolFromGHCup(logger, 'stack'); } if (recGHC === undefined) { - recGHC = !executableExists('ghc') ? await getLatestAvailableToolFromGHCup(logger, 'ghc', 'recommended') : null; + recGHC = !executableExists('ghc') + ? await ghcup.getLatestAvailableToolFromGHCup(logger, 'ghc', 'recommended') + : null; } // download popups @@ -324,7 +222,7 @@ export async function findHaskellLanguageServer( } // our preliminary toolchain - const latestToolchainBindir = await callGHCup( + const latestToolchainBindir = await ghcup.callGHCup( logger, [ 'run', @@ -394,7 +292,7 @@ export async function findHaskellLanguageServer( } // now install the proper versions - const hlsBinDir = await callGHCup( + const hlsBinDir = await ghcup.callGHCup( logger, [ 'run', @@ -458,35 +356,6 @@ async function promptUserForManagingHls(context: ExtensionContext, manageHlsSett } } -async function callGHCup( - logger: Logger, - args: string[], - title?: string, - cancellable?: boolean, - callback?: ProcessCallback, -): Promise { - const metadataUrl = workspace.getConfiguration('haskell').metadataURL; - - if (manageHLS === 'GHCup') { - const ghcup = findGHCup(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, - ); - } else { - throw new HlsError(`Internal error: tried to call ghcup while haskell.manageHLS is set to ${manageHLS}. Aborting!`); - } -} - async function getLatestProjectHLS( context: ExtensionContext, logger: Logger, @@ -583,73 +452,6 @@ export async function getProjectGhcVersion( ); } -export async function upgradeGHCup(logger: Logger): Promise { - if (manageHLS === 'GHCup') { - const upgrade = workspace.getConfiguration('haskell').get('upgradeGHCup') as boolean; - if (upgrade) { - await callGHCup(logger, ['upgrade'], 'Upgrading ghcup', true); - } - } else { - throw new Error(`Internal error: tried to call ghcup while haskell.manageHLS is set to ${manageHLS}. Aborting!`); - } -} - -export function findGHCup(logger: Logger, folder?: WorkspaceFolder): string { - 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 function getStoragePath(context: ExtensionContext): string { let storagePath: string | undefined = workspace.getConfiguration('haskell').get('releasesDownloadStoragePath'); @@ -662,50 +464,6 @@ export function getStoragePath(context: ExtensionContext): string { return storagePath; } -// the tool might be installed or not -async function getLatestToolFromGHCup(logger: Logger, tool: Tool): Promise { - // these might be custom/stray/compiled, so we try first - const installedVersions = await callGHCup(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(logger, tool); -} - -async function getLatestAvailableToolFromGHCup( - logger: Logger, - tool: Tool, - tag?: string, - criteria?: string, -): Promise { - // fall back to installable versions - const availableVersions = await callGHCup( - 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 {@link getReleaseMetadata}, by checking possibly locally compiled @@ -719,9 +477,9 @@ async function getLatestAvailableToolFromGHCup( */ async function findAvailableHlsBinariesFromGHCup(logger: Logger): Promise | null> { - const hlsVersions = await callGHCup(logger, ['list', '-t', 'hls', '-c', 'installed', '-r'], undefined, false); + const hlsVersions = await ghcup.callGHCup(logger, ['list', '-t', 'hls', '-c', 'installed', '-r'], undefined, false); - const bindir = await callGHCup(logger, ['whereis', 'bindir'], undefined, false); + const bindir = await ghcup.callGHCup(logger, ['whereis', 'bindir'], undefined, false); const files = fs.readdirSync(bindir).filter((e) => { const stat = fs.statSync(path.join(bindir, e)); @@ -748,7 +506,8 @@ async function findAvailableHlsBinariesFromGHCup(logger: Logger): Promise { - const b = await callGHCup(logger, ['whereis', tool, version], undefined, false) + const b = await ghcup + .callGHCup(logger, ['whereis', tool, version], undefined, false) .then(() => true) .catch(() => false); return new InstalledTool(tool, version, b); 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/utils.ts b/src/utils.ts index 0d1ba295..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 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 Date: Thu, 2 Jan 2025 17:34:15 +0100 Subject: [PATCH 07/13] Turn ghcup into a class --- src/config.ts | 7 ++ src/extension.ts | 2 +- src/ghcup.ts | 176 ++++++++++++++++++++++++++------------------- src/hlsBinaries.ts | 59 +++++++-------- 4 files changed, 137 insertions(+), 107 deletions(-) diff --git a/src/config.ts b/src/config.ts index 3712ccf1..6acef3dc 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,6 +3,7 @@ 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'; @@ -19,6 +20,7 @@ export type Config = { outputChannel: OutputChannel; serverArgs: string[]; serverEnvironment: IEnvVars; + ghcupConfig: GHCupConfig; }; export function initConfig(workspaceConfig: WorkspaceConfiguration, uri: Uri, folder?: WorkspaceFolder): Config { @@ -44,6 +46,11 @@ export function initConfig(workspaceConfig: WorkspaceConfiguration, uri: Uri, fo 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, + }, }; } diff --git a/src/extension.ts b/src/extension.ts index ba5579b5..22a99319 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -118,7 +118,7 @@ async function activateServerForFolder(context: ExtensionContext, uri: Uri, fold let hlsExecutable: HlsExecutable; try { - hlsExecutable = await findHaskellLanguageServer(context, logger, config.workingDir, folder); + hlsExecutable = await findHaskellLanguageServer(context, logger, config.ghcupConfig, config.workingDir, folder); } catch (e) { if (e instanceof MissingToolError) { const link = e.installLink(); diff --git a/src/ghcup.ts b/src/ghcup.ts index a1e67bda..c19e9c5a 100644 --- a/src/ghcup.ts +++ b/src/ghcup.ts @@ -1,50 +1,122 @@ import * as path from 'path'; import * as os from 'os'; import * as process from 'process'; -import { workspace, WorkspaceFolder } from 'vscode'; +import { WorkspaceFolder } from 'vscode'; import { Logger } from 'vscode-languageclient'; import { MissingToolError } from './errors'; -import { resolvePathPlaceHolders, executableExists, callAsync, ProcessCallback } from './utils'; +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 async function callGHCup( - logger: Logger, - args: string[], - title?: string, - cancellable?: boolean, - callback?: ProcessCallback, -): Promise { - const metadataUrl = workspace.getConfiguration('haskell').metadataURL; - const ghcup = findGHCup(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, - ); +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 async function upgradeGHCup(logger: Logger): Promise { - const upgrade = workspace.getConfiguration('haskell').get('upgradeGHCup') as boolean; - if (upgrade) { - await callGHCup(logger, ['upgrade'], 'Upgrading ghcup', true); +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; + } } } -export function findGHCup(logger: Logger, folder?: WorkspaceFolder): string { +function findGHCup(logger: Logger, exePath?: string, folder?: WorkspaceFolder): string { 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); @@ -97,47 +169,3 @@ export function findGHCup(logger: Logger, folder?: WorkspaceFolder): string { } } } - -// the tool might be installed or not -export async function getLatestToolFromGHCup(logger: Logger, tool: Tool): Promise { - // these might be custom/stray/compiled, so we try first - const installedVersions = await callGHCup(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(logger, tool); -} - -export async function getLatestAvailableToolFromGHCup( - logger: Logger, - tool: Tool, - tag?: string, - criteria?: string, -): Promise { - // fall back to installable versions - const availableVersions = await callGHCup( - 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; - } -} diff --git a/src/hlsBinaries.ts b/src/hlsBinaries.ts index 3fc8c288..61f883fb 100644 --- a/src/hlsBinaries.ts +++ b/src/hlsBinaries.ts @@ -15,8 +15,7 @@ import { IEnvVars, resolvePathPlaceHolders, } from './utils'; -import * as ghcup from './ghcup'; -import { ToolConfig, Tool } from './ghcup'; +import { ToolConfig, Tool, initDefaultGHCup, GHCup, GHCupConfig } from './ghcup'; export { IEnvVars }; type ManageHLS = 'GHCup' | 'PATH'; @@ -99,6 +98,7 @@ export type HlsViaGhcup = { export async function findHaskellLanguageServer( context: ExtensionContext, logger: Logger, + ghcupConfig: GHCupConfig, workingDir: string, folder?: WorkspaceFolder, ): Promise { @@ -129,7 +129,8 @@ export async function findHaskellLanguageServer( }; } else { // we manage HLS, make sure ghcup is installed/available - await ghcup.upgradeGHCup(logger); + const ghcup = initDefaultGHCup(ghcupConfig, logger, folder); + await ghcup.upgrade(); // boring init let latestHLS: string | undefined; @@ -157,26 +158,24 @@ 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 ghcup.getLatestToolFromGHCup(logger, 'hls'); + latestHLS = await ghcup.getLatestVersion('hls'); } if (latestCabal === undefined) { - latestCabal = await ghcup.getLatestToolFromGHCup(logger, 'cabal'); + latestCabal = await ghcup.getLatestVersion('cabal'); } if (latestStack === undefined) { - latestStack = await ghcup.getLatestToolFromGHCup(logger, 'stack'); + latestStack = await ghcup.getLatestVersion('stack'); } if (recGHC === undefined) { - recGHC = !executableExists('ghc') - ? await ghcup.getLatestAvailableToolFromGHCup(logger, 'ghc', 'recommended') - : null; + recGHC = !executableExists('ghc') ? await ghcup.getLatestAvailableVersion('ghc', 'recommended') : null; } // download popups const promptBeforeDownloads = workspace.getConfiguration('haskell').get('promptBeforeDownloads') as boolean; if (promptBeforeDownloads) { - const hlsInstalled = latestHLS ? await toolInstalled(logger, 'hls', latestHLS) : undefined; - const cabalInstalled = latestCabal ? await toolInstalled(logger, 'cabal', latestCabal) : undefined; - const stackInstalled = latestStack ? await toolInstalled(logger, 'stack', latestStack) : undefined; + const hlsInstalled = latestHLS ? await toolInstalled(ghcup, 'hls', latestHLS) : undefined; + const cabalInstalled = latestCabal ? await toolInstalled(ghcup, 'cabal', latestCabal) : undefined; + const stackInstalled = latestStack ? await toolInstalled(ghcup, 'stack', latestStack) : undefined; const ghcInstalled = executableExists('ghc') ? new InstalledTool( 'ghc', @@ -184,7 +183,7 @@ export async function findHaskellLanguageServer( ) : // if recGHC is null, that means user disabled automatic handling, recGHC !== null - ? await toolInstalled(logger, 'ghc', recGHC) + ? await toolInstalled(ghcup, 'ghc', recGHC) : undefined; const toInstall: InstalledTool[] = [hlsInstalled, cabalInstalled, stackInstalled, ghcInstalled].filter( (tool) => tool && !tool.installed, @@ -222,8 +221,7 @@ export async function findHaskellLanguageServer( } // our preliminary toolchain - const latestToolchainBindir = await ghcup.callGHCup( - logger, + const latestToolchainBindir = await ghcup.call( [ 'run', ...(latestHLS ? ['--hls', latestHLS] : []), @@ -246,7 +244,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, context, logger, workingDir, latestToolchainBindir); if (projectHls === undefined) { projectHls = res[0]; } @@ -257,8 +255,8 @@ export async function findHaskellLanguageServer( // more download popups if (promptBeforeDownloads) { - const hlsInstalled = await toolInstalled(logger, 'hls', projectHls); - const ghcInstalled = projectGhc ? await toolInstalled(logger, 'ghc', projectGhc) : undefined; + const hlsInstalled = await toolInstalled(ghcup, 'hls', projectHls); + const ghcInstalled = projectGhc ? await toolInstalled(ghcup, 'ghc', projectGhc) : undefined; const toInstall: InstalledTool[] = [hlsInstalled, ghcInstalled].filter( (tool) => tool && !tool.installed, ) as InstalledTool[]; @@ -292,8 +290,7 @@ export async function findHaskellLanguageServer( } // now install the proper versions - const hlsBinDir = await ghcup.callGHCup( - logger, + const hlsBinDir = await ghcup.call( [ 'run', ...['--hls', projectHls], @@ -357,6 +354,7 @@ async function promptUserForManagingHls(context: ExtensionContext, manageHlsSett } async function getLatestProjectHLS( + ghcup: GHCup, context: ExtensionContext, logger: Logger, workingDir: string, @@ -376,7 +374,7 @@ async function getLatestProjectHLS( // first we get supported GHC versions from available HLS bindists (whether installed or not) const metadataMap = (await getHlsMetadata(context, logger)) || new Map(); // then we get supported GHC versions from currently installed HLS versions - const ghcupMap = (await findAvailableHlsBinariesFromGHCup(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 @@ -471,16 +469,14 @@ export function getStoragePath(context: ExtensionContext): string { * If 'targetGhc' is omitted, picks the latest 'haskell-language-server-wrapper', * otherwise ensures the specified GHC is supported. * - * @param context - * @param logger - * @returns + * @param ghcup GHCup wrapper. + * @returns A Map of the locally installed HLS versions and with which `GHC` versions they are compatible. */ -async function findAvailableHlsBinariesFromGHCup(logger: Logger): Promise | null> { - const hlsVersions = await ghcup.callGHCup(logger, ['list', '-t', 'hls', '-c', 'installed', '-r'], undefined, false); - - const bindir = await ghcup.callGHCup(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 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(); @@ -498,16 +494,15 @@ async function findAvailableHlsBinariesFromGHCup(logger: Logger): Promise { +async function toolInstalled(ghcup: GHCup, tool: Tool, version: string): Promise { const b = await ghcup - .callGHCup(logger, ['whereis', tool, version], undefined, false) + .call(['whereis', tool, version], undefined, false) .then(() => true) .catch(() => false); return new InstalledTool(tool, version, b); @@ -650,7 +645,7 @@ async function getReleaseMetadata(storagePath: string, logger: Logger): Promise< /** * 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. + * @param someObj Release Metadata without any typing information but well-formed. * @returns Typed ReleaseMetadata. */ const objectToMetadata = (someObj: any): ReleaseMetadata => { From 81e3f1c06e73777e0afbff8d5fad3bfb608b2a8a Mon Sep 17 00:00:00 2001 From: Fendor Date: Thu, 2 Jan 2025 18:48:36 +0100 Subject: [PATCH 08/13] Extract metadata into dedicated file --- src/hlsBinaries.ts | 213 +++------------------------------------------ src/metadata.ts | 198 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 208 insertions(+), 203 deletions(-) create mode 100644 src/metadata.ts diff --git a/src/hlsBinaries.ts b/src/hlsBinaries.ts index 61f883fb..3e1739ed 100644 --- a/src/hlsBinaries.ts +++ b/src/hlsBinaries.ts @@ -1,8 +1,5 @@ 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 { ConfigurationTarget, ExtensionContext, window, workspace, WorkspaceFolder } from 'vscode'; import { Logger } from 'vscode-languageclient'; import { HlsError, MissingToolError, NoMatchingHls } from './errors'; @@ -11,11 +8,11 @@ import { callAsync, comparePVP, executableExists, - httpsGetSilently, IEnvVars, resolvePathPlaceHolders, } from './utils'; import { ToolConfig, Tool, initDefaultGHCup, GHCup, GHCupConfig } from './ghcup'; +import { getHlsMetadata } from './metadata'; export { IEnvVars }; type ManageHLS = 'GHCup' | 'PATH'; @@ -23,6 +20,7 @@ let manageHLS = workspace.getConfiguration('haskell').get('manageHLS') as Manage export type Context = { manageHls: ManageHLS; + storagePath: string; serverExecutable?: HlsExecutable; logger: Logger; }; @@ -47,7 +45,7 @@ function findServerExecutable(logger: Logger, folder?: WorkspaceFolder): string /** Searches the PATH. Fails if nothing is found. */ -function findHlsInPath(_context: ExtensionContext, logger: Logger): string { +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`); @@ -118,11 +116,11 @@ export async function findHaskellLanguageServer( fs.mkdirSync(storagePath); } - // first plugin initialization + // first extension initialization manageHLS = await promptUserForManagingHls(context, manageHLS); if (manageHLS === 'PATH') { - const exe = findHlsInPath(context, logger); + const exe = findHlsInPath(logger); return { location: exe, tag: 'path', @@ -244,7 +242,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(ghcup, context, logger, workingDir, latestToolchainBindir); + const res = await getLatestProjectHls(ghcup, logger, storagePath, workingDir, latestToolchainBindir); if (projectHls === undefined) { projectHls = res[0]; } @@ -353,10 +351,10 @@ async function promptUserForManagingHls(context: ExtensionContext, manageHlsSett } } -async function getLatestProjectHLS( +async function getLatestProjectHls( ghcup: GHCup, - context: ExtensionContext, logger: Logger, + storagePath: string, workingDir: string, toolchainBindir: string, ): Promise<[string, string]> { @@ -372,7 +370,7 @@ 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 getHlsMetadata(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 findAvailableHlsBinariesFromGHCup(ghcup)) || new Map(); // since installed HLS versions may support a different set of GHC versions than the bindists @@ -395,7 +393,7 @@ 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`. @@ -508,197 +506,6 @@ async function toolInstalled(ghcup: GHCup, tool: Tool, version: string): Promise 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 getHlsMetadata(context: ExtensionContext, logger: Logger): Promise | null> { - const storagePath: string = getStoragePath(context); - 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); -} - -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.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); - } - } -} - /** * Tracks the name, version and installation state of tools we need. */ diff --git a/src/metadata.ts b/src/metadata.ts new file mode 100644 index 00000000..356b0257 --- /dev/null +++ b/src/metadata.ts @@ -0,0 +1,198 @@ +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, IEnvVars } from './utils'; +export { IEnvVars }; + +/** + * 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); + } + } +} From a02af6372b3c15c791338ead6faf654655466d1e Mon Sep 17 00:00:00 2001 From: Fendor Date: Sat, 4 Jan 2025 19:09:10 +0100 Subject: [PATCH 09/13] Fix bug in ghcup-based toolchain configuration --- src/ghcup.ts | 2 +- src/hlsBinaries.ts | 36 +++++++++++++++++++++++++----------- test/suite/extension.test.ts | 2 +- 3 files changed, 27 insertions(+), 13 deletions(-) diff --git a/src/ghcup.ts b/src/ghcup.ts index c19e9c5a..b759d58c 100644 --- a/src/ghcup.ts +++ b/src/ghcup.ts @@ -9,7 +9,7 @@ import { match } from 'ts-pattern'; export type Tool = 'hls' | 'ghc' | 'cabal' | 'stack'; -export type ToolConfig = Map; +export type ToolConfig = Map; export function initDefaultGHCup(config: GHCupConfig, logger: Logger, folder?: WorkspaceFolder): GHCup { const ghcupLoc = findGHCup(logger, config.executablePath, folder); diff --git a/src/hlsBinaries.ts b/src/hlsBinaries.ts index 3e1739ed..74e20461 100644 --- a/src/hlsBinaries.ts +++ b/src/hlsBinaries.ts @@ -25,10 +25,16 @@ export type Context = { logger: Logger; }; -// On Windows the executable needs to be stored somewhere with an .exe extension +/** + * On Windows the executable needs to be stored somewhere with an .exe extension + */ const exeExt = process.platform === 'win32' ? '.exe' : ''; -/** Gets serverExecutablePath and fails if it's not set. +/** + * 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. */ function findServerExecutable(logger: Logger, folder?: WorkspaceFolder): string { const rawExePath = workspace.getConfiguration('haskell').get('serverExecutablePath') as string; @@ -131,11 +137,11 @@ export async function findHaskellLanguageServer( await ghcup.upgrade(); // boring init - let latestHLS: string | undefined; + let latestHLS: string | undefined | null; let latestCabal: string | undefined | null; let latestStack: string | undefined | null; let recGHC: string | undefined | null = 'recommended'; - let projectHls: string | undefined; + let projectHls: string | undefined | null; let projectGhc: string | undefined | null; // support explicit toolchain config @@ -253,7 +259,7 @@ export async function findHaskellLanguageServer( // more download popups if (promptBeforeDownloads) { - const hlsInstalled = await toolInstalled(ghcup, 'hls', projectHls); + const hlsInstalled = projectHls ? await toolInstalled(ghcup, 'hls', projectHls) : undefined; const ghcInstalled = projectGhc ? await toolInstalled(ghcup, 'ghc', projectGhc) : undefined; const toInstall: InstalledTool[] = [hlsInstalled, ghcInstalled].filter( (tool) => tool && !tool.installed, @@ -291,7 +297,7 @@ export async function findHaskellLanguageServer( const hlsBinDir = await ghcup.call( [ 'run', - ...['--hls', projectHls], + ...(projectHls ? ['--hls', projectHls] : []), ...(latestCabal ? ['--cabal', latestCabal] : []), ...(latestStack ? ['--stack', latestStack] : []), ...(projectGhc ? ['--ghc', projectGhc] : []), @@ -309,11 +315,19 @@ export async function findHaskellLanguageServer( true, ); - return { - binaryDirectory: hlsBinDir, - location: path.join(hlsBinDir, `haskell-language-server-wrapper${exeExt}`), - tag: 'ghcup', - }; + if (projectHls) { + return { + binaryDirectory: hlsBinDir, + location: path.join(hlsBinDir, `haskell-language-server-wrapper${exeExt}`), + tag: 'ghcup', + }; + } else { + return { + binaryDirectory: hlsBinDir, + location: findHlsInPath(logger), + tag: 'ghcup', + }; + } } } 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'; From 2a482c4858b0cbc9577299e2325dae9c1b253d9a Mon Sep 17 00:00:00 2001 From: Fendor Date: Sun, 5 Jan 2025 19:06:50 +0100 Subject: [PATCH 10/13] Rename and add more docs --- src/config.ts | 10 +++---- src/errors.ts | 2 -- src/extension.ts | 66 ++++++++++++++++++++++++++-------------------- src/hlsBinaries.ts | 51 +++++++++++++++++++---------------- 4 files changed, 72 insertions(+), 57 deletions(-) diff --git a/src/config.ts b/src/config.ts index 6acef3dc..914a51fe 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,4 +1,4 @@ -import { OutputChannel, Uri, window, WorkspaceConfiguration, WorkspaceFolder } from 'vscode'; +import { OutputChannel, Uri, window, WorkspaceConfiguration, WorkspaceFolder } from 'vscode'; import { expandHomeDir, IEnvVars } from './utils'; import * as path from 'path'; import { Logger } from 'vscode-languageclient'; @@ -94,10 +94,10 @@ function getClientLogLevel(workspaceConfig: WorkspaceConfiguration): ClientLogLe clientLogLevel = clientLogLevel_; break; default: - throw new Error(); + throw new Error("Option \"haskell.trace.client\" is expected to be one of 'off', 'error', 'info', 'debug'."); } } else { - throw new Error(); + throw new Error('Option "haskell.trace.client" is expected to be a string'); } return clientLogLevel; } @@ -113,10 +113,10 @@ function getLogLevel(workspaceConfig: WorkspaceConfiguration): LogLevel { logLevel = logLevel_; break; default: - throw new Error("haskell.trace.server is expected to be one of 'off', 'messages', 'verbose'."); + throw new Error("Option \"haskell.trace.server\" is expected to be one of 'off', 'messages', 'verbose'."); } } else { - throw new Error('haskell.trace.server is expected to be a string'); + throw new Error('Option "haskell.trace.server" is expected to be a string'); } return logLevel; } 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 22a99319..6941136e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -120,34 +120,7 @@ async function activateServerForFolder(context: ExtensionContext, uri: Uri, fold try { 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; } @@ -261,6 +234,43 @@ async function activateServerForFolder(context: ExtensionContext, uri: Uri, fold 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') { diff --git a/src/hlsBinaries.ts b/src/hlsBinaries.ts index 74e20461..5e8c8bf0 100644 --- a/src/hlsBinaries.ts +++ b/src/hlsBinaries.ts @@ -49,7 +49,11 @@ function findServerExecutable(logger: Logger, folder?: WorkspaceFolder): string } } -/** 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. */ function findHlsInPath(logger: Logger): string { // try PATH @@ -87,17 +91,19 @@ export type HlsViaGhcup = { }; /** - * 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, @@ -125,6 +131,7 @@ export async function findHaskellLanguageServer( // first extension initialization manageHLS = await promptUserForManagingHls(context, manageHLS); + // based on the user-decision if (manageHLS === 'PATH') { const exe = findHlsInPath(logger); return { @@ -177,21 +184,21 @@ export async function findHaskellLanguageServer( // download popups const promptBeforeDownloads = workspace.getConfiguration('haskell').get('promptBeforeDownloads') as boolean; if (promptBeforeDownloads) { - const hlsInstalled = latestHLS ? await toolInstalled(ghcup, 'hls', latestHLS) : undefined; - const cabalInstalled = latestCabal ? await toolInstalled(ghcup, 'cabal', latestCabal) : undefined; - const stackInstalled = latestStack ? await toolInstalled(ghcup, '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(ghcup, '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?`, @@ -259,11 +266,11 @@ export async function findHaskellLanguageServer( // more download popups if (promptBeforeDownloads) { - const hlsInstalled = projectHls ? await toolInstalled(ghcup, 'hls', projectHls) : undefined; - const ghcInstalled = projectGhc ? await toolInstalled(ghcup, '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?`, @@ -512,18 +519,18 @@ async function findAvailableHlsBinariesFromGHCup(ghcup: GHCup): Promise { +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); + 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. */ From 87bb052cae002af328b7df239686eae73f529584 Mon Sep 17 00:00:00 2001 From: Fendor Date: Mon, 10 Feb 2025 09:15:58 +0100 Subject: [PATCH 11/13] Add docs and extract constant to top-level scope --- src/hlsBinaries.ts | 36 +++++++++++++++++++++++------------- src/metadata.ts | 3 +-- 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/src/hlsBinaries.ts b/src/hlsBinaries.ts index 5e8c8bf0..51329c94 100644 --- a/src/hlsBinaries.ts +++ b/src/hlsBinaries.ts @@ -15,9 +15,6 @@ import { ToolConfig, Tool, initDefaultGHCup, GHCup, GHCupConfig } from './ghcup' import { getHlsMetadata } from './metadata'; export { IEnvVars }; -type ManageHLS = 'GHCup' | 'PATH'; -let manageHLS = workspace.getConfiguration('haskell').get('manageHLS') as ManageHLS; - export type Context = { manageHls: ManageHLS; storagePath: string; @@ -25,11 +22,19 @@ export type Context = { logger: Logger; }; +/** + * Global configuration for this extension. + */ +const haskellConfig = workspace.getConfiguration('haskell'); + /** * On Windows the executable needs to be stored somewhere with an .exe extension */ const exeExt = process.platform === 'win32' ? '.exe' : ''; +type ManageHLS = 'GHCup' | 'PATH'; +let manageHLS = haskellConfig.get('manageHLS') as ManageHLS; + /** * Gets serverExecutablePath and fails if it's not set. * @param logger Log progress. @@ -37,7 +42,7 @@ const exeExt = process.platform === 'win32' ? '.exe' : ''; * @returns Path to an HLS executable binary. */ function findServerExecutable(logger: Logger, folder?: WorkspaceFolder): string { - const rawExePath = workspace.getConfiguration('haskell').get('serverExecutablePath') as 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}`); @@ -114,7 +119,7 @@ export async function findHaskellLanguageServer( ): Promise { logger.info('Finding haskell-language-server'); - const hasConfigForExecutable = workspace.getConfiguration('haskell').get('serverExecutablePath') as string; + const hasConfigForExecutable = haskellConfig.get('serverExecutablePath') as string; if (hasConfigForExecutable) { const exe = findServerExecutable(logger, folder); return { @@ -152,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 ToolConfig), - ) 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'); @@ -182,7 +185,7 @@ export async function findHaskellLanguageServer( } // download popups - const promptBeforeDownloads = workspace.getConfiguration('haskell').get('promptBeforeDownloads') as boolean; + const promptBeforeDownloads = haskellConfig.get('promptBeforeDownloads') as boolean; if (promptBeforeDownloads) { const hlsInstalled = latestHLS ? await installationStatusOfGhcupTool(ghcup, 'hls', latestHLS) : undefined; const cabalInstalled = latestCabal ? await installationStatusOfGhcupTool(ghcup, 'cabal', latestCabal) : undefined; @@ -212,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) { @@ -285,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) { @@ -364,7 +367,7 @@ async function promptUserForManagingHls(context: ExtensionContext, manageHlsSett ); howToManage = 'PATH'; } - workspace.getConfiguration('haskell').update('manageHLS', howToManage, ConfigurationTarget.Global); + haskellConfig.update('manageHLS', howToManage, ConfigurationTarget.Global); context.globalState.update('pluginInitialized', true); return howToManage; } else { @@ -469,8 +472,15 @@ export async function getProjectGhcVersion( ); } +/** + * 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 = workspace.getConfiguration('haskell').get('releasesDownloadStoragePath'); + let storagePath: string | undefined = haskellConfig.get('releasesDownloadStoragePath'); if (!storagePath) { storagePath = context.globalStorageUri.fsPath; diff --git a/src/metadata.ts b/src/metadata.ts index 356b0257..fbdc6429 100644 --- a/src/metadata.ts +++ b/src/metadata.ts @@ -5,8 +5,7 @@ import { match } from 'ts-pattern'; import { promisify } from 'util'; import { window, workspace } from 'vscode'; import { Logger } from 'vscode-languageclient'; -import { httpsGetSilently, IEnvVars } from './utils'; -export { IEnvVars }; +import { httpsGetSilently } from './utils'; /** * Metadata of release information. From 27ed89fead159325c40c4057594fb074d856cdfc Mon Sep 17 00:00:00 2001 From: Fendor Date: Mon, 10 Feb 2025 09:16:09 +0100 Subject: [PATCH 12/13] First iteration of the StatusBarItem This 'StatusBarItem' improves the discoverability of the most important interactions between the user and the extension. This includes debug information, such as the version of the extension, showing the output panel to find the logs more easily, but also to restart all running Haskell Language Server binaries and the extension itself. --- package.json | 6 +-- src/commands/constants.ts | 4 +- src/extension.ts | 89 ++++++++++++++++++++++++++++++--------- src/statusBar.ts | 38 +++++++++++++++++ 4 files changed, 112 insertions(+), 25 deletions(-) create mode 100644 src/statusBar.ts diff --git a/package.json b/package.json index 3fbada64..9c17bebd 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/extension.ts b/src/extension.ts index 6941136e..b3ef1e11 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -7,20 +7,32 @@ 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 { 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 @@ -37,41 +49,69 @@ export async function activate(context: ExtensionContext) { 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'); - await 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'); - await 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'); - await langClient?.start(); - langClient?.info('Server started'); + langClient?.client.info('Starting the server'); + await langClient?.client.start(); + langClient?.client.info('Server started'); } }); @@ -83,6 +123,9 @@ export async function activate(context: ExtensionContext) { const openOnHackageDisposable = DocsBrowser.registerDocsOpenOnHackage(); context.subscriptions.push(openOnHackageDisposable); + + statusBar.refresh(); + statusBar.show(); } async function activateServer(context: ExtensionContext, document: TextDocument) { @@ -178,7 +221,7 @@ async function activateServerForFolder(context: ExtensionContext, uri: Uri, fold logger.info(`Support for '.cabal' files: ${cabalFileSupport}`); switch (cabalFileSupport) { - case 'automatic': + case 'automatic': { const hlsVersion = await callAsync( hlsExecutable.location, ['--numeric-version'], @@ -193,6 +236,7 @@ async function activateServerForFolder(context: ExtensionContext, uri: Uri, fold documentSelector.push(cabalDocumentSelector); } break; + } case 'enable': documentSelector.push(cabalDocumentSelector); break; @@ -230,7 +274,10 @@ async function activateServerForFolder(context: ExtensionContext, uri: Uri, fold // Finally start the client and add it to the list of clients. logger.info('Starting language server'); - clients.set(clientsKey, langClient); + clients.set(clientsKey, { + client: langClient, + config, + }); await langClient.start(); } @@ -290,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/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(); + } +} From df5b397ca176c79146e3615f5427f0dc188e826b Mon Sep 17 00:00:00 2001 From: fendor Date: Sun, 4 May 2025 15:09:40 +0200 Subject: [PATCH 13/13] Bump tested GHC versions --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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