Skip to content

Commit 391ce94

Browse files
committed
Turn ghcup into a class
1 parent e111eca commit 391ce94

File tree

2 files changed

+120
-104
lines changed

2 files changed

+120
-104
lines changed

src/ghcup.ts

Lines changed: 94 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -4,45 +4,111 @@ import * as process from 'process';
44
import { workspace, WorkspaceFolder } from 'vscode';
55
import { Logger } from 'vscode-languageclient';
66
import { MissingToolError } from './errors';
7-
import { resolvePathPlaceHolders, executableExists, callAsync, ProcessCallback } from './utils';
7+
import { resolvePathPlaceHolders, executableExists, callAsync, ProcessCallback, IEnvVars } from './utils';
88
import { match } from 'ts-pattern';
99

1010
export type Tool = 'hls' | 'ghc' | 'cabal' | 'stack';
1111

1212
export type ToolConfig = Map<Tool, string>;
1313

14-
export async function callGHCup(
15-
logger: Logger,
16-
args: string[],
17-
title?: string,
18-
cancellable?: boolean,
19-
callback?: ProcessCallback,
20-
): Promise<string> {
21-
const metadataUrl = workspace.getConfiguration('haskell').metadataURL;
22-
const ghcup = findGHCup(logger);
23-
return await callAsync(
24-
ghcup,
25-
['--no-verbose'].concat(metadataUrl ? ['-s', metadataUrl] : []).concat(args),
26-
logger,
27-
undefined,
28-
title,
29-
cancellable,
30-
{
31-
// omit colourful output because the logs are uglier
32-
NO_COLOR: '1',
33-
},
34-
callback,
35-
);
14+
export function initDefaultGHCup(logger: Logger, folder?: WorkspaceFolder): GHCup {
15+
const ghcupLoc = findGHCup(logger, folder);
16+
return new GHCup(logger, ghcupLoc, {
17+
// omit colourful output because the logs are uglier
18+
NO_COLOR: '1',
19+
});
3620
}
3721

38-
export async function upgradeGHCup(logger: Logger): Promise<void> {
39-
const upgrade = workspace.getConfiguration('haskell').get('upgradeGHCup') as boolean;
40-
if (upgrade) {
41-
await callGHCup(logger, ['upgrade'], 'Upgrading ghcup', true);
22+
export class GHCup {
23+
constructor(
24+
readonly logger: Logger,
25+
readonly location: string,
26+
readonly environment: IEnvVars,
27+
) {}
28+
29+
/**
30+
* Most generic way to run the `ghcup` binary.
31+
* @param args Arguments to run the `ghcup` binary with.
32+
* @param title Displayed to the user for long-running tasks.
33+
* @param cancellable Whether this invocation can be cancelled by the user.
34+
* @param callback Handle success or failures.
35+
* @returns The output of the `ghcup` invocation. If no {@link callback} is given, this is the stdout. Otherwise, whatever {@link callback} produces.
36+
*/
37+
public async call(
38+
args: string[],
39+
title?: string,
40+
cancellable?: boolean,
41+
callback?: ProcessCallback,
42+
): Promise<string> {
43+
const metadataUrl = workspace.getConfiguration('haskell').metadataURL;
44+
return await callAsync(
45+
this.location,
46+
['--no-verbose'].concat(metadataUrl ? ['-s', metadataUrl] : []).concat(args),
47+
this.logger,
48+
undefined,
49+
title,
50+
cancellable,
51+
this.environment,
52+
callback,
53+
);
54+
}
55+
56+
/**
57+
* Upgrade the `ghcup` binary unless this option was disabled by the user.
58+
*/
59+
public async upgrade(): Promise<void> {
60+
const upgrade = workspace.getConfiguration('haskell').get('upgradeGHCup') as boolean;
61+
if (upgrade) {
62+
await this.call(['upgrade'], 'Upgrading ghcup', true);
63+
}
64+
}
65+
66+
/**
67+
* Find the latest version of a {@link Tool} that we can find in GHCup.
68+
* Prefer already installed versions, but fall back to all available versions, if there aren't any.
69+
* @param tool Tool you want to know the latest version of.
70+
* @returns The latest installed or generally available version of the {@link tool}
71+
*/
72+
public async getLatestVersion(tool: Tool): Promise<string> {
73+
// these might be custom/stray/compiled, so we try first
74+
const installedVersions = await this.call(['list', '-t', tool, '-c', 'installed', '-r'], undefined, false);
75+
const latestInstalled = installedVersions.split(/\r?\n/).pop();
76+
if (latestInstalled) {
77+
return latestInstalled.split(/\s+/)[1];
78+
}
79+
80+
return this.getLatestAvailableVersion(tool);
81+
}
82+
83+
/**
84+
* Find the latest available version that we can find in GHCup with a certain {@link tag}.
85+
* Corresponds to the `ghcup list -t <tool> -c available -r` command.
86+
* The tag can be used to further filter the list of versions, for example you can provide
87+
* @param tool Tool you want to know the latest version of.
88+
* @param tag The tag to filter the available versions with. By default `"latest"`.
89+
* @returns The latest available version filtered by {@link tag}.
90+
*/
91+
public async getLatestAvailableVersion(tool: Tool, tag: string = 'latest'): Promise<string> {
92+
// fall back to installable versions
93+
const availableVersions = await this.call(['list', '-t', tool, '-c', 'available', '-r'], undefined, false).then(
94+
(s) => s.split(/\r?\n/),
95+
);
96+
97+
let latestAvailable: string | null = null;
98+
availableVersions.forEach((ver) => {
99+
if (ver.split(/\s+/)[2].split(',').includes(tag)) {
100+
latestAvailable = ver.split(/\s+/)[1];
101+
}
102+
});
103+
if (!latestAvailable) {
104+
throw new Error(`Unable to find ${tag} tool ${tool}`);
105+
} else {
106+
return latestAvailable;
107+
}
42108
}
43109
}
44110

45-
export function findGHCup(logger: Logger, folder?: WorkspaceFolder): string {
111+
function findGHCup(logger: Logger, folder?: WorkspaceFolder): string {
46112
logger.info('Checking for ghcup installation');
47113
let exePath = workspace.getConfiguration('haskell').get('ghcupExecutablePath') as string;
48114
if (exePath) {
@@ -97,47 +163,3 @@ export function findGHCup(logger: Logger, folder?: WorkspaceFolder): string {
97163
}
98164
}
99165
}
100-
101-
// the tool might be installed or not
102-
export async function getLatestToolFromGHCup(logger: Logger, tool: Tool): Promise<string> {
103-
// these might be custom/stray/compiled, so we try first
104-
const installedVersions = await callGHCup(logger, ['list', '-t', tool, '-c', 'installed', '-r'], undefined, false);
105-
const latestInstalled = installedVersions.split(/\r?\n/).pop();
106-
if (latestInstalled) {
107-
return latestInstalled.split(/\s+/)[1];
108-
}
109-
110-
return getLatestAvailableToolFromGHCup(logger, tool);
111-
}
112-
113-
export async function getLatestAvailableToolFromGHCup(
114-
logger: Logger,
115-
tool: Tool,
116-
tag?: string,
117-
criteria?: string,
118-
): Promise<string> {
119-
// fall back to installable versions
120-
const availableVersions = await callGHCup(
121-
logger,
122-
['list', '-t', tool, '-c', criteria ? criteria : 'available', '-r'],
123-
undefined,
124-
false,
125-
).then((s) => s.split(/\r?\n/));
126-
127-
let latestAvailable: string | null = null;
128-
availableVersions.forEach((ver) => {
129-
if (
130-
ver
131-
.split(/\s+/)[2]
132-
.split(',')
133-
.includes(tag ? tag : 'latest')
134-
) {
135-
latestAvailable = ver.split(/\s+/)[1];
136-
}
137-
});
138-
if (!latestAvailable) {
139-
throw new Error(`Unable to find ${tag ? tag : 'latest'} tool ${tool}`);
140-
} else {
141-
return latestAvailable;
142-
}
143-
}

src/hlsBinaries.ts

Lines changed: 26 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,7 @@ import {
1515
IEnvVars,
1616
resolvePathPlaceHolders,
1717
} from './utils';
18-
import * as ghcup from './ghcup';
19-
import { ToolConfig, Tool } from './ghcup';
18+
import { ToolConfig, Tool, initDefaultGHCup, GHCup } from './ghcup';
2019
export { IEnvVars };
2120

2221
type ManageHLS = 'GHCup' | 'PATH';
@@ -129,7 +128,8 @@ export async function findHaskellLanguageServer(
129128
};
130129
} else {
131130
// we manage HLS, make sure ghcup is installed/available
132-
await ghcup.upgradeGHCup(logger);
131+
const ghcup = initDefaultGHCup(logger, folder);
132+
await ghcup.upgrade();
133133

134134
// boring init
135135
let latestHLS: string | undefined;
@@ -157,34 +157,32 @@ export async function findHaskellLanguageServer(
157157
// (we need HLS and cabal/stack and ghc as fallback),
158158
// later we may install a different toolchain that's more project-specific
159159
if (latestHLS === undefined) {
160-
latestHLS = await ghcup.getLatestToolFromGHCup(logger, 'hls');
160+
latestHLS = await ghcup.getLatestVersion('hls');
161161
}
162162
if (latestCabal === undefined) {
163-
latestCabal = await ghcup.getLatestToolFromGHCup(logger, 'cabal');
163+
latestCabal = await ghcup.getLatestVersion('cabal');
164164
}
165165
if (latestStack === undefined) {
166-
latestStack = await ghcup.getLatestToolFromGHCup(logger, 'stack');
166+
latestStack = await ghcup.getLatestVersion('stack');
167167
}
168168
if (recGHC === undefined) {
169-
recGHC = !executableExists('ghc')
170-
? await ghcup.getLatestAvailableToolFromGHCup(logger, 'ghc', 'recommended')
171-
: null;
169+
recGHC = !executableExists('ghc') ? await ghcup.getLatestAvailableVersion('ghc', 'recommended') : null;
172170
}
173171

174172
// download popups
175173
const promptBeforeDownloads = workspace.getConfiguration('haskell').get('promptBeforeDownloads') as boolean;
176174
if (promptBeforeDownloads) {
177-
const hlsInstalled = latestHLS ? await toolInstalled(logger, 'hls', latestHLS) : undefined;
178-
const cabalInstalled = latestCabal ? await toolInstalled(logger, 'cabal', latestCabal) : undefined;
179-
const stackInstalled = latestStack ? await toolInstalled(logger, 'stack', latestStack) : undefined;
175+
const hlsInstalled = latestHLS ? await toolInstalled(ghcup, 'hls', latestHLS) : undefined;
176+
const cabalInstalled = latestCabal ? await toolInstalled(ghcup, 'cabal', latestCabal) : undefined;
177+
const stackInstalled = latestStack ? await toolInstalled(ghcup, 'stack', latestStack) : undefined;
180178
const ghcInstalled = executableExists('ghc')
181179
? new InstalledTool(
182180
'ghc',
183181
await callAsync(`ghc${exeExt}`, ['--numeric-version'], logger, undefined, undefined, false),
184182
)
185183
: // if recGHC is null, that means user disabled automatic handling,
186184
recGHC !== null
187-
? await toolInstalled(logger, 'ghc', recGHC)
185+
? await toolInstalled(ghcup, 'ghc', recGHC)
188186
: undefined;
189187
const toInstall: InstalledTool[] = [hlsInstalled, cabalInstalled, stackInstalled, ghcInstalled].filter(
190188
(tool) => tool && !tool.installed,
@@ -222,8 +220,7 @@ export async function findHaskellLanguageServer(
222220
}
223221

224222
// our preliminary toolchain
225-
const latestToolchainBindir = await ghcup.callGHCup(
226-
logger,
223+
const latestToolchainBindir = await ghcup.call(
227224
[
228225
'run',
229226
...(latestHLS ? ['--hls', latestHLS] : []),
@@ -246,7 +243,7 @@ export async function findHaskellLanguageServer(
246243
// now figure out the actual project GHC version and the latest supported HLS version
247244
// we need for it (e.g. this might in fact be a downgrade for old GHCs)
248245
if (projectHls === undefined || projectGhc === undefined) {
249-
const res = await getLatestProjectHLS(context, logger, workingDir, latestToolchainBindir);
246+
const res = await getLatestProjectHLS(ghcup, context, logger, workingDir, latestToolchainBindir);
250247
if (projectHls === undefined) {
251248
projectHls = res[0];
252249
}
@@ -257,8 +254,8 @@ export async function findHaskellLanguageServer(
257254

258255
// more download popups
259256
if (promptBeforeDownloads) {
260-
const hlsInstalled = await toolInstalled(logger, 'hls', projectHls);
261-
const ghcInstalled = projectGhc ? await toolInstalled(logger, 'ghc', projectGhc) : undefined;
257+
const hlsInstalled = await toolInstalled(ghcup, 'hls', projectHls);
258+
const ghcInstalled = projectGhc ? await toolInstalled(ghcup, 'ghc', projectGhc) : undefined;
262259
const toInstall: InstalledTool[] = [hlsInstalled, ghcInstalled].filter(
263260
(tool) => tool && !tool.installed,
264261
) as InstalledTool[];
@@ -292,8 +289,7 @@ export async function findHaskellLanguageServer(
292289
}
293290

294291
// now install the proper versions
295-
const hlsBinDir = await ghcup.callGHCup(
296-
logger,
292+
const hlsBinDir = await ghcup.call(
297293
[
298294
'run',
299295
...['--hls', projectHls],
@@ -357,6 +353,7 @@ async function promptUserForManagingHls(context: ExtensionContext, manageHlsSett
357353
}
358354

359355
async function getLatestProjectHLS(
356+
ghcup: GHCup,
360357
context: ExtensionContext,
361358
logger: Logger,
362359
workingDir: string,
@@ -376,7 +373,7 @@ async function getLatestProjectHLS(
376373
// first we get supported GHC versions from available HLS bindists (whether installed or not)
377374
const metadataMap = (await getHlsMetadata(context, logger)) || new Map<string, string[]>();
378375
// then we get supported GHC versions from currently installed HLS versions
379-
const ghcupMap = (await findAvailableHlsBinariesFromGHCup(logger)) || new Map<string, string[]>();
376+
const ghcupMap = (await findAvailableHlsBinariesFromGHCup(ghcup)) || new Map<string, string[]>();
380377
// since installed HLS versions may support a different set of GHC versions than the bindists
381378
// (e.g. because the user ran 'ghcup compile hls'), we need to merge both maps, preferring
382379
// values from already installed HLSes
@@ -471,16 +468,14 @@ export function getStoragePath(context: ExtensionContext): string {
471468
* If 'targetGhc' is omitted, picks the latest 'haskell-language-server-wrapper',
472469
* otherwise ensures the specified GHC is supported.
473470
*
474-
* @param context
475-
* @param logger
476-
* @returns
471+
* @param ghcup GHCup wrapper.
472+
* @returns A Map of the locally installed HLS versions and with which `GHC` versions they are compatible.
477473
*/
478474

479-
async function findAvailableHlsBinariesFromGHCup(logger: Logger): Promise<Map<string, string[]> | null> {
480-
const hlsVersions = await ghcup.callGHCup(logger, ['list', '-t', 'hls', '-c', 'installed', '-r'], undefined, false);
481-
482-
const bindir = await ghcup.callGHCup(logger, ['whereis', 'bindir'], undefined, false);
475+
async function findAvailableHlsBinariesFromGHCup(ghcup: GHCup): Promise<Map<string, string[]> | null> {
476+
const hlsVersions = await ghcup.call(['list', '-t', 'hls', '-c', 'installed', '-r'], undefined, false);
483477

478+
const bindir = await ghcup.call(['whereis', 'bindir'], undefined, false);
484479
const files = fs.readdirSync(bindir).filter((e) => {
485480
const stat = fs.statSync(path.join(bindir, e));
486481
return stat.isFile();
@@ -498,16 +493,15 @@ async function findAvailableHlsBinariesFromGHCup(logger: Logger): Promise<Map<st
498493
});
499494
myMap.set(hls, ghcs);
500495
});
501-
502496
return myMap;
503497
} else {
504498
return null;
505499
}
506500
}
507501

508-
async function toolInstalled(logger: Logger, tool: Tool, version: string): Promise<InstalledTool> {
502+
async function toolInstalled(ghcup: GHCup, tool: Tool, version: string): Promise<InstalledTool> {
509503
const b = await ghcup
510-
.callGHCup(logger, ['whereis', tool, version], undefined, false)
504+
.call(['whereis', tool, version], undefined, false)
511505
.then(() => true)
512506
.catch(() => false);
513507
return new InstalledTool(tool, version, b);
@@ -650,7 +644,7 @@ async function getReleaseMetadata(storagePath: string, logger: Logger): Promise<
650644
/**
651645
* Convert a json value to ReleaseMetadata.
652646
* Assumes the json is well-formed and a valid Release-Metadata.
653-
* @param obj Release Metadata without any typing information but well-formed.
647+
* @param someObj Release Metadata without any typing information but well-formed.
654648
* @returns Typed ReleaseMetadata.
655649
*/
656650
const objectToMetadata = (someObj: any): ReleaseMetadata => {

0 commit comments

Comments
 (0)