Skip to content

Instantly share code, notes, and snippets.

@YannickFricke
Last active October 10, 2023 00:49
Show Gist options
  • Save YannickFricke/615c30cf2953dd408fca2f0ec36d1fc5 to your computer and use it in GitHub Desktop.
Save YannickFricke/615c30cf2953dd408fca2f0ec36d1fc5 to your computer and use it in GitHub Desktop.
TypeScript Command Manager
/**
* Defines a basic command
*
* @export
* @interface Command
*/
export interface Command {
/**
* The name of the command
*
* @type {string}
* @memberof Command
*/
name: string;
/**
* The aliases of the command
*
* @type {string[]}
* @memberof Command
*/
aliases: string[];
/**
* The subcommands of the command
*
* @type {Command[]}
* @memberof Command
*/
subCommands: Command[];
/**
* Executes the current command
*
* @memberof Command
*/
execute: (args: string[]) => Promise<void>;
}
/**
* An error which indicates that a command could not be found by the CommandManager
*
* @export
* @class CommandNotFoundError
* @extends {Error}
*/
export class CommandNotFoundError extends Error {
constructor(commandName: string) {
super(`Could not find command with name '${commandName}'`);
}
}
import { CommandManager } from '../CommandManager';
import { Command } from '../Command';
describe('CommandManager', () => {
let commandManager: CommandManager;
const setCommand = {
name: 'set',
aliases: ['s'],
subCommands: [],
execute: jest.fn(),
};
const removeCommand = {
name: 'remove',
aliases: [],
subCommands: [],
execute: jest.fn(),
};
const adminCommand: Command = {
name: 'admin',
aliases: ['ad'],
subCommands: [setCommand, removeCommand],
execute: jest.fn(),
};
beforeEach(() => {
commandManager = new CommandManager();
});
it('should be instantiable', () => {
expect(commandManager).toBeDefined();
});
it('should register a command', () => {
expect(commandManager.getCommands()).toHaveLength(0);
commandManager.registerCommand(adminCommand);
expect(commandManager.getCommands()).toHaveLength(1);
});
describe('Command', () => {
it('should execute a command', async () => {
commandManager.registerCommand(adminCommand);
await commandManager.executeCommand('admin', []);
expect(adminCommand.execute).toBeCalled();
});
it('should execute a command with an alias', async () => {
commandManager.registerCommand(adminCommand);
await commandManager.executeCommand('ad', []);
expect(adminCommand.execute).toBeCalled();
});
it('should execute a command with no arguments', async () => {
commandManager.registerCommand(adminCommand);
await commandManager.executeCommand('admin', []);
expect(adminCommand.execute).toBeCalled();
expect(adminCommand.execute).toBeCalledWith([]);
});
it('should execute a command with the correct arguments', async () => {
commandManager.registerCommand(adminCommand);
await commandManager.executeCommand('admin', ['lol']);
expect(adminCommand.execute).toBeCalled();
expect(adminCommand.execute).toBeCalledWith(['lol']);
});
});
describe('Subcommand', () => {
it('should execute a subcommand', async () => {
commandManager.registerCommand(adminCommand);
await commandManager.executeCommand('admin', ['set', '123']);
expect(setCommand.execute).toBeCalled();
expect(setCommand.execute).toBeCalledWith(['123']);
expect(removeCommand.execute).not.toBeCalled();
});
it('should execute a subcommand with an alias', async () => {
commandManager.registerCommand(adminCommand);
await commandManager.executeCommand('admin', ['s', '123']);
expect(setCommand.execute).toBeCalled();
expect(setCommand.execute).toBeCalledWith(['123']);
expect(removeCommand.execute).not.toBeCalled();
});
it('should execute a subcommand with no arguments', async () => {
commandManager.registerCommand(adminCommand);
await commandManager.executeCommand('admin', ['set']);
expect(setCommand.execute).toBeCalled();
expect(setCommand.execute).toBeCalledWith([]);
});
it('should execute a command with the correct arguments', async () => {
commandManager.registerCommand(adminCommand);
await commandManager.executeCommand('admin', ['set', 'lol']);
expect(setCommand.execute).toBeCalled();
expect(setCommand.execute).toBeCalledWith(['lol']);
});
});
});
import { Command } from './Command';
import { CommandNotFoundError } from './CommandManager.Errors';
/**
* The CommandManager can find and execute commands based on the given process arguments
*
* @export
* @class CommandManager
*/
export class CommandManager {
/**
* All registered top-level commands
*
* @private
* @type {Command[]}
* @memberof CommandManager
*/
private commands: Command[];
/**
* Creates an instance of CommandManager
* @memberof CommandManager
*/
constructor() {
this.commands = [];
}
/**
* Gets all top-level commands
*
* @return {Command[]}
* @memberof CommandManager
*/
getCommands(): Command[] {
return this.commands;
}
/**
* Registers a new top-level command
*
* @param {Command} command The command to register
* @memberof CommandManager
*/
registerCommand(command: Command) {
if (this.isCommandRegistered(command)) {
throw `The command ${command.name} is already registered`;
}
this.commands.push(command);
}
/**
* The "main" function of the CommandManager.
*
* It is looking if a (sub-)command can be found based on the given `commandName` and it's arguments.
*
* @param {string} commandName The name of the top-level command to search for
* @param {string[]} [commandArguments=[]] The remaining arguments which should be passed to the found command
* @memberof CommandManager
* @throws {CommandNotFoundError} When no top-level can be found by the given `commandName`
*/
async executeCommand(commandName: string, commandArguments: string[] = []) {
const foundCommand = this.commands.find(
(registeredCommand: Command) => {
return (
registeredCommand.name === commandName ||
registeredCommand.aliases.includes(commandName)
);
},
);
if (foundCommand === undefined) {
throw new CommandNotFoundError(commandName);
}
const subCommand = this.findSubCommand(foundCommand, commandArguments);
let commandToExecute: Command;
if (subCommand === undefined) {
commandToExecute = foundCommand;
} else {
commandToExecute = subCommand;
}
await commandToExecute.execute(commandArguments);
}
/**
* Finds a subcommand by the given arguments.
*
* To do so we take the first argument and check all the
* subcommands of the given command if they include a
* command with the name or alias.
*
* @private
* @param {Command} currentCommand The actual found command. This doesn't have to be a top-level command tho.
* @param {string[]} commandArguments The remaining command line arguments
* @return {(Command | undefined)} The found subcommand when one of the subcommand matches by it's name or aliases. Otherwise undefined.
* @memberof CommandManager
*/
private findSubCommand(
currentCommand: Command,
commandArguments: string[],
): Command | undefined {
const subCommandName = commandArguments[0];
const foundSubCommand = currentCommand.subCommands.find(
(subCommand) => {
return (
subCommand.name === subCommandName ||
subCommand.aliases.includes(subCommandName)
);
},
);
if (foundSubCommand === undefined) {
return undefined;
}
commandArguments.shift();
const subSubCommand = this.findSubCommand(
foundSubCommand,
commandArguments.slice(1),
);
return subSubCommand === undefined ? foundSubCommand : subSubCommand;
}
/**
* Checks if a top-level command is already registered.
*
* To do so it checks if the name is already taken by another command
* or the name of the command appears in the aliases of the already
* registered commands.
*
* @private
* @param {Command} command The command to check
* @return {boolean} True when the name is already taken or appears in the aliases of another command. False otherwise.
* @memberof CommandManager
*/
private isCommandRegistered(command: Command): boolean {
return this.commands.some((registeredCommand) => {
return (
registeredCommand.name === command.name ||
registeredCommand.aliases.includes(command.name)
);
});
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment