Skip to content

Commit

Permalink
feat: cross-shell completion
Browse files Browse the repository at this point in the history
* Fixed cross-shell completion issue:
  - Before this change, the completer would rely on the `SHELL` environment
    variable provided by the first login shell.
    This can be unreliable. For example: Launching another
    bash from zsh does not change the `SHELL` environment variable which
    was already set to `/bin/zsh`.
  - After this change, the completion scripts will explicitly set the
    `SHELL` environment variable to whatever shell it supposed to serve

* Replace `systemShell` with `getShellFromEnv` and require the user to
  pass `env` explicitly.
  • Loading branch information
KSXGitHub committed Feb 2, 2024
1 parent c34b51c commit cda11df
Show file tree
Hide file tree
Showing 9 changed files with 62 additions and 39 deletions.
31 changes: 29 additions & 2 deletions lib/index.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
const { SUPPORTED_SHELLS, SHELL_LOCATIONS } = require('./constants');
const prompt = require('./prompt');
const installer = require('./installer');
const { tabtabDebug, systemShell } = require('./utils');
const { tabtabDebug } = require('./utils');

/**
* @typedef {import('./constants').SupportedShell} SupportedShell
Expand All @@ -18,6 +18,33 @@ const debug = tabtabDebug('tabtab');
*/
const isShellSupported = shell => (/** @type {ReadonlyArray.<String>} */ (SUPPORTED_SHELLS)).includes(shell);

/**
* This function is to be used inside a completer.
*
* An environment variable named `SHELL` shall be explicitly set
* by the completion script when it invokes the completer.
*
* The value of `SHELL` is expected to be one of the supported shells.
* If this expectation isn't met, it will result in an error.
*
* @example
* const shell = getShellFromEnv(process.env)
*
* @param {Readonly.<Record.<String, String | undefined>>} env - Env objects that may contain `SHELL`, usually `process.env`.
* @returns {SupportedShell}
*/
const getShellFromEnv = env => {
const shell = env.SHELL;
if (!shell) {
throw new TypeError('SHELL cannot be empty');
}
if (!isShellSupported(shell)) {
const supportedValues = SUPPORTED_SHELLS.map(x => `'${x}'`).join(', ');
throw new TypeError(`SHELL was set to an invalid value (${shell}). Supported values are: ${supportedValues}`);
}
return shell;
}

/**
* Construct a completion script.
* @param {Object} options - Options object.
Expand Down Expand Up @@ -253,7 +280,7 @@ const logFiles = () => {

module.exports = {
SUPPORTED_SHELLS,
shell: systemShell,
getShellFromEnv,
isShellSupported,
getCompletionScript,
install,
Expand Down
2 changes: 1 addition & 1 deletion lib/installer.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ const fs = require('fs');
const path = require('path');
const untildify = require('untildify');
const { promisify } = require('util');
const { tabtabDebug, systemShell, exists } = require('./utils');
const { tabtabDebug, exists } = require('./utils');
const { SUPPORTED_SHELLS } = require('./constants')

const debug = tabtabDebug('tabtab:installer');
Expand Down
1 change: 1 addition & 0 deletions lib/templates/completion.bash
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ if type complete &>/dev/null; then
IFS=$'\n' COMPREPLY=($(COMP_CWORD="$cword" \
COMP_LINE="$COMP_LINE" \
COMP_POINT="$COMP_POINT" \
SHELL=bash \
{completer} completion-server -- "${words[@]}" \
2>/dev/null)) || return $?
IFS="$si"
Expand Down
2 changes: 1 addition & 1 deletion lib/templates/completion.fish
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ function _{pkgname}_completion
set cursor (commandline -C)
set words (count $cmd)

set completions (eval env DEBUG=\"" \"" COMP_CWORD=\""$words\"" COMP_LINE=\""$cmd \"" COMP_POINT=\""$cursor\"" {completer} completion-server -- $cmd)
set completions (eval env DEBUG=\"" \"" COMP_CWORD=\""$words\"" COMP_LINE=\""$cmd \"" COMP_POINT=\""$cursor\"" SHELL=fish {completer} completion-server -- $cmd)

if [ "$completions" = "__tabtab_complete_files__" ]
set -l matches (commandline -ct)*
Expand Down
2 changes: 1 addition & 1 deletion lib/templates/completion.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ if type compdef &>/dev/null; then
local reply
local si=$IFS

IFS=$'\n' reply=($(COMP_CWORD="$((CURRENT-1))" COMP_LINE="$BUFFER" COMP_POINT="$CURSOR" {completer} completion-server -- "${words[@]}"))
IFS=$'\n' reply=($(COMP_CWORD="$((CURRENT-1))" COMP_LINE="$BUFFER" COMP_POINT="$CURSOR" SHELL=zsh {completer} completion-server -- "${words[@]}"))
IFS=$si

if [ "$reply" = "__tabtab_complete_files__" ]; then
Expand Down
2 changes: 0 additions & 2 deletions lib/utils/index.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
const tabtabDebug = require('./tabtabDebug');
const systemShell = require('./systemShell');
const exists = require('./exists');

module.exports = {
systemShell,
tabtabDebug,
exists
};
11 changes: 0 additions & 11 deletions lib/utils/systemShell.js

This file was deleted.

21 changes: 0 additions & 21 deletions test/basic.js

This file was deleted.

29 changes: 29 additions & 0 deletions test/getShellFromEnv.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
const assert = require('assert');
const { SUPPORTED_SHELLS, getShellFromEnv } = require('..');

describe('getShellFromEnv', () => {
it('errors when env lacks SHELL', () => {
assert.throws(
() => getShellFromEnv({}),
{
message: 'SHELL cannot be empty',
},
)
});

it('errors on unsupported shells', () => {
assert.throws(
() => getShellFromEnv({ SHELL: 'unknown' }),
{
message: "SHELL was set to an invalid value (unknown). Supported values are: 'bash', 'fish', 'pwsh', 'zsh'",
},
);
})

it('returns supported shells', () => {
assert.deepStrictEqual(
SUPPORTED_SHELLS.map(SHELL => getShellFromEnv({ SHELL })),
SUPPORTED_SHELLS,
);
});
});

0 comments on commit cda11df

Please sign in to comment.