refactor: Rewrite functions into packages

This commit is contained in:
Jesse Wierzbinski 2024-03-07 19:34:50 -10:00
parent 847e679a10
commit 78f216092b
No known key found for this signature in database
21 changed files with 1426 additions and 70 deletions

View file

@ -0,0 +1,8 @@
export interface CliParameter {
name: string;
// If not positioned, the argument will need to be called with --name value instead of just value
positioned?: boolean;
// Whether the argument needs a value (requires positioned to be false)
needsValue?: boolean;
type: "string" | "number" | "boolean" | "array";
}

View file

@ -0,0 +1,203 @@
import type { CliParameter } from "./cli-builder.type";
export function startsWithArray(fullArray: any[], startArray: any[]) {
if (startArray.length > fullArray.length) {
return false;
}
return fullArray
.slice(0, startArray.length)
.every((value, index) => value === startArray[index]);
}
/**
* Builder for a CLI
* @param commands Array of commands to register
*/
export class CliBuilder {
constructor(public commands: CliCommand[] = []) {}
/**
* Add command to the CLI
* @throws Error if command already exists
* @param command Command to add
*/
registerCommand(command: CliCommand) {
if (this.checkIfCommandAlreadyExists(command)) {
throw new Error(
`Command category '${command.categories.join(" ")}' already exists`
);
}
this.commands.push(command);
}
/**
* Add multiple commands to the CLI
* @throws Error if command already exists
* @param commands Commands to add
*/
registerCommands(commands: CliCommand[]) {
const existingCommand = commands.find(command =>
this.checkIfCommandAlreadyExists(command)
);
if (existingCommand) {
throw new Error(
`Command category '${existingCommand.categories.join(" ")}' already exists`
);
}
this.commands.push(...commands);
}
/**
* Remove command from the CLI
* @param command Command to remove
*/
deregisterCommand(command: CliCommand) {
this.commands = this.commands.filter(
registeredCommand => registeredCommand !== command
);
}
/**
* Remove multiple commands from the CLI
* @param commands Commands to remove
*/
deregisterCommands(commands: CliCommand[]) {
this.commands = this.commands.filter(
registeredCommand => !commands.includes(registeredCommand)
);
}
checkIfCommandAlreadyExists(command: CliCommand) {
return this.commands.some(
registeredCommand =>
registeredCommand.categories.length ==
command.categories.length &&
registeredCommand.categories.every(
(category, index) => category === command.categories[index]
)
);
}
/**
* Get relevant args for the command (without executable or runtime)
* @param args Arguments passed to the CLI
*/
private getRelevantArgs(args: string[]) {
if (args[0].startsWith("./")) {
// Formatted like ./cli.ts [command]
return args.slice(1);
} else if (args[0].includes("bun")) {
// Formatted like bun cli.ts [command]
return args.slice(2);
} else {
return args;
}
}
/**
* Turn raw system args into a CLI command and run it
* @param args Args directly from process.argv
*/
processArgs(args: string[]) {
const revelantArgs = this.getRelevantArgs(args);
// Find revelant command
// Search for a command with as many categories matching args as possible
const matchingCommands = this.commands.filter(command =>
startsWithArray(revelantArgs, command.categories)
);
// Get command with largest category size
const command = matchingCommands.reduce((prev, current) =>
prev.categories.length > current.categories.length ? prev : current
);
const argsWithoutCategories = args.slice(command.categories.length - 1);
command.run(argsWithoutCategories);
}
}
/**
* A command that can be executed from the command line
* @param categories Example: `["user", "create"]` for the command `./cli user create --name John`
*/
export class CliCommand {
constructor(
public categories: string[],
public argTypes: CliParameter[],
private execute: (args: Record<string, any>) => void
) {}
/**
* Parses string array arguments into a full JavaScript object
* @param argsWithoutCategories
* @returns
*/
private parseArgs(argsWithoutCategories: string[]): Record<string, any> {
const parsedArgs: Record<string, any> = {};
let currentParameter: CliParameter | null = null;
for (let i = 0; i < argsWithoutCategories.length; i++) {
const arg = argsWithoutCategories[i];
if (arg.startsWith("--")) {
const argName = arg.substring(2);
currentParameter =
this.argTypes.find(argType => argType.name === argName) ||
null;
if (currentParameter && !currentParameter.needsValue) {
parsedArgs[argName] = true;
currentParameter = null;
} else if (currentParameter && currentParameter.needsValue) {
parsedArgs[argName] = this.castArgValue(
argsWithoutCategories[i + 1],
currentParameter.type
);
i++;
currentParameter = null;
}
} else if (currentParameter) {
parsedArgs[currentParameter.name] = this.castArgValue(
arg,
currentParameter.type
);
currentParameter = null;
} else {
const positionedArgType = this.argTypes.find(
argType => argType.positioned
);
if (positionedArgType) {
parsedArgs[positionedArgType.name] = this.castArgValue(
arg,
positionedArgType.type
);
}
}
}
return parsedArgs;
}
private castArgValue(value: string, type: CliParameter["type"]): any {
switch (type) {
case "string":
return value;
case "number":
return Number(value);
case "boolean":
return value === "true";
case "array":
return value.split(",");
default:
return value;
}
}
/**
* Runs the execute function with the parsed parameters as an argument
*/
run(argsWithoutCategories: string[]) {
const args = this.parseArgs(argsWithoutCategories);
this.execute(args);
}
}

View file

@ -0,0 +1,6 @@
{
"name": "arg-parser",
"version": "0.0.0",
"main": "index.ts",
"dependencies": {}
}

View file

@ -0,0 +1,217 @@
// FILEPATH: /home/jessew/Dev/lysand/packages/cli-parser/index.test.ts
import { CliCommand, CliBuilder, startsWithArray } from "..";
import { describe, beforeEach, it, expect, jest } from "bun:test";
describe("startsWithArray", () => {
it("should return true when fullArray starts with startArray", () => {
const fullArray = ["a", "b", "c", "d", "e"];
const startArray = ["a", "b", "c"];
expect(startsWithArray(fullArray, startArray)).toBe(true);
});
it("should return false when fullArray does not start with startArray", () => {
const fullArray = ["a", "b", "c", "d", "e"];
const startArray = ["b", "c", "d"];
expect(startsWithArray(fullArray, startArray)).toBe(false);
});
it("should return true when startArray is empty", () => {
const fullArray = ["a", "b", "c", "d", "e"];
const startArray: any[] = [];
expect(startsWithArray(fullArray, startArray)).toBe(true);
});
it("should return false when fullArray is shorter than startArray", () => {
const fullArray = ["a", "b", "c"];
const startArray = ["a", "b", "c", "d", "e"];
expect(startsWithArray(fullArray, startArray)).toBe(false);
});
});
describe("CliCommand", () => {
let cliCommand: CliCommand;
beforeEach(() => {
cliCommand = new CliCommand(
["category1", "category2"],
[
{ name: "arg1", type: "string", needsValue: true },
{ name: "arg2", type: "number", needsValue: true },
{ name: "arg3", type: "boolean", needsValue: false },
{ name: "arg4", type: "array", needsValue: true },
],
() => {
// Do nothing
}
);
});
it("should parse string arguments correctly", () => {
const args = cliCommand["parseArgs"]([
"--arg1",
"value1",
"--arg2",
"42",
"--arg3",
"--arg4",
"value1,value2",
]);
expect(args).toEqual({
arg1: "value1",
arg2: 42,
arg3: true,
arg4: ["value1", "value2"],
});
});
it("should cast argument values correctly", () => {
expect(cliCommand["castArgValue"]("42", "number")).toBe(42);
expect(cliCommand["castArgValue"]("true", "boolean")).toBe(true);
expect(cliCommand["castArgValue"]("value1,value2", "array")).toEqual([
"value1",
"value2",
]);
});
it("should run the execute function with the parsed parameters", () => {
const mockExecute = jest.fn();
cliCommand = new CliCommand(
["category1", "category2"],
[
{ name: "arg1", type: "string", needsValue: true },
{ name: "arg2", type: "number", needsValue: true },
{ name: "arg3", type: "boolean", needsValue: false },
{ name: "arg4", type: "array", needsValue: true },
],
mockExecute
);
cliCommand.run([
"--arg1",
"value1",
"--arg2",
"42",
"--arg3",
"--arg4",
"value1,value2",
]);
expect(mockExecute).toHaveBeenCalledWith({
arg1: "value1",
arg2: 42,
arg3: true,
arg4: ["value1", "value2"],
});
});
it("should work with a mix of positioned and non-positioned arguments", () => {
const mockExecute = jest.fn();
cliCommand = new CliCommand(
["category1", "category2"],
[
{ name: "arg1", type: "string", needsValue: true },
{ name: "arg2", type: "number", needsValue: true },
{ name: "arg3", type: "boolean", needsValue: false },
{ name: "arg4", type: "array", needsValue: true },
{
name: "arg5",
type: "string",
needsValue: true,
positioned: true,
},
],
mockExecute
);
cliCommand.run([
"--arg1",
"value1",
"--arg2",
"42",
"--arg3",
"--arg4",
"value1,value2",
"value5",
]);
expect(mockExecute).toHaveBeenCalledWith({
arg1: "value1",
arg2: 42,
arg3: true,
arg4: ["value1", "value2"],
arg5: "value5",
});
});
});
describe("CliBuilder", () => {
let cliBuilder: CliBuilder;
let mockCommand1: CliCommand;
let mockCommand2: CliCommand;
beforeEach(() => {
mockCommand1 = new CliCommand(["category1"], [], jest.fn());
mockCommand2 = new CliCommand(["category2"], [], jest.fn());
cliBuilder = new CliBuilder([mockCommand1]);
});
it("should register a command correctly", () => {
cliBuilder.registerCommand(mockCommand2);
expect(cliBuilder.commands).toContain(mockCommand2);
});
it("should register multiple commands correctly", () => {
const mockCommand3 = new CliCommand(["category3"], [], jest.fn());
cliBuilder.registerCommands([mockCommand2, mockCommand3]);
expect(cliBuilder.commands).toContain(mockCommand2);
expect(cliBuilder.commands).toContain(mockCommand3);
});
it("should error when adding duplicates", () => {
expect(() => {
cliBuilder.registerCommand(mockCommand1);
}).toThrow();
expect(() => {
cliBuilder.registerCommands([mockCommand1]);
}).toThrow();
});
it("should deregister a command correctly", () => {
cliBuilder.deregisterCommand(mockCommand1);
expect(cliBuilder.commands).not.toContain(mockCommand1);
});
it("should deregister multiple commands correctly", () => {
cliBuilder.registerCommand(mockCommand2);
cliBuilder.deregisterCommands([mockCommand1, mockCommand2]);
expect(cliBuilder.commands).not.toContain(mockCommand1);
expect(cliBuilder.commands).not.toContain(mockCommand2);
});
it("should process args correctly", () => {
const mockExecute = jest.fn();
const mockCommand = new CliCommand(
["category1", "sub1"],
[
{
name: "arg1",
type: "string",
needsValue: true,
positioned: false,
},
],
mockExecute
);
cliBuilder.registerCommand(mockCommand);
cliBuilder.processArgs([
"./cli.ts",
"category1",
"sub1",
"--arg1",
"value1",
]);
expect(mockExecute).toHaveBeenCalledWith({
arg1: "value1",
});
});
});