Skip to content
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

- fix: "Right-hand side of 'instanceof' is not an object" if `api/Buffer` is required before the
rest of the package. https://github.com/vscode-neovim/vscode-neovim/issues/2671
- feat: `findNvim(…, { cmds: … })` accepts multi-part commands.
Example: `['wsl.exe', '-d', 'Ubuntu', 'nvim']`. Results have `cmd`; `path` is deprecated. #432

## [5.4.0](https://github.com/neovim/node-client/compare/v5.3.0...v5.4.0)

Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ Following is a complete, working example.
1. Install the `neovim` package _locally_ in any directory (i.e. without `-g`. Node throws `ERR_MODULE_NOT_FOUND` if a script imports a _globally_ installed package).
```bash
npm install neovim
# Or if build from source:
npm run build
```
2. Paste the script below into a `demo.mjs` file and run it!

Expand All @@ -69,7 +71,8 @@ Following is a complete, working example.
(async function () {
const found = findNvim({ orderBy: 'desc', minVersion: '0.9.0' });
console.log(found);
const nvim_proc = child_process.spawn(found.matches[0].path, ['--clean', '--embed'], {});
const [arg0, ...args] = found.matches[0].cmd;
const nvim_proc = child_process.spawn(arg0, [...args, '--clean', '--embed'], {});
const nvim = attach({ proc: nvim_proc });

nvim.command('vsp | vsp | vsp');
Expand Down
8 changes: 4 additions & 4 deletions packages/neovim/src/testUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@ import { getLogger } from './utils/logger';

export function findNvimOrFail() {
const minVersion = '0.9.5';
const found = findNvim({ minVersion });
const found = findNvim({ minVersion, firstMatch: true });
if (found.matches.length === 0) {
throw new Error(`nvim ${minVersion} not found`);
}
return found.matches[0].path;
return found.matches[0].cmd;
}

const nvimPath = findNvimOrFail();
const nvimCmd = findNvimOrFail();

// eslint-disable-next-line import/no-mutable-exports
export let proc: cp.ChildProcessWithoutNullStreams;
Expand All @@ -36,7 +36,7 @@ export function startNvim(
fs.writeFileSync(logfile, `${msg}\n`, { flag: 'a' });
}

proc = cp.spawn(nvimPath, ['-u', 'NONE', '--embed', '-n', '--noplugin'], {
proc = cp.spawn(nvimCmd[0], [...nvimCmd.slice(1), '-u', 'NONE', '--embed', '-n', '--noplugin'], {
cwd: __dirname,
});
if (!doAttach) {
Expand Down
39 changes: 23 additions & 16 deletions packages/neovim/src/utils/findNvim.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ describe('findNvim', () => {
});
expect(nvimRes.matches.length).toBeGreaterThan(0);
expect(nvimRes.matches[0]).toEqual({
cmd: [expect.any(String)],
nvimVersion: expect.any(String),
path: expect.any(String),
buildType: expect.any(String),
Expand Down Expand Up @@ -103,6 +104,7 @@ describe('findNvim', () => {
expect(nvimRes.matches.length).toEqual(0);
expect(nvimRes.invalid.length).toBeGreaterThan(0);
expect(nvimRes.invalid[0]).toEqual({
cmd: [expect.any(String)],
nvimVersion: expect.any(String),
path: expect.any(String),
buildType: expect.any(String),
Expand All @@ -119,6 +121,7 @@ describe('findNvim', () => {
});
expect(nvimRes.matches.length).toEqual(1);
expect(nvimRes.matches[0]).toEqual({
cmd: [expect.any(String)],
nvimVersion: expect.any(String),
path: expect.any(String),
buildType: expect.any(String),
Expand All @@ -127,29 +130,33 @@ describe('findNvim', () => {
});
});

it('searches in additional custom paths', () => {
const customPaths = [
join(process.cwd(), 'package.json'),
'/custom/path/to/nvim',
'/another/custom/path',
].map(normalizePath);
const nvimRes = findNvim({ paths: customPaths });

expect(nvimRes.matches.length).toBeGreaterThanOrEqual(1);

expect(nvimRes.invalid.length).toBe(3);

const invalidPaths = nvimRes.invalid.map(i => i.path);
expect(invalidPaths).toEqual(customPaths);
it('tries commands given by `cmds`', () => {
const nvim = findNvim({ firstMatch: true }).matches[0].cmd[0];
const cmds = [
[nvim, '--clean'],
[nvim, '--bogus'],
[join(process.cwd(), 'package.json')],
['/custom/path/to/nvim'],
['/another/custom/path'],
].map(([arg0, ...args]) => [normalizePath(arg0), ...args]);
const nvimRes = findNvim({ cmds, orderBy: 'none' });

// Args are passed to the command: `--clean` works, `--bogus` fails.
expect(nvimRes.matches[0].cmd).toEqual(cmds[0]);
expect(nvimRes.invalid.map(i => i.cmd)).toEqual(cmds.slice(1));
// Deprecated `paths`.
expect(findNvim({ paths: cmds[2] }).invalid.map(i => i.cmd)).toEqual([cmds[2]]);
});

it('searches in additional custom dirs', () => {
it('searches in additional custom dirs', function () {
// Windows: running the fresh fake nvim.exe can be slow (antivirus scan). #426
this.timeout(10000);
const customDirs = [testDir, '/non/existent/dir'].map(normalizePath);
const nvimRes = findNvim({ dirs: customDirs });

expect(nvimRes.matches.length).toBeGreaterThanOrEqual(1);

expect(nvimRes.invalid.length).toBe(1);
expect(nvimRes.invalid[0].path).toBe(nvimExecutablePath);
expect(nvimRes.invalid[0].cmd).toEqual([nvimExecutablePath]);
});
});
151 changes: 76 additions & 75 deletions packages/neovim/src/utils/findNvim.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { execFileSync } from 'node:child_process';
import { join, delimiter, normalize } from 'node:path';
import { constants, existsSync, accessSync } from 'node:fs';
import { existsSync } from 'node:fs';

export type NvimVersion = {
/** Path to `nvim` executable. */
/** Command that runs Nvim, e.g. `['wsl.exe', '-d', 'Ubuntu', 'nvim']`. Append args to it. */
readonly cmd: string[];
/** @deprecated Use `cmd`. Same as `cmd[0]`. */
readonly path: string;
/** Nvim version, or undefined if there was an error. */
readonly nvimVersion?: string;
Expand Down Expand Up @@ -36,16 +38,24 @@ export type FindNvimOptions = {
*/
readonly firstMatch?: boolean;
/**
* (Optional) Additional specific file paths to check for Nvim executables.
* These paths will be checked before searching `dirs`.
* Useful for allowing users to specify exact Nvim executable locations.
* (Optional) Other commands that (potentially) invoke Nvim and can receive arbitrary args.
* Checked before searching `dirs`. Useful for checking a user-configured Nvim location or
* arbitrary wrappers such as Windows WSL.
*
* Example: ['/usr/local/bin/nvim', '/opt/homebrew/bin/nvim']
* Example:
* ```
* cmds: [
* ['/opt/homebrew/bin/nvim'],
* ['C:/Windows/system32/wsl.exe', '-d', 'Ubuntu', 'nvim'],
* ],
* ```
*/
readonly cmds?: string[][];
/** @deprecated */
readonly paths?: string[];
/**
* (Optional) Additional directories to search for Nvim executables.
* These directories will be searched after checking `paths`
* These directories will be searched after checking `cmds`
* but before searching `$PATH` and other default locations.
* Useful for including non-standard installation directories.
*
Expand Down Expand Up @@ -144,48 +154,47 @@ function normalizePath(path: string): string {
}

function getPlatformSearchDirs(): Set<string> {
const paths = new Set<string>();
const dirs = new Set<string>();
const { PATH, USERPROFILE, LOCALAPPDATA, PROGRAMFILES, HOME } = process.env;

PATH?.split(delimiter).forEach(p => paths.add(normalizePath(p)));
PATH?.split(delimiter).forEach(p => dirs.add(normalizePath(p)));

// Add common Neovim installation paths not always in the system's PATH.
// Add common Nvim locations which may not be in the system $PATH.
if (windows) {
// Scoop common install location
// Scoop install location.
if (USERPROFILE) {
paths.add(normalizePath(`${USERPROFILE}/scoop/shims`));
dirs.add(normalizePath(`${USERPROFILE}/scoop/shims`));
}
paths.add(normalizePath('C:/ProgramData/scoop/shims'));
dirs.add(normalizePath('C:/ProgramData/scoop/shims'));

// Winget common install location
// See https://github.com/microsoft/winget-cli/blob/master/doc/specs/%23182%20-%20Support%20for%20installation%20of%20portable%20standalone%20apps.md
// Winget install location. https://github.com/microsoft/winget-cli/blob/master/doc/specs/%23182%20-%20Support%20for%20installation%20of%20portable%20standalone%20apps.md
if (LOCALAPPDATA) {
paths.add(normalizePath(`${LOCALAPPDATA}/Microsoft/WindowsApps`));
paths.add(normalizePath(`${LOCALAPPDATA}/Microsoft/WinGet/Packages`));
dirs.add(normalizePath(`${LOCALAPPDATA}/Microsoft/WindowsApps`));
dirs.add(normalizePath(`${LOCALAPPDATA}/Microsoft/WinGet/Packages`));
}
if (PROGRAMFILES) {
paths.add(normalizePath(`${PROGRAMFILES}/Neovim/bin`));
paths.add(normalizePath(`${PROGRAMFILES} (x86)/Neovim/bin`));
paths.add(normalizePath(`${PROGRAMFILES}/WinGet/Packages`));
paths.add(normalizePath(`${PROGRAMFILES} (x86)/WinGet/Packages`));
dirs.add(normalizePath(`${PROGRAMFILES}/Neovim/bin`));
dirs.add(normalizePath(`${PROGRAMFILES} (x86)/Neovim/bin`));
dirs.add(normalizePath(`${PROGRAMFILES}/WinGet/Packages`));
dirs.add(normalizePath(`${PROGRAMFILES} (x86)/WinGet/Packages`));
}
} else {
// Common paths for Unix-like systems
// Common locations for Unix-like systems.
[
'/usr/local/bin',
'/usr/bin',
'/opt/homebrew/bin',
'/home/linuxbrew/.linuxbrew/bin',
'/snap/nvim/current/usr/bin',
].forEach(p => paths.add(p));
].forEach(p => dirs.add(p));

if (HOME) {
paths.add(normalizePath(`${HOME}/bin`));
paths.add(normalizePath(`${HOME}/.linuxbrew/bin`));
dirs.add(normalizePath(`${HOME}/bin`));
dirs.add(normalizePath(`${HOME}/.linuxbrew/bin`));
}
}

return paths;
return dirs;
}

/**
Expand All @@ -194,64 +203,56 @@ function getPlatformSearchDirs(): Set<string> {
* @param opt.minVersion See {@link FindNvimOptions.minVersion}
* @param opt.orderBy See {@link FindNvimOptions.orderBy}
* @param opt.firstMatch See {@link FindNvimOptions.firstMatch}
* @param opt.paths See {@link FindNvimOptions.paths}
* @param opt.cmds See {@link FindNvimOptions.cmds}
* @param opt.dirs See {@link FindNvimOptions.dirs}
*/
export function findNvim(opt: FindNvimOptions = {}): Readonly<FindNvimResult> {
const platformDirs = getPlatformSearchDirs();
const nvimExecutable = windows ? 'nvim.exe' : 'nvim';
const normalizedPathsFromUser = (opt.paths ?? []).map(normalizePath);

const allPaths = new Set<string>([
...normalizedPathsFromUser,
...(opt.dirs ?? []).map(dir => normalizePath(join(dir, nvimExecutable))),
...[...platformDirs].map(dir => join(dir, nvimExecutable)),
]);
const userCmds = [...(opt.cmds ?? []), ...(opt.paths ?? []).map(p => [p])].map(
([arg0, ...args]) => [normalizePath(arg0), ...args]
);
// Unlike `cmds` (always tried, so failures are reported in `invalid`), skip dirs without Nvim.
const dirCmds = [...(opt.dirs ?? []), ...getPlatformSearchDirs()]
.map(dir => [normalizePath(join(dir, nvimExecutable))])
.filter(([nvimPath]) => existsSync(nvimPath));
// Dedupe, e.g. if a dir is in both $PATH and the platform defaults.
const allCmds = new Map(
[...userCmds, ...dirCmds].map(cmd => [JSON.stringify(cmd), cmd] as const)
);

const matches = new Array<NvimVersion>();
const invalid = new Array<NvimVersion>();
for (const nvimPath of allPaths) {
if (existsSync(nvimPath) || normalizedPathsFromUser.includes(nvimPath)) {
try {
accessSync(nvimPath, constants.X_OK);
// TODO: fallback to `echo 'print(vim.version())' | nvim -l -` if parsing --version fails.
const nvimVersionFull = execFileSync(nvimPath, ['--version']).toString();
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 ?? '0.0.0', nvimVersionMatch[1]) === 1
) {
invalid.push({
nvimVersion: nvimVersionMatch[1],
path: nvimPath,
buildType: buildTypeMatch[1],
luaJitVersion: luaJitVersionMatch[1],
});
} else {
matches.push({
nvimVersion: nvimVersionMatch[1],
path: nvimPath,
buildType: buildTypeMatch[1],
luaJitVersion: luaJitVersionMatch[1],
});

if (opt.firstMatch) {
return {
matches,
invalid,
} as const;
}
}
for (const cmd of allCmds.values()) {
const [arg0, ...args] = cmd;
try {
// TODO: fallback to `echo 'print(vim.version())' | nvim -l -` if parsing --version fails.
const nvimVersionFull = execFileSync(arg0, [...args, '--version']).toString();
const nvimVersionMatch = nvimVersionRegex.exec(nvimVersionFull);
const buildTypeMatch = buildTypeRegex.exec(nvimVersionFull);
const luaJitVersionMatch = luaJitVersionRegex.exec(nvimVersionFull);
if (!nvimVersionMatch || !buildTypeMatch || !luaJitVersionMatch) {
continue;
}
const found: NvimVersion = {
cmd,
path: arg0,
nvimVersion: nvimVersionMatch[1],
buildType: buildTypeMatch[1],
luaJitVersion: luaJitVersionMatch[1],
};
if (
'minVersion' in opt &&
compareVersions(opt.minVersion ?? '0.0.0', nvimVersionMatch[1]) === 1
) {
invalid.push(found);
} else {
matches.push(found);
if (opt.firstMatch) {
break;
}
} catch (e) {
invalid.push({
path: nvimPath,
error: e as Error,
});
}
} catch (e) {
invalid.push({ cmd, path: arg0, error: e as Error });
}
}

Expand Down