@compas/cli
Version:
CLI containing utilities and simple script runner
445 lines (386 loc) • 11.8 kB
JavaScript
import { appendFileSync } from "node:fs";
import { writeFile } from "node:fs/promises";
import { AppError, environment, isNil } from "@compas/stdlib";
import { cliParserGetKnownFlags, cliParserSplitArgs } from "../parser.js";
/**
* @type {import("../../generated/common/types.js").CliCommandDefinitionInput}
*/
export const cliDefinition = {
name: "completions",
flags: [
{
name: "getCompletions",
rawName: "--get-completions",
value: {
specification: "booleanOrString",
},
modifiers: {
isInternal: true,
},
description:
"This flag is used in the completions executor with the current user input so we can determine completions dynamically.",
},
],
shortDescription: "Configure shell auto-complete for this CLI.",
executor: async (...args) => {
try {
return await cliExecutor(...args);
} catch (e) {
if (environment.COMPAS_DEBUG_COMPLETIONS === "true") {
await writeFile(
"./error.json",
JSON.stringify(
{
now: new Date().toISOString(),
error: AppError.format(e),
},
null,
2,
),
);
}
throw e;
}
},
};
/**
* Auto completes commands, flags, flag values
*
* @param {import("@compas/stdlib").Logger} logger
* @param {import("../../cli/types.js").CliExecutorState} state
* @returns {Promise<import("../../cli/types.js").CliResult>}
*/
export async function cliExecutor(logger, state) {
const isZSH =
environment.SHELL?.includes("zsh") ??
environment.ZSH_NAME?.includes("zsh") ??
false;
if (!isZSH) {
logger.info("This CLI only supports completions for ZSH.");
return {
exitStatus: "failed",
};
}
if (isNil(state.flags.getCompletions)) {
// TODO: Disable JSON logger?
printCompletionScripts(state.cli);
return {
exitStatus: "passed",
};
}
// We use \t as the separator when passing the current command to be auto-completed to
// '--get-completions'
/** @type {Array<string>} */
// @ts-ignore
const inputCommand = state.flags.getCompletions.split("\t");
const { commandCompletions, flagCompletions } =
await completionsGetCompletions(state.cli, inputCommand);
completionsPrintForZsh(commandCompletions, flagCompletions);
return {
exitStatus: "passed",
};
}
/**
* Resolve completions for the cli and input array
*
* @param {import("../types.js").CliResolved} cli
* @param {Array<string>} input
* @returns {Promise<{
* commandCompletions: Array<import("../../generated/common/types.js").CliCompletion>,
* flagCompletions: Array<import("../../generated/common/types.js").CliCompletion>,
* }>}
*/
export async function completionsGetCompletions(cli, input) {
/** @type {Array<import("../../generated/common/types.js").CliCompletion>} */
let commandCompletions = [];
/** @type {Array<import("../../generated/common/types.js").CliCompletion>} */
let flagCompletions = [];
const command = completionsMatchCommand(cli, input);
if (isNil(command)) {
// We don't have any match, so we can't even give auto-complete results.
return {
commandCompletions,
flagCompletions,
};
}
commandCompletions = await completionsDetermineCommandCompletions(
command,
input,
);
const availableFlags = cliParserGetKnownFlags(command);
const flagsResult = await completionGetFlagCompletions(
availableFlags,
input,
{
forceFlagCompletions: !command?.modifiers.isCosmetic,
},
);
if (flagsResult.shouldSkipCommands) {
commandCompletions = [];
}
flagCompletions = flagsResult.flagCompletions;
return {
commandCompletions,
flagCompletions,
};
}
/**
* @param {Array<import("../../generated/common/types.d.ts").CliCompletion>} commandCompletions
* @param {Array<import("../../generated/common/types.d.ts").CliCompletion>} flagCompletions
*/
function completionsPrintForZsh(commandCompletions, flagCompletions) {
let completionResult = `local commands\ncommands=(`;
let postResult = ``;
/**
*
* @param {import("../../generated/common/types.d.ts").CliCompletion} completion
*/
const addCompletion = (completion) => {
// @ts-ignore
const escapedDescription = completion?.description?.replaceAll(
/(['"`])/g,
(it) => `\\${it}`,
);
switch (completion.type) {
case "directory":
postResult += `\n_files -/`;
break;
case "file":
postResult += `\n_files`;
break;
case "completion":
completionResult += `"${completion.name}${
completion.description ? `:${escapedDescription}` : ""
}" `;
break;
case "value": {
const options = {
boolean: "true, false",
number: "number",
string: "string ",
booleanOrString: "true, false or a string",
};
postResult += `\n_message -r 'Input: ${options[completion.specification]}${
completion.description ? `\nDescription: ${escapedDescription}` : ""
}'`;
break;
}
}
};
for (const cmp of commandCompletions) {
addCompletion(cmp);
}
for (const cmp of flagCompletions) {
addCompletion(cmp);
}
let result = completionResult;
if (result.endsWith("commands=(")) {
result = "";
} else {
result += `)\n_describe 'values' commands`;
}
result += postResult;
if (environment.COMPAS_DEBUG_COMPLETIONS === "true") {
appendFileSync(
"./compas-debug-completions.txt",
`\n${new Date().toISOString()}\n${result}\n`,
);
}
// eslint-disable-next-line no-console
console.log(result);
}
/**
* Match the command based on the input.
* When an invalid match is encountered, we abort any matching.
* If an invalid match is the last 'command' input like 'compas foo<tab>' we can return
* all valid sub commands of 'compas <tab>'.
*
* @param {import("../../cli/types.js").CliResolved} cli
* @param {Array<string>} input
* @returns {import("../../cli/types.js").CliResolved|undefined|undefined}
*/
function completionsMatchCommand(cli, input) {
let command = cli;
const { commandArgs, flagArgs } = cliParserSplitArgs(input);
// commandArgs[0] is always cli.name, so skip that match
// If flags are passed, we expect that the command is fully correct, else we expect to
// generate completions for the last commandArgs item.
for (
let i = 1;
i < commandArgs.length - (flagArgs.length === 0 ? 1 : 0);
++i
) {
const currentInput = commandArgs[i];
const matchedCommand = command.subCommands.find(
(it) => it.name === currentInput,
);
const dynamicCommand = command.subCommands.find(
(it) => it.modifiers.isDynamic,
);
if (matchedCommand) {
command = matchedCommand;
} else if (dynamicCommand) {
command = dynamicCommand;
} else if (i !== commandArgs.length - 1) {
// @ts-ignore
command = undefined;
break;
}
}
return command;
}
/**
* Add sub commands; but don't be too greedy;
* - We don't support flags in between commands, so if flags are found, skip sub
* commands.
* - We don't support commands ending in 'isCosmetic' commands, so force sub commands
* - We expect commands to be strings containing only 'a-z' and dashes (-), so if not
* it is a dynamic value or flag.
*
* @param {import("../types.js").CliResolved} command
* @param {Array<string>} input
* @returns {Promise<Array<import("../../generated/common/types.d.ts").CliCompletion>>}
*/
async function completionsDetermineCommandCompletions(command, input) {
/** @type {Array<import("../../generated/common/types.js").CliCompletion>} */
const completions = [];
const { flagArgs } = cliParserSplitArgs(input);
if (command?.modifiers.isCosmetic || flagArgs.length === 0) {
for (const subCommand of command.subCommands) {
if (subCommand.modifiers.isDynamic) {
// Dynamic sub commands always have some form of completions defined, so
// integrate those.
completions.push(
...((await subCommand?.dynamicValue?.completions?.())?.completions ??
[]),
);
continue;
}
completions.push({
type: "completion",
name: subCommand.name,
description: subCommand.shortDescription,
});
}
}
return completions;
}
/**
*
* @param {Map<string, import("../../generated/common/types.d.ts").CliFlagDefinition>} availableFlags
* @param {Array<string>} input
* @param {{ forceFlagCompletions: boolean }} options
* @returns {Promise<{
* shouldSkipCommands: boolean,
* flagCompletions: Array<import("../../generated/common/types.d.ts").CliCompletion>,
* }>}
*/
async function completionGetFlagCompletions(availableFlags, input, options) {
const { commandArgs, flagArgs } = cliParserSplitArgs(input);
if (
flagArgs.length === 0 &&
commandArgs.at(-1) !== "-" &&
!options.forceFlagCompletions
) {
return { shouldSkipCommands: false, flagCompletions: [] };
}
availableFlags.delete("-h");
const lastItem = flagArgs.at(-1);
const oneToLastItem = flagArgs.at(-2);
if (lastItem?.includes("=") && availableFlags.has(lastItem.split("=")[0])) {
const [flagName] = lastItem.split("=");
const definition = availableFlags.get(flagName);
return {
shouldSkipCommands: true,
// @ts-ignore
flagCompletions: (
await definition?.value?.completions?.()
)?.completions.map((it) => {
if (it.type === "completion" && it.name) {
it.name = `${flagName}=${it.name}`;
}
return it;
}),
};
}
/** @type {Array<import("../../generated/common/types.js").CliCompletion>} */
let oneToLastCompletions = [];
if (availableFlags.has(oneToLastItem ?? "")) {
oneToLastCompletions =
(await availableFlags.get(oneToLastItem ?? "")?.value?.completions?.())
?.completions ?? [];
}
if (
oneToLastCompletions.length > 0 &&
!(
oneToLastCompletions.length === 1 &&
oneToLastCompletions[0].type === "value" &&
["boolean", "booleanOrString"].includes(
oneToLastCompletions[0].specification,
)
)
) {
return {
shouldSkipCommands: true,
flagCompletions: oneToLastCompletions,
};
}
for (let i = 0; i < flagArgs.length - 1; ++i) {
let value = flagArgs[i];
if (value.includes("=")) {
value = value.split("=")[0];
}
if (
availableFlags.has(value) &&
!availableFlags.get(value ?? "")?.modifiers.isRepeatable
) {
availableFlags.delete(value);
}
}
return {
shouldSkipCommands: false,
// @ts-ignore
flagCompletions: [...availableFlags.keys()]
.map((it) => ({
type: "completion",
name: it,
description: availableFlags.get(it ?? "")?.description,
}))
.concat(
// @ts-ignore
oneToLastCompletions,
),
};
}
/**
*
* @param {import("../../cli/types.js").CliResolved} cli
*/
function printCompletionScripts(cli) {
// ZSH
// eslint-disable-next-line no-console
console.log(`#compdef ${cli.name}
###-begin-${cli.name}-completions-###
#
# Compas command completion script
#
# Installation: ${cli.name} completions >> ~/.zshrc
# or ${cli.name} completions >> ~/.zsh_profile on OSX.
#
_${cli.name}_compas_completions()
{
local args
local si=$IFS
IFS=$'\\t'
args=$words[*]
IFS=$'\\n'
COMP_CWORD="$((CURRENT-1))"
COMP_LINE="$BUFFER"
COMP_POINT="$CURSOR"
eval "$(${cli.name} completions --get-completions "$args")"
IFS=$si
}
compdef _${cli.name}_compas_completions ${cli.name}
###-end-${cli.name}-completions-###
`);
}