Skip to content

Commit cda68ba

Browse files
committed
Preserve behavior of installing outdated HLS if needed
1 parent f4b1d68 commit cda68ba

File tree

2 files changed

+177
-28
lines changed

2 files changed

+177
-28
lines changed

src/extension.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ async function activateServerForFolder(context: ExtensionContext, uri: Uri, fold
189189
if (serverExecutable === null) {
190190
// If not, then try to download haskell-language-server binaries if it's selected
191191
await downloadGHCup(context, logger);
192-
serverExecutable = await downloadHaskellLanguageServer(context, logger);
192+
serverExecutable = await downloadHaskellLanguageServer(context, logger, currentWorkingDir);
193193
await validateHLSToolchain(serverExecutable, currentWorkingDir, logger)
194194
addInternalServerPath = path.dirname(serverExecutable);
195195
if (!serverExecutable) {

src/hlsBinaries.ts

+176-27
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,13 @@ import * as fs from 'fs';
44
import * as path from 'path';
55
import { ExtensionContext, ProgressLocation, Uri, window, workspace } from 'vscode';
66
import { Logger } from 'vscode-languageclient';
7-
import { downloadFile, executableExists, resolvePathPlaceHolders } from './utils';
7+
import { httpsGetSilently, downloadFile, executableExists, resolvePathPlaceHolders } from './utils';
88
import { match } from 'ts-pattern';
9+
import * as url from 'url';
10+
import * as https from 'https';
11+
import { promisify } from 'util';
12+
13+
export type ReleaseMetadata = Map<string, Map<string, Map<string, string[]>>>;
914

1015
// Used for environment variables later on
1116
export interface IEnvVars {
@@ -134,7 +139,7 @@ async function callAsync(
134139
* Downloads the latest haskell-language-server binaries via ghcup.
135140
* Returns null if it can't find any match.
136141
*/
137-
export async function downloadHaskellLanguageServer(context: ExtensionContext, logger: Logger): Promise<string> {
142+
export async function downloadHaskellLanguageServer(context: ExtensionContext, logger: Logger, workingDir: string): Promise<string> {
138143
logger.info('Downloading haskell-language-server');
139144

140145
const storagePath: string = await getStoragePath(context);
@@ -179,62 +184,94 @@ export async function downloadHaskellLanguageServer(context: ExtensionContext, l
179184
}
180185
await callAsync(
181186
ghcup,
182-
['--no-verbose', 'install', 'hls', '--set', 'latest'],
187+
['--no-verbose', 'install', 'hls', 'latest'],
183188
storagePath,
184189
logger,
185190
`Installing latest HLS`,
186191
true,
187192
{ GHCUP_INSTALL_BASE_PREFIX: storagePath }
188193
);
194+
await callAsync(
195+
ghcup,
196+
['--no-verbose', 'set', 'hls', 'latest'],
197+
storagePath,
198+
logger,
199+
undefined,
200+
false,
201+
{ GHCUP_INSTALL_BASE_PREFIX: storagePath }
202+
);
189203
return downloadedWrapper;
190204
} else {
191-
const args = ['--numeric-version'];
192-
const version = await callAsync(wrapper, args, storagePath, logger);
193205

194-
const args2 = ['--no-verbose', 'list', '-t', 'hls', '-c', 'available', '-r'];
195-
const hls_versions = await callAsync(ghcup, args2, storagePath, logger, undefined, false, { GHCUP_INSTALL_BASE_PREFIX: storagePath });
206+
// version of active hls wrapper
207+
const set_version = await callAsync(wrapper, ['--numeric-version'], storagePath, logger);
208+
209+
// get latest hls version
210+
const hls_versions = await callAsync(ghcup, ['--no-verbose', 'list', '-t', 'hls', '-c', 'available', '-r'], storagePath, logger, undefined, false, { GHCUP_INSTALL_BASE_PREFIX: storagePath });
196211
const latest_hls_version = hls_versions.split(/\r?\n/).pop()!.split(' ')[1];
197212

198-
const cmp = comparePVP(version, latest_hls_version);
199-
if (cmp < 0) {
213+
// get project GHC version
214+
const project_ghc = await getProjectGHCVersion(wrapper, workingDir, logger);
215+
216+
// get installable HLS that supports the project GHC version (this might not be the most recent)
217+
const latest_metadata_hls = (project_ghc != null) ? await getLatestHLSforGHC(context, storagePath, project_ghc, logger) : null;
218+
const installable_hls = (latest_metadata_hls != null) ? latest_metadata_hls : latest_hls_version;
219+
220+
const downgrade: boolean = comparePVP(latest_hls_version, installable_hls) > 0;
221+
222+
const projectGhcWrapper = path.join(
223+
storagePath,
224+
'.ghcup',
225+
'bin',
226+
`haskell-language-server-wrapper-${project_ghc}${exeExt}`
227+
);
228+
const need_install = !executableExists(path.join(projectGhcWrapper));
229+
230+
if (comparePVP(set_version, installable_hls) != 0) {
200231
// only update if the user wants to
201232
if (updateBehaviour === 'never-check') {
202233
logger.warn(
203234
"As 'haskell.updateBehaviour' config option is set to 'never-check' " +
204235
'we try to use the possibly obsolete cached release data'
205236
);
206237
return wrapper;
207-
} else if (updateBehaviour === 'prompt') {
208-
const promptMessage =
209-
'A new version of the haskell-language-server is available, would you like to upgrade now?';
238+
} else if (updateBehaviour === 'prompt' && need_install) {
239+
let promptMessage: string;
240+
if (downgrade) {
241+
promptMessage = `A different (lower) version of the haskell-language-server is required to support ${project_ghc}, would you like to upgrade now?`;
242+
243+
} else {
244+
promptMessage = 'A new version of the haskell-language-server is available, would you like to upgrade now?';
245+
}
210246

211247
const decision = await window.showInformationMessage(promptMessage, 'Download', 'Nevermind');
212248
if (decision !== 'Download') {
213249
return wrapper;
214250
}
251+
} else {
252+
if (downgrade && need_install) {
253+
const decision = await window.showInformationMessage(`Cannot install the latest HLS version ${latest_hls_version}, because it does not support GHC ${project_ghc}. Installing HLS ${installable_hls} instead?`, 'Continue', "Abort");
254+
if (decision !== 'Continue') {
255+
return wrapper;
256+
}
257+
}
215258
}
216259

217-
// there's a new version
218-
// delete old HLS
219-
await callAsync(
220-
ghcup,
221-
['--no-verbose', 'rm', 'hls', version],
222-
storagePath,
223-
logger,
224-
`Removing old HLS ${version}`,
225-
false,
226-
{ GHCUP_INSTALL_BASE_PREFIX: storagePath }
227-
);
228-
// install new hls
260+
// we use this command to both install a HLS, but also create a nice
261+
// isolated symlinked dir with only the given HLS in place, so
262+
// this works for installing and setting
263+
const symHLSPath = path.join(storagePath, 'hls', installable_hls);
229264
await callAsync(
230265
ghcup,
231-
['--no-verbose', 'install', 'hls', '--set', latest_hls_version],
266+
[ '--no-verbose', 'run', '--hls', installable_hls
267+
, '-b', symHLSPath, '-i'],
232268
storagePath,
233269
logger,
234-
`Upgrading HLS to ${latest_hls_version}`,
235-
true,
270+
need_install ? `Installing HLS ${installable_hls}` : undefined,
271+
need_install,
236272
{ GHCUP_INSTALL_BASE_PREFIX: storagePath }
237273
);
274+
return path.join(symHLSPath, `haskell-language-server-wrapper${exeExt}`);
238275
}
239276
return wrapper;
240277
}
@@ -393,3 +430,115 @@ export function addPathToProcessPath(path: string): string {
393430
return PATH.join(pathSep);
394431
}
395432

433+
async function getLatestHLSforGHC(
434+
context: ExtensionContext,
435+
storagePath: string,
436+
targetGhc: string,
437+
logger: Logger
438+
): Promise<string | null> {
439+
const metadata = await getReleaseMetadata(context, storagePath, logger);
440+
if (metadata === null) {
441+
window.showErrorMessage(`Could not get release metadata`);
442+
return null;
443+
}
444+
const plat = match(process.platform)
445+
.with('darwin', (_) => 'Darwin')
446+
.with('linux', (_) => 'Linux_UnknownLinux')
447+
.with('win32', (_) => 'Windows')
448+
.with('freebsd', (_) => 'FreeBSD')
449+
.otherwise((_) => null);
450+
if (plat === null) {
451+
window.showErrorMessage(`Unknown platform ${process.platform}`);
452+
return null;
453+
}
454+
const arch = match(process.arch)
455+
.with('arm', (_) => 'A_ARM')
456+
.with('arm64', (_) => 'A_ARM64')
457+
.with('x32', (_) => 'A_32')
458+
.with('x64', (_) => 'A_64')
459+
.otherwise((_) => null);
460+
if (arch === null) {
461+
window.showErrorMessage(`Unknown architecture ${process.arch}`);
462+
return null;
463+
}
464+
465+
let cur_hls: string | null = null;
466+
467+
const map: ReleaseMetadata = new Map(Object.entries(metadata));
468+
map.forEach((value, key) => {
469+
const value_ = new Map(Object.entries(value));
470+
const archValues = new Map(Object.entries(value_.get(arch)));
471+
const versions: string[] = archValues.get(plat) as string[];
472+
if (versions != undefined && versions.some((el, _ix, _arr) => el === targetGhc)) {
473+
if (cur_hls == null) {
474+
cur_hls = key;
475+
} else if (comparePVP(key, cur_hls) > 0) {
476+
cur_hls = key;
477+
}
478+
}
479+
});
480+
481+
logger.info(`cur_hls: ${cur_hls}`);
482+
return cur_hls;
483+
}
484+
485+
async function getReleaseMetadata(
486+
context: ExtensionContext,
487+
storagePath: string,
488+
logger: Logger
489+
): Promise<ReleaseMetadata | null> {
490+
const releasesUrl = workspace.getConfiguration('haskell').releasesURL
491+
? url.parse(workspace.getConfiguration('haskell').releasesURL)
492+
: undefined;
493+
const opts: https.RequestOptions = releasesUrl
494+
? {
495+
host: releasesUrl.host,
496+
path: releasesUrl.path,
497+
}
498+
: {
499+
host: 'gist.githubusercontent.com',
500+
path: '/hasufell/dd84df5f81a3a7e6e6fad8f122dba429/raw/73efc1078555d971076d3ccf31154f10ed683a82/hls-metadata.json',
501+
};
502+
503+
const offlineCache = path.join(storagePath, 'ghcupReleases.cache.json');
504+
505+
async function readCachedReleaseData(): Promise<ReleaseMetadata | null> {
506+
try {
507+
logger.info(`Reading cached release data at ${offlineCache}`);
508+
const cachedInfo = await promisify(fs.readFile)(offlineCache, { encoding: 'utf-8' });
509+
// export type ReleaseMetadata = Map<string, Map<string, Map<string, string[]>>>;
510+
const value: ReleaseMetadata = JSON.parse(cachedInfo);
511+
return value;
512+
} catch (err: any) {
513+
// If file doesn't exist, return null, otherwise consider it a failure
514+
if (err.code === 'ENOENT') {
515+
logger.warn(`No cached release data found at ${offlineCache}`);
516+
return null;
517+
}
518+
throw err;
519+
}
520+
}
521+
522+
try {
523+
const releaseInfo = await httpsGetSilently(opts);
524+
const releaseInfoParsed = JSON.parse(releaseInfo);
525+
526+
// Cache the latest successfully fetched release information
527+
await promisify(fs.writeFile)(offlineCache, JSON.stringify(releaseInfoParsed), { encoding: 'utf-8' });
528+
return releaseInfoParsed;
529+
} catch (githubError: any) {
530+
// Attempt to read from the latest cached file
531+
try {
532+
const cachedInfoParsed = await readCachedReleaseData();
533+
534+
window.showWarningMessage(
535+
"Couldn't get the latest haskell-language-server releases from GitHub, used local cache instead: " +
536+
githubError.message
537+
);
538+
return cachedInfoParsed;
539+
} catch (fileError) {
540+
throw new Error("Couldn't get the latest haskell-language-server releases from GitHub: " +
541+
githubError.message);
542+
}
543+
}
544+
}

0 commit comments

Comments
 (0)