-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
fix: avoid option leak between subcommands
- Loading branch information
Showing
11 changed files
with
318 additions
and
273 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}` | ||
); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.
Oops, something went wrong.