Skip to content

Commit

Permalink
feat: add error handler, fix minor errors
Browse files Browse the repository at this point in the history
  • Loading branch information
mdbetancourt committed Nov 25, 2021
1 parent d5da137 commit 9556d86
Show file tree
Hide file tree
Showing 5 changed files with 161 additions and 56 deletions.
5 changes: 3 additions & 2 deletions craft.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
/* eslint-disable @typescript-eslint/no-unsafe-call */
// @ts-ignore
import { defineConfig, libraryPreset } from '@kraftr/build/craft';

import path from 'path';

export default defineConfig({
entries: ['./src/index.ts'],
plugins: [
Expand Down
141 changes: 92 additions & 49 deletions src/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
ZodTypeAny,
ZodUnknown
} from 'zod';
import { errorHandler } from './error-handler';
import { Letter } from './utils';

export class Argument<ZodT extends ZodTypeAny = ZodTypeAny> {
Expand Down Expand Up @@ -65,6 +66,7 @@ function dashCase(value: string) {

export class Command {
public hasCommand = false;
public readonly commands: Record<string, Command> = {};

constructor(public readonly name: string) {}
_arguments: Argument[] = [
Expand All @@ -76,19 +78,19 @@ export class Command {
})
];

schema: {
private handler = errorHandler;
// eslint-disable-next-line @typescript-eslint/no-empty-function
private _executor: void | (() => void) = () => {};
private schema: {
positionals?: ZodArray<ZodTypeAny> | ZodTuple;
named?: ZodObject<ZodRawShape>;
} = {};

commands: Record<string, Command> = {};
#positionals: Argument[] = [];
#named: Record<string, Argument> = {};

positionalCount = 0;
parseOptions: ParseOptions = { stopAtFirstUnknown: true };
// eslint-disable-next-line @typescript-eslint/no-empty-function
private _executor: void | (() => void) = () => {};

command(name: string, collector: (cmd: Command) => Action | void): this {
const cmd = new Command(name);
Expand Down Expand Up @@ -137,7 +139,7 @@ export class Command {
named<T extends Record<string, ZodTypeAny>>(
struct: T
): {
[key in keyof T]: Argument<Infer<T[key]>>;
[key in keyof T]: Argument<T[key]>;
};
named(args: ZodRawShape | ZodTypeAny): Record<string, Argument<ZodTypeAny>> {
if (args instanceof ZodType) {
Expand Down Expand Up @@ -184,13 +186,14 @@ export class Command {
}

parse(args: string[] = process.argv) {
// const [command, ...arg] = args;
// this._positionals['dirs'].value = arg[0];
let splitIndex = args.findIndex((arg) => arg in this.commands);
if (splitIndex === -1) {
splitIndex = args.length;
const splitIndex = args.findIndex((arg) => arg in this.commands);
const hasSubCommand = splitIndex !== -1;

let subCommandArgs: string[] = [];

if (hasSubCommand) {
subCommandArgs = args.splice(splitIndex + 1);
}
const noCommandArgs = args.splice(splitIndex + 1);

const { _unknown, ...params } = commandLineArgs(
this._arguments.map((arg) => arg.definition),
Expand All @@ -202,70 +205,110 @@ export class Command {
);

if (_unknown && _unknown.length > 0) {
throw new Error(`Unknown command found ${_unknown[0]}`);
this.handler([
{
code: 'unknown-command',
command: _unknown[0].replace(/^(--)|(-)/, ''),
availables: Object.keys(this.commands).concat(Object.keys(this.#named))
}
]);
process.exit(1);
}

const values = object({
positionals: string().array()
})
.and(record(unknown()))
.safeParse(params);

let commandName: string | undefined;
let subCommand: string | undefined;

if (values.success) {
commandName = noCommandArgs.length > 0 ? values.data.positionals.pop() : undefined;
subCommand = hasSubCommand ? values.data.positionals.pop() : undefined;

const { positionals, ...named } = values.data;
const result = { positionals, named };

const schema = object(
this.schema as {
positionals: ZodArray<ZodType<unknown>>;
named: ZodObject<Record<string, ZodUnknown>>;
}
).parse(result);
this.#positionals.forEach((arg, index) => {
arg.value = schema.positionals[index];
});
).safeParse(result);

Object.keys(this.#named).forEach((key) => {
this.#named[key].value = schema.named[key];
});
}
if (schema.success) {
this.#positionals.forEach((arg, index) => {
arg.value = schema.data.positionals[index];
});

if (commandName) {
const command = this.commands[commandName];
command.parse(noCommandArgs);
Object.keys(this.#named).forEach((key) => {
this.#named[key].value = schema.data.named[key];
});
} else {
this.handler(schema.error.issues);
process.exit(0);
}
}
if (this._executor) {

if (subCommand) {
const command = this.commands[subCommand];
command.parse(subCommandArgs);
} else if (this._executor) {
this._executor();
}
}

flags(): Record<string, Argument<ZodBoolean>> {
return new Proxy(
{},
{
get: (_, prop: string) => {
const boolType = boolean().default(false);
const arg = new Argument(boolType, {
name: prop,
type: Boolean
});
this.#named[prop] = arg;
this._arguments.push(arg);

if (!this.schema.named) {
this.schema.named = object({
[prop]: boolType
});
} else {
this.schema.named = this.schema.named.extend({
[prop]: boolType
flags(): Record<string, Argument<ZodBoolean>>;
flags<T extends Record<string, ZodBoolean>>(
struct: T
): {
[key in keyof T]: Argument<T[key]>;
};
flags(args?: Record<string, ZodBoolean>): Record<string, Argument<ZodBoolean>> {
if (!args) {
return new Proxy(
{},
{
get: (_, prop: string) => {
const boolType = boolean().default(false);
const arg = new Argument(boolType, {
name: prop,
type: Boolean
});
}
this.#named[prop] = arg;
this._arguments.push(arg);

return arg;
if (!this.schema.named) {
this.schema.named = object({
[prop]: boolType
});
} else {
this.schema.named = this.schema.named.extend({
[prop]: boolType
});
}

return arg;
}
}
}
);
}
if (!this.schema.named) {
this.schema.named = object(args);
} else {
this.schema.named = this.schema.named.extend(args);
}
return Object.fromEntries(
Object.entries(args).map(([key, struct]) => {
const arg = new Argument(struct, {
name: dashCase(key),
type: Boolean
});
this._arguments.push(arg);
this.#named[key] = arg;

return [key, arg];
})
);
}

Expand Down
27 changes: 27 additions & 0 deletions src/error-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { ZodIssue } from 'zod';
import { findBestMatch } from 'string-similarity';
type Issue =
| ZodIssue
| {
code: 'unknown-command';
command: string;
availables: string[];
};

export function errorHandler(issues: Issue[]) {
const issue = issues[0];

if (issue.code === 'unknown-command') {
const match = findBestMatch(issue.command, issue.availables).bestMatch;
console.warn(`\n'${issue.command}' is not a command. See '--help'.\n`);
if (match.rating > 0.4) {
console.warn(`The most similar command is:\n\t${match.target}`);
}

return false;
}

console.warn('\n' + issue.message);

return false;
}
4 changes: 0 additions & 4 deletions src/parse.ts

This file was deleted.

40 changes: 39 additions & 1 deletion tests/command.unit.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { test } from 'uvu';
import { equal } from 'uvu/assert';
import { equal, throws } from 'uvu/assert';
import { number, preprocess } from 'zod';
import { createCLI, path, string } from '../src';

Expand Down Expand Up @@ -92,4 +92,42 @@ test('nested commands', () => {
equal(executed, true);
});

test('command without arguments', () => {
const cli = createCLI('cli');
cli.command('copy', () => {
return () => {
throw new Error();
};
});

throws(() => cli.parse(['copy']));
});

test('default command without arguments', () => {
const cli = createCLI('cli');
cli.action(() => {
throw new Error();
});

throws(() => cli.parse(['copy']));
});

test('command depending another command', () => {
const cli = createCLI('cli');
cli.command('copy', (copy) => {
const { serve } = copy.flags();
const { outFolder } = copy.named({
outFolder: string().refine(
() => !serve.value,
'Output folder cannot be set with serve'
)
});

return () => {
outFolder.value;
};
});
throws(() => cli.parse(['copy', '--serve', '--out-folder', 'dist/']));
});

test.run();

0 comments on commit 9556d86

Please sign in to comment.