diff --git a/README.md b/README.md index 8a39485c..f66aadb0 100644 --- a/README.md +++ b/README.md @@ -71,14 +71,17 @@ The environment _only will be visible for the lsp server_, not for other extensi ### Downloaded binaries -This extension will download `haskell-language-server` binaries via an (internal) ghcup to a specific location depending -on your system, unless you set the config option `haskell.manageHLS` to `false` (the default is `true`). +This extension will download `haskell-language-server` binaries either via an internal ghcup (it will download it automaticlaly) +or via a system ghcup (which must be present), unless you set the config option `haskell.manageHLS` to `PATH` (the extension +will ask you on first start). -It will download the newest version of haskell-language-server which has support for the required ghc. +It will then download the newest version of haskell-language-server which has support for the required ghc. That means it could use an older version than the latest one, without the last features and bug fixes. For example, if a project needs ghc-8.10.4 the extension will download and use haskell-language-server-1.4.0, the lastest version which supported ghc-8.10.4. Even if the lastest global haskell language-server version is 1.5.1. -If you find yourself running out of disk space, you can try deleting old versions of language servers in this directory. The extension will redownload them, no strings attached. +If you have disk space issues and use system ghcup, check `ghcup gc --help`. +If you have disk space issues and use the internal ghcup, check the following directories, depending on your platform +and possible delete them: | Platform | Path | | -------- | ------------------------------------------------------------------------------- | @@ -86,10 +89,8 @@ If you find yourself running out of disk space, you can try deleting old version | Windows | `%APPDATA%\Code\User\globalStorage\haskell.haskell\ghcup` | | Linux | `$HOME/.config/Code/User/globalStorage/haskell.haskell/.ghcup` | -If you want to manage HLS yourself, set `haskell.manageHLS` to `false` and make sure HLS is in your PATH -or set `haskell.serverExecutablePath` to a valid executable. - -You can also tell HLS to use your system provided ghcup by setting `haskell.useSystemGHCup` to `true` (default is `false`). +If you want to manage HLS yourself, set `haskell.manageHLS` to `PATH` and make sure HLS is in your PATH +or set `haskell.serverExecutablePath` (overrides all other settings) to a valid executable. If you need to set mirrors for ghcup download info, check the settings `haskell.metadataURL` and `haskell.releasesURL`. diff --git a/package.json b/package.json index b80c3215..ba264628 100644 --- a/package.json +++ b/package.json @@ -159,9 +159,19 @@ }, "haskell.manageHLS": { "scope": "resource", - "type": "boolean", - "default": true, - "description": "Let this extension manage required HLS versions via ghcup." + "type": "string", + "default": null, + "description": "How to manage/find HLS installations.", + "enum": [ + "system-ghcup", + "internal-ghcup", + "PATH" + ], + "enumDescriptions": [ + "Will use a user-wide installation of ghcup (usually in '~/.ghcup') to manage HLS automatically", + "Will use an internal installation of ghcup to manage HLS automatically, to avoid interfering with system ghcup", + "Discovers HLS executables in system PATH" + ] }, "haskell.useSystemGHCup": { "scope": "resource", diff --git a/src/commands/importIdentifier.ts b/src/commands/importIdentifier.ts index 038aa103..2e0eef22 100644 --- a/src/commands/importIdentifier.ts +++ b/src/commands/importIdentifier.ts @@ -9,25 +9,27 @@ import { CommandNames } from './constants'; const askHoogle = async (variable: string): Promise => { return await request({ url: `https://hoogle.haskell.org/?hoogle=${variable}&scope=set%3Astackage&mode=json`, - json: true + json: true, }).promise(); }; -const withCache = (theCache: LRU.Cache, f: (a: T) => U) => (a: T) => { - const maybeB = theCache.get(a); - if (maybeB) { - return maybeB; - } else { - const b = f(a); - theCache.set(a, b); - return b; - } -}; +const withCache = + (theCache: LRU.Cache, f: (a: T) => U) => + (a: T) => { + const maybeB = theCache.get(a); + if (maybeB) { + return maybeB; + } else { + const b = f(a); + theCache.set(a, b); + return b; + } + }; const cache: LRU.Cache> = LRU({ // 1 MB max: 1000 * 1000, - length: (r: any) => JSON.stringify(r).length + length: (r: any) => JSON.stringify(r).length, }); const askHoogleCached = withCache(cache, askHoogle); @@ -42,14 +44,14 @@ const doImport = async (arg: { mod: string; package: string }): Promise => const edit = new vscode.WorkspaceEdit(); const lines = document.getText().split('\n'); - const moduleLine = lines.findIndex(line => { + const moduleLine = lines.findIndex((line) => { const lineTrimmed = line.trim(); return lineTrimmed === 'where' || lineTrimmed.endsWith(' where') || lineTrimmed.endsWith(')where'); }); - const revInputLine = lines.reverse().findIndex(l => l.startsWith('import')); + const revInputLine = lines.reverse().findIndex((l) => l.startsWith('import')); const nextInputLine = revInputLine !== -1 ? lines.length - 1 - revInputLine : moduleLine === -1 ? 0 : moduleLine + 1; - if (!lines.some(line => new RegExp('^import.*' + escapeRegExp(arg.mod)).test(line))) { + if (!lines.some((line) => new RegExp('^import.*' + escapeRegExp(arg.mod)).test(line))) { edit.insert(document.uri, new vscode.Position(nextInputLine, 0), 'import ' + arg.mod + '\n'); } @@ -99,11 +101,13 @@ export namespace ImportIdentifier { const response: any[] = await askHoogleCached(editor.document.getText(identifierRange)); const choice = await vscode.window.showQuickPick( - response.filter(result => result.module.name).map(result => ({ - result, - label: result.package.name, - description: result.module.name + ' -- ' + (cheerio.load as any)(result.item, { xml: {} }).text() - })) + response + .filter((result) => result.module.name) + .map((result) => ({ + result, + label: result.package.name, + description: result.module.name + ' -- ' + (cheerio.load as any)(result.item, { xml: {} }).text(), + })) ); if (!choice) { diff --git a/src/extension.ts b/src/extension.ts index aba6cc63..f6740e32 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,6 +1,7 @@ 'use strict'; import * as path from 'path'; import { + env, commands, ExtensionContext, OutputChannel, @@ -22,7 +23,7 @@ import { import { CommandNames } from './commands/constants'; import { ImportIdentifier } from './commands/importIdentifier'; import { DocsBrowser } from './docsBrowser'; -import { addPathToProcessPath, findHaskellLanguageServer, IEnvVars } from './hlsBinaries'; +import { MissingToolError, addPathToProcessPath, findHaskellLanguageServer, IEnvVars } from './hlsBinaries'; import { expandHomeDir, ExtensionLogger } from './utils'; // The current map of documents & folders to language servers. @@ -153,7 +154,16 @@ async function activateServerForFolder(context: ExtensionContext, uri: Uri, fold return; } } catch (e) { - if (e instanceof Error) { + 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 Error) { logger.error(`Error getting the server executable: ${e.message}`); window.showErrorMessage(e.message); } @@ -188,11 +198,11 @@ async function activateServerForFolder(context: ExtensionContext, uri: Uri, fold let serverEnvironment: IEnvVars = workspace.getConfiguration('haskell', uri).serverEnvironment; if (addInternalServerPath !== undefined) { - const newPath = addPathToProcessPath(addInternalServerPath); - serverEnvironment = { - PATH: newPath, - ... serverEnvironment - }; + const newPath = addPathToProcessPath(addInternalServerPath); + serverEnvironment = { + PATH: newPath, + ...serverEnvironment, + }; } const exeOptions: ExecutableOptions = { cwd: folder ? undefined : path.dirname(uri.fsPath), diff --git a/src/hlsBinaries.ts b/src/hlsBinaries.ts index ce030e87..5cbb0403 100644 --- a/src/hlsBinaries.ts +++ b/src/hlsBinaries.ts @@ -1,14 +1,23 @@ import * as child_process from 'child_process'; import { ExecException } from 'child_process'; import * as fs from 'fs'; +import { stat } from 'fs/promises'; import * as https from 'https'; import * as path from 'path'; import { match } from 'ts-pattern'; import * as url from 'url'; import { promisify } from 'util'; -import { ConfigurationTarget, ExtensionContext, ProgressLocation, Uri, window, workspace, WorkspaceFolder } from 'vscode'; +import { + ConfigurationTarget, + ExtensionContext, + ProgressLocation, + Uri, + window, + workspace, + WorkspaceFolder, +} from 'vscode'; import { Logger } from 'vscode-languageclient'; -import { downloadFile, executableExists, httpsGetSilently, resolvePathPlaceHolders } from './utils'; +import { downloadFile, executableExists, httpsGetSilently, resolvePathPlaceHolders } from './utils'; export type ReleaseMetadata = Map>>; @@ -17,12 +26,13 @@ export interface IEnvVars { [key: string]: string; } -let systemGHCup = workspace.getConfiguration('haskell').get('useSystemGHCup') as boolean | null; +type ManageHLS = 'system-ghcup' | 'internal-ghcup' | 'PATH'; +let manageHLS = workspace.getConfiguration('haskell').get('manageHLS') as ManageHLS | null; // On Windows the executable needs to be stored somewhere with an .exe extension const exeExt = process.platform === 'win32' ? '.exe' : ''; -class MissingToolError extends Error { +export class MissingToolError extends Error { public readonly tool: string; constructor(tool: string) { let prettyTool: string; @@ -36,6 +46,9 @@ class MissingToolError extends Error { case 'ghc': prettyTool = 'GHC'; break; + case 'ghcup': + prettyTool = 'GHCup'; + break; default: prettyTool = tool; break; @@ -48,6 +61,7 @@ class MissingToolError extends Error { switch (this.tool) { case 'Stack': return Uri.parse('https://docs.haskellstack.org/en/stable/install_and_upgrade/'); + case 'GHCup': case 'Cabal': case 'GHC': return Uri.parse('https://www.haskell.org/ghcup/'); @@ -74,93 +88,95 @@ class MissingToolError extends Error { * @returns Stdout of the process invocation, trimmed off newlines, or whatever the `callback` resolved to. */ async function callAsync( - binary: string, - args: string[], - dir: string, - logger: Logger, - title?: string, - cancellable?: boolean, - envAdd?: IEnvVars, - callback?: ( - error: ExecException | null, - stdout: string, - stderr: string, - resolve: (value: string | PromiseLike ) => void, - reject: (reason?: any) => void - ) => void + binary: string, + args: string[], + dir: string, + logger: Logger, + title?: string, + cancellable?: boolean, + envAdd?: IEnvVars, + callback?: ( + error: ExecException | null, + stdout: string, + stderr: string, + resolve: (value: string | PromiseLike) => void, + reject: (reason?: any) => void + ) => void ): Promise { - 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}'`); - token.onCancellationRequested(() => { - logger.warn(`User canceled the execution of '${command}'`); - }); - const newEnv = (envAdd !== undefined) ? Object.assign(process.env, envAdd) : process.env; - // 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 (callback !== undefined) { - callback(err, stdout, stderr, resolve, reject); - } else { - if (err) { - logger.error(`Error executing '${command}' with error code ${err.code}`); - logger.error(`stderr: ${stderr}`); - if (stdout) { - logger.error(`stdout: ${stdout}`); - } - reject( - Error(`${command} exited with exit code ${err.code}:\n${stdout}\n${stderr}`) - ); - } else { - resolve(stdout?.trim()); - } - } - } - ) - .on('exit', (code, signal) => { - const msg = - `Execution of '${command}' terminated with code ${code}` + - (signal ? `and signal ${signal}` : ''); - logger.info(msg); - }) - .on('error', (err) => { - if (err) { - logger.error(`Error executing '${command}': name = ${err.name}, message = ${err.message}`); - reject(err); - } - }); - token.onCancellationRequested(() => childProcess.kill()); - }); - } - ); + 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}'`); + token.onCancellationRequested(() => { + logger.warn(`User canceled the execution of '${command}'`); + }); + const newEnv = envAdd ? Object.assign(process.env, envAdd) : process.env; + // 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 (callback) { + callback(err, stdout, stderr, resolve, reject); + } else { + if (err) { + logger.error(`Error executing '${command}' with error code ${err.code}`); + logger.error(`stderr: ${stderr}`); + if (stdout) { + logger.error(`stdout: ${stdout}`); + } + reject(Error(`${command} exited with exit code ${err.code}:\n${stdout}\n${stderr}`)); + } else { + resolve(stdout?.trim()); + } + } + } + ) + .on('exit', (code, signal) => { + const msg = + `Execution of '${command}' terminated with code ${code}` + (signal ? `and signal ${signal}` : ''); + logger.info(msg); + }) + .on('error', (err) => { + if (err) { + logger.error(`Error executing '${command}': name = ${err.name}, message = ${err.message}`); + reject(err); + } + }); + token.onCancellationRequested(() => childProcess.kill()); + }); + } + ); } -/** Searches the PATH for whatever is set in 'serverExecutablePath'. +/** Gets serverExecutablePath and fails if it's not set. */ -function findHLSinPATH(context: ExtensionContext, logger: Logger, folder?: WorkspaceFolder): string | null { - // try 'serverExecutablePath' setting +function findServerExecutable(context: ExtensionContext, logger: Logger, folder?: WorkspaceFolder): string { let exePath = workspace.getConfiguration('haskell').get('serverExecutablePath') as string; - if (exePath !== '') { - 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; - } + 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; + } else { + const msg = `Could not find a HLS binary at ${exePath}! Consider installing HLS via ghcup or change "haskell.manageHLS" in your settings.`; + window.showErrorMessage(msg); + throw new Error(msg); } +} +/** Searches the PATH. Fails if nothing is found. + */ +function findHLSinPATH(context: ExtensionContext, logger: Logger, folder?: WorkspaceFolder): string { // try PATH const exes: string[] = ['haskell-language-server-wrapper', 'haskell-language-server']; logger.info(`Searching for server executables ${exes.join(',')} in $PATH`); @@ -171,8 +187,10 @@ function findHLSinPATH(context: ExtensionContext, logger: Logger, folder?: Works return exe; } } - - return null; + const msg = + 'Could not find a HLS binary in PATH! Consider installing HLS via ghcup or change "haskell.manageHLS" in your settings.'; + window.showErrorMessage(msg); + throw new Error(msg); } /** @@ -189,80 +207,86 @@ function findHLSinPATH(context: ExtensionContext, logger: Logger, folder?: Works * @returns Path to haskell-language-server-wrapper */ export async function findHaskellLanguageServer( - context: ExtensionContext, - logger: Logger, - workingDir: string, - folder?: WorkspaceFolder + context: ExtensionContext, + logger: Logger, + workingDir: string, + folder?: WorkspaceFolder ): Promise { - // we manage HLS, make sure ghcup is installed/available - await getGHCup(context, logger); + logger.info('Finding haskell-language-server'); - logger.info('Finding haskell-language-server'); + if (workspace.getConfiguration('haskell').get('serverExecutablePath') as string) { + return findServerExecutable(context, logger, folder); + } - const storagePath: string = await getStoragePath(context); - logger.info(`Using ${storagePath} to store downloaded binaries`); + const storagePath: string = await getStoragePath(context); - if (!fs.existsSync(storagePath)) { - fs.mkdirSync(storagePath); + if (!fs.existsSync(storagePath)) { + fs.mkdirSync(storagePath); + } + + if (!manageHLS) { + // plugin needs initialization + const promptMessage = 'How do you want the extension to manage/discover HLS?'; + + const decision = + (await window.showInformationMessage(promptMessage, 'system ghcup (recommended)', 'internal ghcup', 'PATH')) || + null; + if (decision === 'system ghcup (recommended)') { + manageHLS = 'system-ghcup'; + } else if (decision === 'internal ghcup') { + manageHLS = 'internal-ghcup'; + } else if (decision === 'PATH') { + manageHLS = 'PATH'; + } else { + throw new Error(`Internal error: unexpected decision ${decision}`); } + workspace.getConfiguration('haskell').update('manageHLS', manageHLS, ConfigurationTarget.Global); + } - const manageHLS = workspace.getConfiguration('haskell').get('manageHLS') as boolean; + if (manageHLS === 'PATH' || manageHLS === null) { + return findHLSinPATH(context, logger, folder); + } else { + // we manage HLS, make sure ghcup is installed/available + await getGHCup(context, logger); - if (!manageHLS) { - const wrapper = findHLSinPATH(context, logger, folder); - if (!wrapper) { - const msg = 'Could not find a HLS binary! Consider installing HLS via ghcup or set "haskell.manageHLS" to true'; - window.showErrorMessage(msg); - throw new Error(msg); - } else { - return wrapper; - } - } else { - // permissively check if we have HLS installed - // this is just to avoid a popup - let wrapper = await callGHCup(context, logger, - ['whereis', 'hls'], - undefined, - false, - (err, stdout, _stderr, resolve, _reject) => { err ? resolve('') : resolve(stdout?.trim()); } - ); - if (wrapper === '') { - // install recommended HLS... even if this doesn't support the project GHC, because - // we need a HLS to find the correct project GHC in the first place - await callGHCup(context, logger, - ['install', 'hls'], - 'Installing latest HLS', - true - ); - // get path to just installed HLS - wrapper = await callGHCup(context, logger, - ['whereis', 'hls'], - undefined, - false - ); - } - // now figure out the 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) - const installableHls = await getLatestSuitableHLS( - context, - logger, - workingDir, - (wrapper === null) ? undefined : wrapper - ); - - // now install said version in an isolated symlink directory - const symHLSPath = path.join(storagePath, 'hls', installableHls); - wrapper = path.join(symHLSPath, `haskell-language-server-wrapper${exeExt}`); - // Check if we have a working symlink, so we can avoid another popup - if (!fs.existsSync(wrapper)) { - await callGHCup(context, logger, - ['run', '--hls', installableHls, '-b', symHLSPath, '-i'], - `Installing HLS ${installableHls}`, - true - ); - } - return wrapper; + // get a preliminary hls wrapper for finding project GHC version, + // later we may install a different HLS that supports the given GHC + let wrapper = await getLatestWrapperFromGHCup(context, logger).then((e) => + !e + ? callGHCup(context, logger, ['install', 'hls'], 'Installing latest HLS', true).then(() => + callGHCup( + context, + logger, + ['whereis', 'hls'], + undefined, + false, + (err, stdout, _stderr, resolve, reject) => { + err ? reject("Couldn't find latest HLS") : resolve(stdout?.trim()); + } + ) + ) + : e + ); + + // now figure out the 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) + const installableHls = await getLatestHLS(context, logger, workingDir, wrapper); + + // now install said version in an isolated symlink directory + const symHLSPath = path.join(storagePath, 'hls', installableHls); + wrapper = path.join(symHLSPath, `haskell-language-server-wrapper${exeExt}`); + // Check if we have a working symlink, so we can avoid another popup + if (!fs.existsSync(wrapper)) { + await callGHCup( + context, + logger, + ['run', '--hls', installableHls, '-b', symHLSPath, '-i'], + `Installing HLS ${installableHls}`, + true + ); } + return wrapper; + } } async function callGHCup( @@ -272,51 +296,80 @@ async function callGHCup( title?: string, cancellable?: boolean, callback?: ( - error: ExecException | null, - stdout: string, - stderr: string, - resolve: (value: string | PromiseLike) => void, - reject: (reason?: any) => void + error: ExecException | null, + stdout: string, + stderr: string, + resolve: (value: string | PromiseLike) => void, + reject: (reason?: any) => void ) => void ): Promise { - const metadataUrl = workspace.getConfiguration('haskell').metadataURL; const storagePath: string = await getStoragePath(context); - const ghcup = (systemGHCup === true) ? `ghcup${exeExt}` : path.join(storagePath, `ghcup${exeExt}`); - if (systemGHCup) { - return await callAsync('ghcup', ['--no-verbose'].concat(metadataUrl ? ['-s', metadataUrl] : []).concat(args), storagePath, logger, title, cancellable, undefined, callback); - } else { - return await callAsync(ghcup, ['--no-verbose'].concat(metadataUrl ? ['-s', metadataUrl] : []).concat(args), storagePath, logger, title, cancellable, { + const ghcup = manageHLS === 'system-ghcup' ? `ghcup${exeExt}` : path.join(storagePath, `ghcup${exeExt}`); + if (manageHLS === 'system-ghcup') { + return await callAsync( + 'ghcup', + ['--no-verbose'].concat(metadataUrl ? ['-s', metadataUrl] : []).concat(args), + storagePath, + logger, + title, + cancellable, + undefined, + callback + ); + } else if (manageHLS === 'internal-ghcup') { + return await callAsync( + ghcup, + ['--no-verbose'].concat(metadataUrl ? ['-s', metadataUrl] : []).concat(args), + storagePath, + logger, + title, + cancellable, + { GHCUP_INSTALL_BASE_PREFIX: storagePath, - }, callback); + }, + callback + ); + } else { + throw new Error(`Internal error: tried to call ghcup while haskell.manageHLS is set to ${manageHLS}. Aborting!`); } } -async function getLatestSuitableHLS( - context: ExtensionContext, - logger: Logger, - workingDir: string, - wrapper?: string +async function getLatestHLS( + context: ExtensionContext, + logger: Logger, + workingDir: string, + wrapper?: string ): Promise { - const storagePath: string = await getStoragePath(context); + const storagePath: string = await getStoragePath(context); - // get project GHC version, but fallback to system ghc if necessary. - const projectGhc = - wrapper === undefined - ? await callAsync(`ghc${exeExt}`, ['--numeric-version'], storagePath, logger, undefined, false) - : await getProjectGHCVersion(wrapper, workingDir, logger); - - // get installable HLS that supports the project GHC version (this might not be the most recent) - const latestMetadataHls = - projectGhc !== null ? await getLatestHLSforGHC(context, storagePath, projectGhc, logger) : null; - if (latestMetadataHls === null) { - const noMatchingHLS = `No HLS version was found for supporting GHC ${projectGhc}.`; - window.showErrorMessage(noMatchingHLS); - throw new Error(noMatchingHLS); - } else { - return latestMetadataHls; - } + // get project GHC version, but fallback to system ghc if necessary. + const projectGhc = wrapper + ? await getProjectGHCVersion(wrapper, workingDir, logger) + : await callAsync(`ghc${exeExt}`, ['--numeric-version'], storagePath, logger, undefined, false); + const noMatchingHLS = `No HLS version was found for supporting GHC ${projectGhc}.`; + + // first we get supported GHC versions from available HLS bindists (whether installed or not) + const metadataMap = (await getHLSesfromMetadata(context, storagePath, logger)) || new Map(); + // then we get supported GHC versions from currently installed HLS versions + const ghcupMap = (await getHLSesFromGHCup(context, storagePath, 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 + const merged = new Map([...metadataMap, ...ghcupMap]); // right-biased + // now sort and get the latest suitable version + const latest = [...merged] + .filter(([k, v]) => v.some((x) => x === projectGhc)) + .sort(([k1, v1], [k2, v2]) => comparePVP(k1, k2)) + .pop(); + + if (!latest) { + window.showErrorMessage(noMatchingHLS); + throw new Error(noMatchingHLS); + } else { + return latest[0]; + } } /** @@ -327,39 +380,46 @@ async function getLatestSuitableHLS( * @param logger Logger for feedback. * @returns The GHC version, or fail with an `Error`. */ -export async function getProjectGHCVersion( - wrapper: string, - workingDir: string, - logger: Logger -): Promise { - const title = 'Working out the project GHC version. This might take a while...'; - logger.info(title); - const args = ['--project-ghc-version']; - - return callAsync(wrapper, args, workingDir, logger, title, false, undefined, - (err, stdout, stderr, resolve, reject) => { - const command: string = wrapper + ' ' + args.join(' '); - if (err) { - logger.error(`Error executing '${command}' with error code ${err.code}`); - logger.error(`stderr: ${stderr}`); - if (stdout) { - logger.error(`stdout: ${stdout}`); - } - // Error message emitted by HLS-wrapper - const regex = /Cradle requires (.+) but couldn't find it/; - const res = regex.exec(stderr); - if (res) { - reject(new MissingToolError(res[1])); - } - reject( - Error(`${wrapper} --project-ghc-version exited with exit code ${err.code}:\n${stdout}\n${stderr}`) - ); - } else { - logger.info(`The GHC version for the project or file: ${stdout?.trim()}`); - resolve(stdout?.trim()); +export async function getProjectGHCVersion(wrapper: string, workingDir: string, logger: Logger): Promise { + const title = 'Working out the project GHC version. This might take a while...'; + logger.info(title); + const args = ['--project-ghc-version']; + + return callAsync( + wrapper, + args, + workingDir, + logger, + title, + false, + undefined, + (err, stdout, stderr, resolve, reject) => { + const command: string = wrapper + ' ' + args.join(' '); + if (err) { + logger.error(`Error executing '${command}' with error code ${err.code}`); + logger.error(`stderr: ${stderr}`); + if (stdout) { + logger.error(`stdout: ${stdout}`); + } + // Error message emitted by HLS-wrapper + const regex = + /Cradle requires (.+) but couldn't find it|The program \'(.+)\' version .* is required but the version of.*could.*not be determined|Cannot find the program \'(.+)\'\. User-specified/; + const res = regex.exec(stderr); + if (res) { + for (let i = 1; i < res.length; i++) { + if (res[i]) { + reject(new MissingToolError(res[i])); } + } + reject(new MissingToolError('unknown')); } - ); + reject(Error(`${wrapper} --project-ghc-version exited with exit code ${err.code}:\n${stdout}\n${stderr}`)); + } else { + logger.info(`The GHC version for the project or file: ${stdout?.trim()}`); + resolve(stdout?.trim()); + } + } + ); } /** @@ -367,78 +427,62 @@ export async function getProjectGHCVersion( * Returns undefined if it can't find any for the given architecture/platform. */ export async function getGHCup(context: ExtensionContext, logger: Logger): Promise { - logger.info('Checking for ghcup installation'); - const localGHCup = ['ghcup'].find(executableExists); - - if (systemGHCup === null) { - if (localGHCup !== undefined) { - const promptMessage = - 'Detected system ghcup. Do you want VSCode to use it instead of an internal ghcup?'; + logger.info('Checking for ghcup installation'); + const localGHCup = ['ghcup'].find(executableExists); - systemGHCup = await window.showInformationMessage(promptMessage, 'Yes', 'No').then(b => b === 'Yes'); - logger.info(`set useSystemGHCup to ${systemGHCup}`); - - } else { // no local ghcup, disable - systemGHCup = false; - } - } - // set config globally - workspace.getConfiguration('haskell').update('useSystemGHCup', systemGHCup, ConfigurationTarget.Global); - - if (systemGHCup === true) { - if (localGHCup === undefined) { - const msg = 'Could not find a system ghcup installation, please follow instructions at https://www.haskell.org/ghcup/'; - window.showErrorMessage(msg); - throw new Error(msg); - } - logger.info(`found system ghcup at ${localGHCup}`); - return localGHCup; + if (manageHLS === 'system-ghcup') { + if (!localGHCup) { + throw new MissingToolError('ghcup'); + } else { + logger.info(`found system ghcup at ${localGHCup}`); + const args = ['upgrade']; + await callGHCup(context, logger, args, 'Upgrading ghcup', true); + return localGHCup; } - + } else if (manageHLS === 'internal-ghcup') { const storagePath: string = await getStoragePath(context); - logger.info(`Using ${storagePath} to store downloaded binaries`); - + let ghcup = path.join(storagePath, `ghcup${exeExt}`); if (!fs.existsSync(storagePath)) { - fs.mkdirSync(storagePath); + fs.mkdirSync(storagePath); } - const ghcup = path.join(storagePath, `ghcup${exeExt}`); // ghcup exists, just upgrade if (fs.existsSync(ghcup)) { - logger.info('ghcup already installed, trying to upgrade'); - const args = ['upgrade', '-i']; - await callGHCup(context, logger, args, undefined, false); + logger.info('ghcup already installed, trying to upgrade'); + const args = ['upgrade', '-i']; + await callGHCup(context, logger, args, 'Upgrading ghcup', true); } else { - // needs to download ghcup - const plat = match(process.platform) - .with('darwin', (_) => 'apple-darwin') - .with('linux', (_) => 'linux') - .with('win32', (_) => 'mingw64') - .with('freebsd', (_) => 'freebsd12') - .otherwise((_) => null); - if (plat === null) { - window.showErrorMessage(`Couldn't find any pre-built ghcup binary for ${process.platform}`); - return undefined; - } - const arch = match(process.arch) - .with('arm', (_) => 'armv7') - .with('arm64', (_) => 'aarch64') - .with('x32', (_) => 'i386') - .with('x64', (_) => 'x86_64') - .otherwise((_) => null); - if (arch === null) { - window.showErrorMessage(`Couldn't find any pre-built ghcup binary for ${process.arch}`); - return undefined; - } - const dlUri = `https://downloads.haskell.org/~ghcup/${arch}-${plat}-ghcup${exeExt}`; - const title = `Downloading ${dlUri}`; - logger.info(`Downloading ${dlUri}`); - const downloaded = await downloadFile(title, dlUri, ghcup); - if (!downloaded) { - window.showErrorMessage(`Couldn't download ${dlUri} as ${ghcup}`); - } + // needs to download ghcup + const plat = match(process.platform) + .with('darwin', (_) => 'apple-darwin') + .with('linux', (_) => 'linux') + .with('win32', (_) => 'mingw64') + .with('freebsd', (_) => 'freebsd12') + .otherwise((_) => null); + if (plat === null) { + throw new Error(`Couldn't find any pre-built ghcup binary for ${process.platform}`); + } + const arch = match(process.arch) + .with('arm', (_) => 'armv7') + .with('arm64', (_) => 'aarch64') + .with('x32', (_) => 'i386') + .with('x64', (_) => 'x86_64') + .otherwise((_) => null); + if (arch === null) { + throw new Error(`Couldn't find any pre-built ghcup binary for ${process.arch}`); + } + const dlUri = `https://downloads.haskell.org/~ghcup/${arch}-${plat}-ghcup${exeExt}`; + const title = `Downloading ${dlUri}`; + logger.info(`Downloading ${dlUri}`); + const downloaded = await downloadFile(title, dlUri, ghcup); + if (!downloaded) { + throw new Error(`Couldn't download ${dlUri} as ${ghcup}`); + } } return ghcup; + } else { + throw new Error(`Internal error: tried to call ghcup while haskell.manageHLS is set to ${manageHLS}. Aborting!`); + } } /** @@ -450,53 +494,114 @@ export async function getGHCup(context: ExtensionContext, logger: Logger): Promi * @returns `1` if l is newer than r, `0` if they are equal and `-1` otherwise. */ export function comparePVP(l: string, r: string): number { - const al = l.split('.'); - const ar = r.split('.'); - - let eq = 0; - - for (let i = 0; i < Math.max(al.length, ar.length); i++) { - const el = parseInt(al[i], 10) || undefined; - const er = parseInt(ar[i], 10) || undefined; - - if (el === undefined && er === undefined) { - break; - } else if (el !== undefined && er === undefined) { - eq = 1; - break; - } else if (el === undefined && er !== undefined) { - eq = -1; - break; - } else if (el !== undefined && er !== undefined && el > er) { - eq = 1; - break; - } else if (el !== undefined && er !== undefined && el < er) { - eq = -1; - break; - } + const al = l.split('.'); + const ar = r.split('.'); + + let eq = 0; + + for (let i = 0; i < Math.max(al.length, ar.length); i++) { + const el = parseInt(al[i], 10) || undefined; + const er = parseInt(ar[i], 10) || undefined; + + if (el === undefined && er === undefined) { + break; + } else if (el !== undefined && er === undefined) { + eq = 1; + break; + } else if (el === undefined && er !== undefined) { + eq = -1; + break; + } else if (el !== undefined && er !== undefined && el > er) { + eq = 1; + break; + } else if (el !== undefined && er !== undefined && el < er) { + eq = -1; + break; } - return eq; + } + return eq; } export async function getStoragePath(context: ExtensionContext): Promise { - let storagePath: string | undefined = await workspace - .getConfiguration('haskell') - .get('releasesDownloadStoragePath'); + let storagePath: string | undefined = await workspace.getConfiguration('haskell').get('releasesDownloadStoragePath'); - if (!storagePath) { - storagePath = context.globalStorageUri.fsPath; - } else { - storagePath = resolvePathPlaceHolders(storagePath); - } + if (!storagePath) { + storagePath = context.globalStorageUri.fsPath; + } else { + storagePath = resolvePathPlaceHolders(storagePath); + } - return storagePath; + return storagePath; } export function addPathToProcessPath(extraPath: string): string { - const pathSep = process.platform === 'win32' ? ';' : ':'; - const PATH = process.env.PATH!.split(pathSep); - PATH.unshift(extraPath); - return PATH.join(pathSep); + const pathSep = process.platform === 'win32' ? ';' : ':'; + const PATH = process.env.PATH!.split(pathSep); + PATH.unshift(extraPath); + return PATH.join(pathSep); +} + +async function getLatestWrapperFromGHCup(context: ExtensionContext, logger: Logger): Promise { + const hlsVersions = await callGHCup( + context, + logger, + ['list', '-t', 'hls', '-c', 'installed', '-r'], + undefined, + false + ); + const installed = hlsVersions.split(/\r?\n/).pop(); + if (installed) { + const latestHlsVersion = installed.split(' ')[1]; + + let bin = await callGHCup(context, logger, ['whereis', 'hls', `${latestHlsVersion}`], undefined, false); + return bin; + } else { + return null; + } +} + +// complements getLatestHLSfromMetadata, 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, + storagePath: string, + logger: Logger +): Promise | null> { + const hlsVersions = await callGHCup( + context, + logger, + ['list', '-t', 'hls', '-c', 'installed', '-r'], + undefined, + false + ); + + const bindir = await callGHCup(context, 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 installed = hlsVersions.split(/\r?\n/).map((e) => e.split(' ')[1]); + if (installed?.length) { + const myMap = new Map(); + installed.forEach((hls) => { + const ghcs = files + .filter((f) => f.endsWith(`~${hls}${exeExt}`) && f.startsWith('haskell-language-server-')) + .map((f) => { + const rmPrefix = f.substring('haskell-language-server-'.length); + return rmPrefix.substring(0, rmPrefix.length - `~${hls}${exeExt}`.length); + }); + myMap.set(hls, ghcs); + }); + + return myMap; + } else { + return null; + } } /** @@ -509,14 +614,13 @@ export function addPathToProcessPath(extraPath: string): string { * @param logger Logger for feedback * @returns */ -async function getLatestHLSforGHC( +async function getHLSesfromMetadata( context: ExtensionContext, storagePath: string, - targetGhc: string, logger: Logger -): Promise { - const metadata = await getReleaseMetadata(context, storagePath, logger); - if (metadata === null) { +): Promise | null> { + const metadata = await getReleaseMetadata(context, storagePath, logger).catch((e) => null); + if (!metadata) { window.showErrorMessage('Could not get release metadata'); return null; } @@ -527,8 +631,7 @@ async function getLatestHLSforGHC( .with('freebsd', (_) => 'FreeBSD') .otherwise((_) => null); if (plat === null) { - window.showErrorMessage(`Unknown platform ${process.platform}`); - return null; + throw new Error(`Unknown platform ${process.platform}`); } const arch = match(process.arch) .with('arm', (_) => 'A_ARM') @@ -537,27 +640,19 @@ async function getLatestHLSforGHC( .with('x64', (_) => 'A_64') .otherwise((_) => null); if (arch === null) { - window.showErrorMessage(`Unknown architecture ${process.arch}`); - return null; + throw new Error(`Unknown architecture ${process.arch}`); } - let curHls: string | null = null; - const map: ReleaseMetadata = new Map(Object.entries(metadata)); + const newMap = new Map(); map.forEach((value, key) => { - const value_ = new Map(Object.entries(value)); - const archValues = new Map(Object.entries(value_.get(arch))); - const versions: string[] = archValues.get(plat) as string[]; - if (versions !== undefined && versions.some((el) => el === targetGhc)) { - if (curHls === null) { - curHls = key; - } else if (comparePVP(key, curHls) > 0) { - curHls = key; - } - } - }); + const value_ = new Map(Object.entries(value)); + const archValues = new Map(Object.entries(value_.get(arch))); + const versions: string[] = archValues.get(plat) as string[]; + newMap.set(key, versions); + }); - return curHls; + return newMap; } /** @@ -578,13 +673,13 @@ async function getReleaseMetadata( : undefined; const opts: https.RequestOptions = releasesUrl ? { - host: releasesUrl.host, - path: releasesUrl.path, - } + host: releasesUrl.host, + path: releasesUrl.path, + } : { - host: 'raw.githubusercontent.com', - path: '/haskell/ghcup-metadata/master/hls-metadata-0.0.1.json', - }; + host: 'raw.githubusercontent.com', + path: '/haskell/ghcup-metadata/master/hls-metadata-0.0.1.json', + }; const offlineCache = path.join(storagePath, 'ghcupReleases.cache.json'); @@ -592,7 +687,7 @@ async function getReleaseMetadata( try { logger.info(`Reading cached release data at ${offlineCache}`); const cachedInfo = await promisify(fs.readFile)(offlineCache, { encoding: 'utf-8' }); - // export type ReleaseMetadata = Map>>; + // export type ReleaseMetadata = Map>>; const value: ReleaseMetadata = JSON.parse(cachedInfo); return value; } catch (err: any) { @@ -619,12 +714,11 @@ async function getReleaseMetadata( window.showWarningMessage( "Couldn't get the latest haskell-language-server releases from GitHub, used local cache instead: " + - githubError.message + githubError.message ); return cachedInfoParsed; } catch (fileError) { - throw new Error("Couldn't get the latest haskell-language-server releases from GitHub: " + - githubError.message); + throw new Error("Couldn't get the latest haskell-language-server releases from GitHub: " + githubError.message); } } } diff --git a/src/utils.ts b/src/utils.ts index 15f91a6f..c30ef927 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -113,7 +113,7 @@ export async function httpsGetSilently(options: https.RequestOptions): Promise { if (res.statusCode === 301 || res.statusCode === 302) { if (!res.headers.location) { - console.error('301/302 without a location header'); + reject(new Error('301/302 without a location header')); return; } https.get(res.headers.location, (resAfterRedirect) => { @@ -123,6 +123,8 @@ export async function httpsGetSilently(options: https.RequestOptions): Promise= 400) { + reject(new Error(`Unexpected status code: ${res.statusCode}`)); } else { res.on('data', (d) => (data += d)); res.on('error', reject); diff --git a/test/suite/extension.test.ts b/test/suite/extension.test.ts index 93b4bf42..6340d440 100644 --- a/test/suite/extension.test.ts +++ b/test/suite/extension.test.ts @@ -131,7 +131,7 @@ suite('Extension Test Suite', () => { , joinUri(getWorkspaceRoot().uri, 'bin', process.platform === 'win32' ? 'ghcup' : '.ghcup', 'cache') ] ); - await getHaskellConfig().update('useSystemGHCup', false); + await getHaskellConfig().update('manageHLS', 'internal-ghcup'); await getHaskellConfig().update('logFile', 'hls.log'); await getHaskellConfig().update('trace.server', 'messages'); await getHaskellConfig().update('releasesDownloadStoragePath', path.normalize(getWorkspaceFile('bin').fsPath));