Skip to content

Commit

Permalink
fix: avoid option leak between subcommands
Browse files Browse the repository at this point in the history
  • Loading branch information
adbayb committed Mar 25, 2022
1 parent 1b548ac commit 7413c9a
Show file tree
Hide file tree
Showing 11 changed files with 318 additions and 273 deletions.
158 changes: 158 additions & 0 deletions src/api/command/command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { format } from "../message/helpers";
import { Metadata } from "../../types";
import {
CommandController,
createCommandController,
getCommandController,
getCommandDescriptionCollection,
} from "./controller";

export type CommandParameters = {
name: string;
description: string;
};

type InternalCommandParameters = CommandParameters & {
metadata: Metadata;
};

export const createCommand = ({
name,
description,
metadata,
}: InternalCommandParameters) => {
const { args, name: rootCommandName } = metadata;
const isRootCommand = name === rootCommandName;
const isActiveCommand = args.command === name;
const controller = createCommandController(name, description);
const rootController = getCommandController(rootCommandName);

setTimeout(() => {
// @note: By design, the root command instructions are always executed
// even with subcommands (to share options, messages...)
if (isRootCommand && !isActiveCommand) {
rootController.enable();
}

// @note: enable the current active command instructions:
if (isActiveCommand) {
// @note: setTimeout 0 allows to run activation logic in the next event loop iteration.
// It'll allow to make sure that the `metadata` is correctly filled with all commands
// metadata (especially to let the global help option to display all available commands):
const { options } = args;
const optionKeys = Object.keys(options);

if (
optionKeys.includes(OPTION_HELP_NAMES[0]) ||
optionKeys.includes(OPTION_HELP_NAMES[1])
) {
return showHelp(metadata, {
currentCommandName: name,
isRootCommand,
controller,
});
}

if (
optionKeys.includes(OPTION_VERSION_NAMES[0]) ||
optionKeys.includes(OPTION_VERSION_NAMES[1])
) {
return showVersion(metadata);
}

controller.enable();
}
}, 0);

return name;
};

const OPTION_HELP_NAMES = ["help", "h"] as const;
const OPTION_VERSION_NAMES = ["version", "v"] as const;

const showVersion = (metadata: Metadata) => {
console.info(metadata.version);
};

const showHelp = (
metadata: Metadata,
{
currentCommandName,
isRootCommand,
controller,
}: {
currentCommandName: string;
isRootCommand: boolean;
controller: CommandController;
}
) => {
const { name: programName } = metadata;
const commandMetadata = controller.getMetadata(programName);
// const rootCommandMetadata = controller.getMetadata();
const { options, description } = commandMetadata;
const commands = getCommandDescriptionCollection();
const optionKeys = Object.keys(commandMetadata.options);
const commandKeys = Object.keys(commands);
const hasOptions = optionKeys.length > 0;
const hasCommands = isRootCommand && commandKeys.length > 1;

printTitle("Usage");
print(
`${format(
`${programName}${
isRootCommand ? "" : ` ${String(currentCommandName)}`
}`,
{
color: "green",
}
)} ${hasCommands ? "<command> " : ""}${
hasOptions ? "[...options]" : ""
}`
);

if (description) {
printTitle("Description");
print(description);
}

const padding = [...commandKeys, ...optionKeys].reduce((padding, item) => {
return Math.max(padding, item.length);
}, 0);

if (hasCommands) {
printTitle("Commands");

for (const name of commandKeys) {
if (name === programName) continue;

const description = commands[name];

if (description) printLabelValue(name, description, padding);
}
}

if (hasOptions) {
printTitle("Options");

for (const key of optionKeys) {
printLabelValue(key, options[key] as string, padding);
}
}
};

const print = (...parameters: Parameters<typeof format>) =>
console.log(format(...parameters));

const printTitle = (message: string) =>
print(`\n${message}:`, {
color: "yellow",
modifier: ["bold", "underline", "uppercase"],
});

const printLabelValue = (label: string, value: string, padding: number) =>
print(
` ${format(label.padEnd(padding + 1, " "), {
color: "green",
})} ${value}`
);
127 changes: 127 additions & 0 deletions src/api/command/controller/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import {
CommandName,
Context,
EmptyObject,
ObjectLikeConstraint,
} from "../../../types";
import { createQueue } from "./queue";

export type CommandController<
Values extends ObjectLikeConstraint = EmptyObject
> = {
addValue<Key extends keyof Values>(key: Key, value: Values[Key]): void;
addOptionDescription(key: string, description: string): void;
addInstruction(instruction: Instruction): void;
/**
* Iterate over stored instructions and execute them
*/
enable(): Promise<void>;
getContext(rootCommandName: CommandName): Context<Values>;
getMetadata(rootCommandName: CommandName): CommandMetadata;
};

export const getCommandController = <Values extends ObjectLikeConstraint>(
name: CommandName
) => {
const controller = commandControllerCollection[name];

if (!controller) {
throw new Error(
`No controller has been set for the \`${name}\` command.\nHave you run the \`termost\` constructor?`
);
}

return controller as CommandController<Values>;
};

export const createCommandController = <Values extends ObjectLikeConstraint>(
name: CommandName,
description: CommandMetadata["description"]
) => {
const instructions = createQueue<Instruction>();
const context = {
command: name,
values: {},
} as Context<Values>;
const metadata: CommandMetadata = {
description,
options: {
"-h, --help": "Display the help center",
"-v, --version": "Print the version",
},
};

const controller: CommandController<Values> = {
addOptionDescription(key, description) {
metadata.options[key] = description;
},
addValue(key, value) {
context.values[key] = value;
},
addInstruction(instruction) {
instructions.enqueue(instruction);
},
async enable() {
while (!instructions.isEmpty()) {
const task = instructions.dequeue();

if (task) {
await task();
}
}
},
getContext(rootCommandName) {
// @note: By design, global values are accessible to subcommands
// Consequently, root command values are merged with the current command ones:
if (name !== rootCommandName) {
const rootController = getCommandController(rootCommandName);
const globalContext =
rootController.getContext(rootCommandName);

context.values = {
...globalContext.values,
...context.values,
};
}

return context;
},
getMetadata(rootCommandName) {
if (name !== rootCommandName) {
const globalMetadata =
getCommandController(rootCommandName).getMetadata(
rootCommandName
);

metadata.options = {
...globalMetadata.options,
...metadata.options,
};
}

return metadata;
},
};

commandDescriptionCollection[name] = description;
commandControllerCollection[name] = controller;

return controller;
};

export const getCommandDescriptionCollection = () => {
return commandDescriptionCollection;
};

type CommandMetadata = {
description: string;
options: Record<string, string>;
};

type Instruction = () => Promise<void>;

const commandControllerCollection: Record<CommandName, CommandController> = {};
const commandDescriptionCollection: Record<
CommandName,
CommandMetadata["description"]
> = {};
File renamed without changes.
Loading

0 comments on commit 7413c9a

Please sign in to comment.