Last active
October 10, 2023 00:49
-
-
Save YannickFricke/615c30cf2953dd408fca2f0ec36d1fc5 to your computer and use it in GitHub Desktop.
TypeScript Command Manager
This file contains hidden or 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
/** | |
* 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>; | |
} |
This file contains hidden or 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
/** | |
* 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}'`); | |
} | |
} |
This file contains hidden or 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
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']); | |
}); | |
}); | |
}); |
This file contains hidden or 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
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