Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

nvim version from env #207

Merged
merged 12 commits into from
Jul 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/neovim/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,8 @@ export { Neovim, NeovimClient, Buffer, Tabpage, Window } from './api/index';
export { Plugin, Function, Autocmd, Command } from './plugin';
export { NvimPlugin } from './host/NvimPlugin';
export { loadPlugin } from './host/factory';
export {
getNvimFromEnv,
GetNvimFromEnvOptions,
GetNvimFromEnvResult,
} from './utils/getNvimFromEnv';
101 changes: 101 additions & 0 deletions packages/neovim/src/utils/getNvimFromEnv.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/* eslint-env jest */
// eslint-disable-next-line import/no-extraneous-dependencies
import which from 'which';
import {
getNvimFromEnv,
compareVersions,
parseVersion,
} from './getNvimFromEnv';

try {
which.sync('nvim');
} catch (e) {
// eslint-disable-next-line no-console
console.error(
'A Neovim installation is required to run the tests',
'(see https://github.com/neovim/neovim/wiki/Installing)'
);
process.exit(1);
}

describe('get_nvim_from_env', () => {
it('Parse version', () => {
const a = parseVersion('0.5.0-dev+1357-g192f89ea1');
expect(a).toEqual([0, 5, 0, 'dev+1357-g192f89ea1']);
const b = parseVersion('0.5.0-dev+1357-g192f89ea1-Homebrew');
expect(b).toEqual([0, 5, 0, 'dev+1357-g192f89ea1-Homebrew']);
const c = parseVersion('0.9.1');
expect(c).toEqual([0, 9, 1, 'zzz']);
});

it('Compare versions', () => {
expect(compareVersions('0.3.0', '0.3.0')).toBe(0);
expect(compareVersions('0.3.0', '0.3.1')).toBe(-1);
expect(compareVersions('0.3.1', '0.3.0')).toBe(1);
expect(compareVersions('0.3.0-abc', '0.3.0-dev-420')).toBe(-1);
expect(compareVersions('0.3.0', '0.3.0-dev-658+g06694203e-Homebrew')).toBe(
1
);
expect(compareVersions('0.3.0-dev-658+g06694203e-Homebrew', '0.3.0')).toBe(
-1
);
expect(
compareVersions(
'0.3.0-dev-658+g06694203e-Homebrew',
'0.3.0-dev-658+g06694203e-Homebrew'
)
).toBe(0);
expect(
compareVersions(
'0.3.0-dev-658+g06694203e-Homebrew',
'0.3.0-dev-659+g06694203e-Homebrew'
)
).toBe(-1);
expect(
compareVersions(
'0.3.0-dev-659+g06694203e-Homebrew',
'0.3.0-dev-658+g06694203e-Homebrew'
)
).toBe(1);
});

it('Get matching nvim from specified min version', () => {
const nvimRes = getNvimFromEnv({ minVersion: '0.3.0' });
expect(nvimRes).toBeTruthy();
expect(nvimRes).toEqual({
matches: expect.any(Array),
unmatchedVersions: expect.any(Array),
errors: expect.any(Array),
});
expect(nvimRes.matches.length).toBeTruthy();
expect(nvimRes.matches.length).toBeGreaterThan(0);
expect(nvimRes.matches[0]).toEqual({
nvimVersion: expect.any(String),
path: expect.any(String),
buildType: expect.any(String),
luaJitVersion: expect.any(String),
});
expect(nvimRes.unmatchedVersions.length).toEqual(0);
expect(nvimRes.errors.length).toEqual(0);
});

it('Get matching nvim without specified min version', () => {
const nvimRes = getNvimFromEnv();
expect(nvimRes).toBeTruthy();
expect(nvimRes).toEqual({
matches: expect.any(Array),
unmatchedVersions: expect.any(Array),
errors: expect.any(Array),
});
expect(nvimRes.matches.length).toBeTruthy();
expect(nvimRes.matches.length).toBeGreaterThan(0);
expect(nvimRes.matches[0]).toEqual({
nvimVersion: expect.any(String),
path: expect.any(String),
buildType: expect.any(String),
luaJitVersion: expect.any(String),
});
expect(nvimRes.unmatchedVersions.length).toEqual(0);
expect(nvimRes.errors.length).toEqual(0);
});
});
202 changes: 202 additions & 0 deletions packages/neovim/src/utils/getNvimFromEnv.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import { execSync } from 'child_process';
import { join, delimiter } from 'path';
import { constants, existsSync, accessSync } from 'fs';

export type NvimVersion = {
readonly nvimVersion: string;
readonly path: string;
readonly buildType: string;
readonly luaJitVersion: string;
};

export type GetNvimFromEnvOptions = {
/**
* The minimum version of nvim to get. This is optional.
*
* - Example: `'0.5.0'`
* - Note: This is inclusive.
* - Note: If this is not set, then there is no minimum version.
*/
readonly minVersion?: string;
/**
* The order to return the nvim versions in. This is optional.
*
* - `latest_nvim_first` - The latest version of nvim will be first. This is the default.
* - Example: `['0.5.0', '0.4.4', '0.4.3']`
* - Note: This will be slower than `latest_nvim_first`.
* - `keep_path` - The order of the nvim versions will be the same as the order of the paths in the `PATH` environment variable.
* - Example: `['0.4.4', '0.5.0', '0.4.3']`
* - Note: This will be faster than `latest_nvim_first`.
* - this is the default.
*/
readonly orderBy?: 'latest_nvim_first' | 'keep_path';
};

export type GetNvimFromEnvError = {
/** The executeable path that failed. */
readonly path: string;
/** The catched error */
readonly exception: Readonly<Error>;
};

export type GetNvimFromEnvResult = {
/**
* A list of nvim versions that match the minimum version.
* This will be empty if no matching versions were found.
* This will be sorted in the order specified by `orderBy`.
*/
readonly matches: ReadonlyArray<NvimVersion>;
/**
* A list of nvim versions that do not match the minimum version.
* This will be empty if all versions match the minimum version or if no minimum version was specified.
* This will not be sorted (it will be in the order of the paths in the `PATH` environment variable).
*/
readonly unmatchedVersions: ReadonlyArray<NvimVersion>;
/**
* A list of errors that occurred while trying to get the nvim versions.
* This will be empty if no errors occurred.
* This will not be sorted (it will be in the order of the paths in the `PATH` environment variable).
* Unmatched versions are not treated as errors.
*/
readonly errors: ReadonlyArray<GetNvimFromEnvError>;
};

const versionRegex = /^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/;
const nvimVersionRegex = /^NVIM\s+v(.+)$/m;
const buildTypeRegex = /^Build\s+type:\s+(.+)$/m;
const luaJitVersionRegex = /^LuaJIT\s+(.+)$/m;
const windows = process.platform === 'win32';

export function parseVersion(version: string): (number | string)[] | null {
if (typeof version !== 'string') {
throw new Error('Invalid version format: not a string');
}

const match = version.match(versionRegex);
if (match === null) {
return null;
}

const [, major, minor, patch, prerelease] = match;
const majorNumber = Number(major);
if (Number.isNaN(majorNumber)) {
throw new Error('Invalid version format: major is not a number');
}

const minorNumber = Number(minor);
if (Number.isNaN(minorNumber)) {
throw new Error('Invalid version format: minor is not a number');
}

const patchNumber = Number(patch);
if (Number.isNaN(patchNumber)) {
throw new Error('Invalid version format: patch is not a number');
}

const versionParts: Array<number | string> = [
majorNumber,
minorNumber,
patchNumber,
];
if (prerelease !== undefined) {
versionParts.push(prerelease);
} else {
versionParts.push('zzz');
}
return versionParts;
}

/**
* Compare two versions.
* @param a - The first version to compare.
* @param b - The second version to compare.
* @returns -1 if a < b, 0 if a == b, 1 if a > b.
* @throws {Error} If the versions are not valid.
*
* Format could be:
* - 0.9.1
* - 0.10.0-dev-658+g06694203e-Homebrew
*/
export function compareVersions(a: string, b: string): number {
const versionA = parseVersion(a);
const versionB = parseVersion(b);
const length = Math.min(versionA.length, versionB.length);

for (let i = 0; i < length; i = i + 1) {
const partA = versionA[i];
const partB = versionB[i];
if (partA < partB) {
return -1;
}
if (partA > partB) {
return 1;
}
}

if (versionB.length > versionA.length) {
return -1;
}

return 0;
}

/**
* Get the highest matching nvim version from the environment.
Copy link
Member

@justinmk justinmk Jul 16, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "find the highest" version logic seems nice, but it might be worth dropping it to simplify the code. Instead, if the first nvim found doesn't meet the minimum version, the caller can simply report this to the user (mentioning the found nvim path and version).

fwiw, Nvim 0.9+ includes vim.version.parse(). Could eventually use that to avoid rolling our own semver parsing here.

*/
export function getNvimFromEnv(
opt: GetNvimFromEnvOptions = {}
): Readonly<GetNvimFromEnvResult> {
const paths = process.env.PATH.split(delimiter);
Copy link
Member

@justinmk justinmk Jul 17, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$PATH is likely not sufficient, especially for applications such as https://github.com/vscode-neovim/vscode-neovim which are started in GUI contexts and often (macOS, at least) the user's terminal $PATH is much different than whatever GUI apps are started with.

Thus we also need to search "common locations". These are the ones I know of:

/usr/local/bin/
/usr/bin
/opt/homebrew/bin
/home/linuxbrew/.linuxbrew/bin
$HOME/.linuxbrew/bin
$HOME/bin

Ironically on Windows I think we can depend on $PATH. I assume that winget and scoop setup $PATH, which gets broadcast to GUI programs on Windows. Else we may also need to add those locations. But that could be improved later.

const pathLength = paths.length;
const matches = new Array<NvimVersion>();
const unmatchedVersions = new Array<NvimVersion>();
const errors = new Array<GetNvimFromEnvError>();
for (let i = 0; i !== pathLength; i = i + 1) {
const possibleNvimPath = join(paths[i], windows ? 'nvim.exe' : 'nvim');
if (existsSync(possibleNvimPath)) {
try {
accessSync(possibleNvimPath, constants.X_OK);
const nvimVersionFull = execSync(
`${possibleNvimPath} --version`
).toString();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nvim --api-info returns a msgpack object that includes a version dict. That avoids needing to parse nvim --version though I suppose that output isn't likely to change.

const nvimVersionMatch = nvimVersionRegex.exec(nvimVersionFull);
const buildTypeMatch = buildTypeRegex.exec(nvimVersionFull);
const luaJitVersionMatch = luaJitVersionRegex.exec(nvimVersionFull);
if (nvimVersionMatch && buildTypeMatch && luaJitVersionMatch) {
if (
'minVersion' in opt &&
compareVersions(opt.minVersion, nvimVersionMatch[1]) === 1
) {
unmatchedVersions.push({
nvimVersion: nvimVersionMatch[1],
path: possibleNvimPath,
buildType: buildTypeMatch[1],
luaJitVersion: luaJitVersionMatch[1],
});
}
matches.push({
nvimVersion: nvimVersionMatch[1],
path: possibleNvimPath,
buildType: buildTypeMatch[1],
luaJitVersion: luaJitVersionMatch[1],
});
}
} catch (e) {
errors.push({
path: possibleNvimPath,
exception: e,
} as const);
}
}
}

if (matches.length > 1 && opt.orderBy === 'latest_nvim_first') {
matches.sort((a, b) => compareVersions(b.nvimVersion, a.nvimVersion));
}

return {
matches,
unmatchedVersions,
errors,
} as const;
}