diff --git a/.codex/skills/writeJsDoc/assets/index-template.md b/.codex/skills/writeJsDoc/assets/index-template.md index 3b45eb0..a02838f 100644 --- a/.codex/skills/writeJsDoc/assets/index-template.md +++ b/.codex/skills/writeJsDoc/assets/index-template.md @@ -8,7 +8,7 @@ Describe the behavior in one short paragraph. @remarks Optional extra detail. -@see https://utils.duplojs.dev/en/v1/api/namespace/function -@see [`Alias.or.Other`](https://server-utils.duplojs.dev/en/v1/api/namespace/other) +@see https://server-utils.duplojs.dev/en/v0/api/namespace/function +@see [`Alias.or.Other`](https://server-utils.duplojs.dev/en/v0/api/namespace/other) @namespace X diff --git a/integration/deno/deno.json b/integration/deno/deno.json index 46787d6..4797342 100644 --- a/integration/deno/deno.json +++ b/integration/deno/deno.json @@ -24,7 +24,7 @@ }, "imports": { "@duplojs/server-utils": "../../dist/index.mjs", - "@duplojs/utils": "npm:@duplojs/utils@1.5.1", + "@duplojs/utils": "npm:@duplojs/utils@1.6.3", "@std/assert": "jsr:@std/assert@^1.0.0", "@std/testing": "jsr:@std/testing@^1.0.0" }, diff --git a/integration/deno/deno.lock b/integration/deno/deno.lock index 90bd671..00a33ac 100644 --- a/integration/deno/deno.lock +++ b/integration/deno/deno.lock @@ -9,7 +9,7 @@ "jsr:@std/internal@^1.0.12": "1.0.12", "jsr:@std/path@^1.1.4": "1.1.4", "jsr:@std/testing@1": "1.0.17", - "npm:@duplojs/utils@1.5.1": "1.5.1" + "npm:@duplojs/utils@1.6.3": "1.6.3" }, "jsr": { "@std/assert@1.0.17": { @@ -52,15 +52,15 @@ } }, "npm": { - "@duplojs/utils@1.5.1": { - "integrity": "sha512-1BHngmR/9MAtPoMMpuWTXBHp1/qyTyggc4PtPzme1OWNWlt9ueTuftJoLQYut0quXnddmqwD2SI8u4QEATIgCQ==" + "@duplojs/utils@1.6.3": { + "integrity": "sha512-83qJLc1D+l9cXVRjPSdq7H+/yTrwAidWYKBU+DoBX3iYppDodpk54D1M2dqoFQ16MKYco8CR0f5GxGWN2r5KWQ==" } }, "workspace": { "dependencies": [ "jsr:@std/assert@1", "jsr:@std/testing@1", - "npm:@duplojs/utils@1.5.1" + "npm:@duplojs/utils@1.6.3" ] } } diff --git a/integration/node/__snapshots__/index.test.ts.snap b/integration/node/__snapshots__/index.test.ts.snap index 5230f23..f4325b0 100644 --- a/integration/node/__snapshots__/index.test.ts.snap +++ b/integration/node/__snapshots__/index.test.ts.snap @@ -10,7 +10,7 @@ exports[`node integration > command integration with nested help and execute > h Seed a target OPTIONS: - force: -f, --force - force execution + force execution SUBJECT:[string]" `; @@ -25,7 +25,7 @@ exports[`node integration > command integration with nested help and execute > h Seed a target OPTIONS: - force: -f, --force - force execution + force execution SUBJECT:[string]" `; diff --git a/integration/node/index.test.ts b/integration/node/index.test.ts index 495d271..4724e77 100644 --- a/integration/node/index.test.ts +++ b/integration/node/index.test.ts @@ -286,7 +286,7 @@ describe("node integration", () => { expect(logSpy).toHaveBeenCalled(); expect( stripAnsiColor( - logSpy.mock.calls.map((call) => call.join("")).join("\n"), + logSpy.mock.calls.map(([message]) => String(message)).join("\n"), ), ).toMatchSnapshot("help root"); @@ -294,7 +294,7 @@ describe("node integration", () => { await command.execute(["--help"]); expect( stripAnsiColor( - logSpy.mock.calls.map((call) => call.join("")).join("\n"), + logSpy.mock.calls.map(([message]) => String(message)).join("\n"), ), ).toMatchSnapshot("help command"); @@ -302,7 +302,7 @@ describe("node integration", () => { await command.execute(["help-db", "--help"]); expect( stripAnsiColor( - logSpy.mock.calls.map((call) => call.join("")).join("\n"), + logSpy.mock.calls.map(([message]) => String(message)).join("\n"), ), ).toMatchSnapshot("help sub-command"); diff --git a/package-lock.json b/package-lock.json index 51ca8df..082f068 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,7 +38,7 @@ "node": ">=22.15.1" }, "peerDependencies": { - "@duplojs/utils": ">=1.5.13 <2.0.0" + "@duplojs/utils": ">=1.6.3 <2.0.0" } }, "docs": { @@ -1086,9 +1086,9 @@ "link": true }, "node_modules/@duplojs/utils": { - "version": "1.5.13", - "resolved": "https://registry.npmjs.org/@duplojs/utils/-/utils-1.5.13.tgz", - "integrity": "sha512-N4GbxGwQVhMbTu5UsMkgOrCrhRc7biWH8/HSX6w3SDS5NkVz2Rl235d7GZyaJ7NmJfFFm28LW88WM+Ub4a74hA==", + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@duplojs/utils/-/utils-1.6.3.tgz", + "integrity": "sha512-83qJLc1D+l9cXVRjPSdq7H+/yTrwAidWYKBU+DoBX3iYppDodpk54D1M2dqoFQ16MKYco8CR0f5GxGWN2r5KWQ==", "license": "MIT", "peer": true, "workspaces": [ diff --git a/package.json b/package.json index 0d4f67c..33a02cb 100644 --- a/package.json +++ b/package.json @@ -92,7 +92,7 @@ "@duplojs/dev-tools": "0.2.0" }, "peerDependencies": { - "@duplojs/utils": ">=1.5.13 <2.0.0" + "@duplojs/utils": ">=1.6.3 <2.0.0" }, "workspaces": [ "integration", diff --git a/scripts/command/create.ts b/scripts/command/create.ts index 23f66a1..d1dfbd0 100644 --- a/scripts/command/create.ts +++ b/scripts/command/create.ts @@ -1,10 +1,10 @@ -import { type SimplifyTopLevel, type Kind, type AnyFunction, type RemoveKind, A, DP, type MaybePromise, O } from "@duplojs/utils"; +import { type SimplifyTopLevel, type Kind, type AnyFunction, type RemoveKind, A, DP, E, unwrap, type MaybePromise, O } from "@duplojs/utils"; import { createBooleanOption, type Option } from "./options"; import { createDuplojsServerUtilsKind } from "@scripts/kind"; import type { EligibleDataParser } from "./types"; -import { exitProcess } from "@scripts/common"; +import { exitProcess } from "@scripts/common/exitProcess"; +import { addIssue, addDataParserError, createError, interpretError, popErrorPath, setErrorPath, SymbolCommandError, type CommandError } from "./error"; import { logHelp } from "./help"; -import { CommandManyArgumentsError } from "./errors"; export type Subject = ( | EligibleDataParser @@ -32,6 +32,16 @@ export type Subject = ( > ); +function printError(commandError: CommandError, error?: CommandError): SymbolCommandError { + if (!error) { + // eslint-disable-next-line no-console + console.error(interpretError(commandError)); + exitProcess(1); + } + + return SymbolCommandError; +} + const commandKind = createDuplojsServerUtilsKind("command"); const helpOption = createBooleanOption("help", { aliases: ["h"] }); @@ -43,7 +53,7 @@ export interface Command extends Kind< readonly description: string | null; readonly subject: Subject | null | readonly Command[]; readonly options: readonly Option[]; - execute(args: readonly string[]): MaybePromise; + execute(args: readonly string[], error?: CommandError): Promise; } export interface CreateCommandParams< @@ -60,11 +70,14 @@ export interface CreateCommandExecuteParams< GenericSubject extends Subject, > { options: { - [GenericOptionName in GenericOptions[number]["name"]]: ReturnType< - Extract< - GenericOptions[number], - { name: GenericOptionName } - >["execute"] + [GenericOptionName in GenericOptions[number]["name"]]: Exclude< + ReturnType< + Extract< + GenericOptions[number], + { name: GenericOptionName } + >["execute"] + >, + SymbolCommandError >["result"] }; subject: DP.Output; @@ -105,24 +118,38 @@ export function create( description: params.description ?? null, options: params.options ?? [], subject: params.subject ?? null, - execute: async(args: readonly string[]) => { + execute: async(args, error?) => { + const commandError = error ?? createError(self.name); + const pathIndex = commandError.currentCommandPath.length; + if (self.subject instanceof Array) { for (const command of self.subject) { if (args[0] === command.name) { - await command.execute(A.shift(args)); + let result: Awaited> | undefined = undefined; + + setErrorPath(commandError, command.name, pathIndex); + try { + result = await command.execute(A.shift(args), commandError); + } finally { + popErrorPath(commandError); + } - return; + if (result === SymbolCommandError) { + return printError(commandError, error); + } + + return result; } } } - const help = helpOption.execute(args); + const help = helpOption.execute(args, commandError); - if (help.result) { + if (help === SymbolCommandError) { + return printError(commandError, error); + } else if (help.result) { logHelp(self); - exitProcess(0); - - return; + return void exitProcess(0); } const commandOptions = A.reduce( @@ -134,8 +161,12 @@ export function create( options: {}, restArgs: args, }), - ({ element: option, lastValue, next }) => { - const optionResult = option.execute(lastValue.restArgs); + ({ element: option, lastValue, next, exit }) => { + const optionResult = option.execute(lastValue.restArgs, commandError); + + if (optionResult === SymbolCommandError) { + return exit(SymbolCommandError); + } return next({ options: O.override( @@ -149,31 +180,72 @@ export function create( }, ); + if (commandOptions === SymbolCommandError) { + return printError(commandError, error); + } + if (self.subject === null) { await execute({ options: commandOptions.options }); } else if ( DP.identifier(self.subject, DP.arrayKind) || DP.identifier(self.subject, DP.tupleKind) ) { + const subjectResult = self.subject.parse(commandOptions.restArgs); + + if (E.isLeft(subjectResult)) { + addDataParserError( + commandError, + unwrap(subjectResult), + { + type: "subject", + }, + ); + + return printError(commandError, error); + } + await execute({ options: commandOptions.options, - subject: self.subject.parseOrThrow(commandOptions.restArgs), + subject: unwrap(subjectResult), }); } else if (DP.identifier(self.subject, DP.dataParserKind)) { if (commandOptions.restArgs.length > 1) { - throw new CommandManyArgumentsError(commandOptions.restArgs.length); + addIssue( + commandError, + { + type: "command", + expected: "exactly one subject argument", + received: commandOptions.restArgs, + message: `Expected exactly one subject argument, received ${commandOptions.restArgs.length}.`, + }, + ); + + return printError(commandError, error); + } + + const subjectResult = self.subject.parse(commandOptions.restArgs); + + if (E.isLeft(subjectResult)) { + addDataParserError( + commandError, + unwrap(subjectResult), + { + type: "subject", + }, + ); + + return printError(commandError, error); } await execute({ options: commandOptions.options, - subject: self.subject.parseOrThrow(commandOptions.restArgs), + subject: unwrap(subjectResult), }); } else { await execute({}); } - exitProcess(0); - return; + return void exitProcess(0); }, } satisfies RemoveKind, ); diff --git a/scripts/command/error.ts b/scripts/command/error.ts new file mode 100644 index 0000000..dd23d1d --- /dev/null +++ b/scripts/command/error.ts @@ -0,0 +1,141 @@ +import { type DP, Printer } from "@duplojs/utils"; + +export interface CommandErrorIssue { + readonly type: "command" | "option" | "subject"; + readonly commandPath: readonly string[]; + readonly target?: string; + readonly parserPath?: string; + readonly expected: string; + readonly received: unknown; + readonly message?: string; +} + +export interface CommandError { + readonly issues: CommandErrorIssue[]; + readonly currentCommandPath: string[]; +} + +export const SymbolCommandError = Symbol.for("SymbolCommandError"); +export type SymbolCommandError = typeof SymbolCommandError; + +export function createError( + commandName: string, +): CommandError { + return { + issues: [], + currentCommandPath: [commandName], + }; +} + +export function addIssue( + error: CommandError, + issue: Omit, +): typeof SymbolCommandError { + error.issues.push( + { + ...issue, + commandPath: [...error.currentCommandPath], + }, + ); + + return SymbolCommandError; +} + +export function setErrorPath( + error: CommandError, + value: string, + index: number, +): CommandError { + error.currentCommandPath[index] = value; + + return error; +} + +export function popErrorPath( + error: CommandError, +): CommandError { + error.currentCommandPath.pop(); + + return error; +} + +export function addDataParserError( + error: CommandError, + parseError: DP.DataParserError, + params: { + type: "option" | "subject"; + target?: string; + }, +): SymbolCommandError { + for (const issue of parseError.issues) { + error.issues.push( + { + type: params.type, + commandPath: [...error.currentCommandPath], + target: params.target, + parserPath: issue.path || undefined, + expected: issue.expected, + received: issue.data, + message: issue.message, + }, + ); + } + + return SymbolCommandError; +} + +export function interpretError( + error: CommandError, +): string { + return Printer.renderParagraph( + [ + Printer.render( + [ + Printer.colorizedBold("Command failed", "red"), + Printer.back, + Printer.indent(1), + Printer.colorizedBold("COMMAND: ", "cyan"), + error.issues[0]?.commandPath.join(" ") ?? error.currentCommandPath.join(" "), + ], + "", + ), + error.issues.map( + (issue) => Printer.renderParagraph( + [ + issue.type === "option" + && issue.target + && Printer.render( + [ + Printer.indent(1), + Printer.colorizedBold("OPTION: ", "magenta"), + `--${issue.target}`, + ], + "", + ), + issue.type === "subject" + && issue.parserPath + && Printer.render( + [ + Printer.indent(1), + Printer.colorizedBold("SUBJECT:", "magenta"), + ], + "", + ), + Printer.renderLine( + [ + Printer.colorizedBold("✖", "red"), + issue.parserPath && Printer.colorizedBold(issue.parserPath, "cyan"), + "expected", + Printer.colorized(issue.expected, "green"), + "but received", + Printer.colorized(Printer.stringify(issue.received), "red"), + ], + ), + issue.message !== undefined && `${Printer.indent(1)}↳ ${issue.message}`, + ], + ), + ), + error.issues.length === 0 && "No issue found", + ], + ); +} diff --git a/scripts/command/errors.ts b/scripts/command/errors.ts deleted file mode 100644 index adc77af..0000000 --- a/scripts/command/errors.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { kindHeritage } from "@duplojs/utils"; -import { createDuplojsServerUtilsKind } from "@scripts/kind"; - -export class CommandManyArgumentsError extends kindHeritage( - "command-many-arguments-error", - createDuplojsServerUtilsKind("command-many-arguments-error"), - Error, -) { - public constructor( - public restArgumentsLength: number, - ) { - super({}, [`Expected exactly one subject argument, received ${restArgumentsLength}.`]); - } -} - -export class CommandOptionValueLooksLikeOptionError extends kindHeritage( - "command-option-value-looks-like-option-error", - createDuplojsServerUtilsKind("command-option-value-looks-like-option-error"), - Error, -) { - public constructor( - public optionName: string, - public value: string | undefined, - ) { - super({}, [`Missing value for option "${optionName}": received another option token instead of a value.`]); - } -} - -export class CommandOptionValueNotRequiredError extends kindHeritage( - "command-option-value-not-required-error", - createDuplojsServerUtilsKind("command-option-value-not-required-error"), - Error, -) { - public constructor( - public optionName: string, - ) { - super({}, [`Option "${optionName}" does not accept a value.`]); - } -} - -export class CommandOptionRequiredError extends kindHeritage( - "command-option-required-error", - createDuplojsServerUtilsKind("command-option-required-error"), - Error, -) { - public constructor( - public optionName: string, - ) { - super({}, [`Option "${optionName}" is required.`]); - } -} diff --git a/scripts/command/help.ts b/scripts/command/help.ts index 62fdaa5..b6c5004 100644 --- a/scripts/command/help.ts +++ b/scripts/command/help.ts @@ -1,8 +1,10 @@ -import { A, DP, hasSomeKinds, isType, justReturn, P, pipe } from "@duplojs/utils"; +import { A, DP, hasSomeKinds, isType, justReturn, P, pipe, Printer } from "@duplojs/utils"; import type { Command } from "./create"; -import { Printer } from "./printer"; -function formatSubject(subject: DP.DataParser): string { +/** + * @internal + */ +export function formatSubject(subject: DP.DataParser): string { return P.match(subject) .when( DP.identifier(DP.stringKind), @@ -78,76 +80,98 @@ function formatSubject(subject: DP.DataParser): string { .otherwise(justReturn("unknown")); } -export function logHelp( +/** + * @internal + */ +export function renderCommandHelp( command: Command, - depth = 0, -) { - Printer.render( - [ - Printer.indent(depth), - Printer.colorized("NAME:", "GREEN"), - command.name, - ], + depth: number, +): string[] { + const logs: string[] = []; + + logs.push( + `${Printer.indent(depth)}${Printer.colorizedBold("NAME:", "green")}${command.name}`, ); + if (command.description) { - Printer.render( - [ - Printer.indent(depth + 1), - Printer.colorized("DESCRIPTION:", "CYAN"), - Printer.back, - Printer.indent(depth + 1), - command.description, - ], + logs.push( + Printer.renderParagraph( + [ + `${Printer.indent(depth + 1)}${Printer.colorizedBold("DESCRIPTION:", "cyan")}`, + `${Printer.indent(depth + 1)}${command.description}`, + ], + ), ); } - if (A.minElements(command.options, 1)) { - const optionLines: string[] = []; - command.options.forEach((option, index) => { - optionLines.push( - Printer.indent(depth + 1), - Printer.dash, - Printer.colorized(` ${option.name}: `, "cyan"), - Printer.colorizedOption(option, "gray"), - ); - - if (option.description) { - optionLines.push( - Printer.back, - Printer.indent(depth + 1), - ` ${option.description}`, - ); - } - - if (index < command.options.length - 1) { - optionLines.push(Printer.back); - } - }); - - Printer.render( - [ - Printer.indent(depth + 1), - Printer.colorized("OPTIONS:", "BLUE"), - Printer.back, - ...optionLines, - ], + if (A.minElements(command.options, 1)) { + logs.push( + Printer.renderParagraph( + [ + `${Printer.indent(depth + 1)}${Printer.colorizedBold("OPTIONS:", "blue")}`, + A.map( + command.options, + (option) => Printer.renderParagraph( + [ + A.join( + [ + Printer.indent(depth + 1), + Printer.dash, + Printer.colorized(` ${option.name}: `, "cyan"), + Printer.colorized( + pipe( + option.aliases, + A.map((alias) => `-${alias},`), + A.push(`--${option.name}`), + A.join(" "), + ), + "gray", + ), + ], + "", + ), + option.description + && `${Printer.indent(depth + 1)} ${option.description}`, + ], + ), + ), + ], + ), ); } + if (isType(command.subject, "array")) { for (const childCommand of command.subject) { - logHelp(childCommand, depth + 1); + logs.push(...renderCommandHelp(childCommand, depth + 1)); } } else if (DP.identifier(command.subject, DP.dataParserKind)) { const formattedSubject = formatSubject(command.subject); - Printer.render( - [ - Printer.indent(depth + 1), - Printer.colorized("SUBJECT:", "MAGENTA"), - hasSomeKinds(command.subject, [DP.tupleKind, DP.arrayKind]) - ? formattedSubject - : `<${formattedSubject}>`, - ], + logs.push( + A.join( + [ + Printer.indent(depth + 1), + Printer.colorizedBold("SUBJECT:", "magenta"), + hasSomeKinds(command.subject, [DP.tupleKind, DP.arrayKind]) + ? formattedSubject + : `<${formattedSubject}>`, + ], + "", + ), ); } + + return logs; +} + +export function logHelp( + command: Command, + depth = 0, +) { + // eslint-disable-next-line no-console + console.log( + Printer.renderParagraph( + renderCommandHelp(command, depth), + ), + ); } diff --git a/scripts/command/index.ts b/scripts/command/index.ts index 84212a2..536cc58 100644 --- a/scripts/command/index.ts +++ b/scripts/command/index.ts @@ -4,8 +4,7 @@ export * from "./types"; export * from "./options"; -export * from "./errors"; export * from "./create"; export * from "./exec"; -export * from "./printer"; export * from "./help"; +export * from "./error"; diff --git a/scripts/command/options/array.ts b/scripts/command/options/array.ts index b9a04c7..1a224ee 100644 --- a/scripts/command/options/array.ts +++ b/scripts/command/options/array.ts @@ -1,7 +1,7 @@ -import { type A, DP, pipe, S } from "@duplojs/utils"; +import { type A, DP, E, pipe, S, unwrap } from "@duplojs/utils"; import { initOption, type Option } from "./base"; import type { EligibleDataParser } from "../types"; -import { CommandOptionRequiredError } from "../errors"; +import { addIssue, addDataParserError } from "../error"; const defaultSeparator = ","; @@ -88,16 +88,38 @@ export function createArrayOption( return initOption( name, - ({ isHere, value }) => { + ({ isHere, value }, error) => { if (!isHere && params?.required) { - throw new CommandOptionRequiredError(name); + return addIssue( + error, + { + type: "option", + target: name, + expected: `required option --${name}`, + received: value, + message: `Option "${name}" is required.`, + }, + ); } const values = value !== undefined ? S.split(value, params?.separator ?? defaultSeparator) : undefined; - return dataParser.parseOrThrow(values); + const result = dataParser.parse(values); + + if (E.isLeft(result)) { + return addDataParserError( + error, + unwrap(result), + { + type: "option", + target: name, + }, + ); + } + + return unwrap(result); }, { description: params?.description, diff --git a/scripts/command/options/base.ts b/scripts/command/options/base.ts index fb953dc..56e1f5c 100644 --- a/scripts/command/options/base.ts +++ b/scripts/command/options/base.ts @@ -1,6 +1,6 @@ import { A, type RemoveKind, S, type Kind } from "@duplojs/utils"; import { createDuplojsServerUtilsKind } from "@scripts/kind"; -import { CommandOptionValueLooksLikeOptionError, CommandOptionValueNotRequiredError } from "../errors"; +import { addIssue, type CommandError, SymbolCommandError } from "../error"; const optionKind = createDuplojsServerUtilsKind("command-option"); const regexOption = /^(?-{1,2})(?[A-Za-z0-9][A-Za-z0-9_-]*)(?:=(?.*))?$/; @@ -13,10 +13,15 @@ export interface Option< readonly description: string | null; readonly aliases: readonly string[]; readonly hasValue: boolean; - execute(args: readonly string[]): { + execute( + args: readonly string[], + error: CommandError, + ): + | { result: GenericExecuteOutputValue; argumentRest: readonly string[]; - }; + } + | SymbolCommandError; } export interface InitOptionExecuteParams { @@ -31,7 +36,8 @@ export function initOption< name: GenericName, execute: ( params: InitOptionExecuteParams, - ) => GenericExecuteOutputValue, + error: CommandError, + ) => GenericExecuteOutputValue | SymbolCommandError, params?: { description?: string; aliases?: readonly string[]; @@ -40,7 +46,10 @@ export function initOption< ) { const self: Option = optionKind.setTo({ name, - execute: (args) => { + execute: ( + args: readonly string[], + error: CommandError, + ) => { const result = A.reduce( args, A.reduceFrom(null), @@ -66,13 +75,20 @@ export function initOption< ); if (!result) { + const executeResult = execute( + { + isHere: false, + value: undefined, + }, + error, + ); + + if (executeResult === SymbolCommandError) { + return SymbolCommandError; + } + return { - result: execute( - { - isHere: false, - value: undefined, - }, - ), + result: executeResult, argumentRest: args, }; } else if (self.hasValue) { @@ -80,16 +96,32 @@ export function initOption< const isOption = S.test(value ?? "", regexOption); if (isOption) { - throw new CommandOptionValueLooksLikeOptionError(self.name, value); + return addIssue( + error, + { + type: "option", + target: self.name, + expected: `value for option --${self.name}`, + received: value, + message: `Missing value for option "${self.name}": received another option token instead of a value.`, + }, + ); + } + + const executeResult = execute( + { + isHere: true, + value, + }, + error, + ); + + if (executeResult === SymbolCommandError) { + return SymbolCommandError; } return { - result: execute( - { - isHere: true, - value, - }, - ), + result: executeResult, argumentRest: A.spliceDelete( args, result.index, @@ -99,16 +131,32 @@ export function initOption< ), }; } else if (!self.hasValue && result.value !== undefined) { - throw new CommandOptionValueNotRequiredError(self.name); + return addIssue( + error, + { + type: "option", + target: self.name, + expected: `option without value --${self.name}`, + received: result.value, + message: `Option "${self.name}" does not accept a value.`, + }, + ); + } + + const executeResult = execute( + { + isHere: true, + value: undefined, + }, + error, + ); + + if (executeResult === SymbolCommandError) { + return SymbolCommandError; } return { - result: execute( - { - isHere: true, - value: undefined, - }, - ), + result: executeResult, argumentRest: A.spliceDelete(args, result.index, 1), }; }, diff --git a/scripts/command/options/simple.ts b/scripts/command/options/simple.ts index 9061ba4..4b86f9b 100644 --- a/scripts/command/options/simple.ts +++ b/scripts/command/options/simple.ts @@ -1,7 +1,7 @@ -import { DP } from "@duplojs/utils"; +import { DP, E, unwrap } from "@duplojs/utils"; import { initOption, type Option } from "./base"; import type { EligibleDataParser } from "../types"; -import { CommandOptionRequiredError } from "../errors"; +import { addIssue, addDataParserError } from "../error"; /** * {@include command/createOption/index.md} @@ -46,12 +46,34 @@ export function createOption( return initOption( name, - ({ isHere, value }) => { + ({ isHere, value }, error) => { if (!isHere && params?.required) { - throw new CommandOptionRequiredError(name); + return addIssue( + error, + { + type: "option", + target: name, + expected: `required option --${name}`, + received: value, + message: `Option "${name}" is required.`, + }, + ); } - return dataParser.parseOrThrow(value); + const result = dataParser.parse(value); + + if (E.isLeft(result)) { + return addDataParserError( + error, + unwrap(result), + { + type: "option", + target: name, + }, + ); + } + + return unwrap(result); }, { description: params?.description, diff --git a/scripts/command/printer.ts b/scripts/command/printer.ts deleted file mode 100644 index 234cbcc..0000000 --- a/scripts/command/printer.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { A, createEnum, type GetEnumValue, pipe, S } from "@duplojs/utils"; -import type { Option } from "./options"; - -export namespace Printer { - const codeColors = { - reset: "\x1b[0m", - red: "\x1b[31m", - green: "\x1b[32m", - yellow: "\x1b[33m", - blue: "\x1b[34m", - magenta: "\x1b[35m", - cyan: "\x1b[36m", - gray: "\x1b[90m", - bold: "\x1b[1m", - } as const; - - export const colorsEnum = createEnum([ - "red", - "RED", - "green", - "GREEN", - "yellow", - "YELLOW", - "blue", - "BLUE", - "magenta", - "MAGENTA", - "cyan", - "CYAN", - "gray", - "GRAY", - ]); - - export type ColorEnum = GetEnumValue; - - export const tab = "\t" as const; - export const back = "\n" as const; - export const dash = "-" as const; - - const regexCapitalize = /[A-Z]/; - export function colorized(input: string, color: ColorEnum) { - const text = `${codeColors[S.toLowerCase(color)]}${input}${codeColors.reset}`; - - if ( - S.test(color, regexCapitalize) - ) { - return `${codeColors.bold}${text}${codeColors.reset}`; - } - - return text; - } - - export function indent(level: number) { - return S.repeat(tab, level); - } - - export function parenthesize(input: string) { - return `(${input})`; - } - - export function colorizedOption(option: Option, color: ColorEnum) { - return colorized( - pipe( - option.aliases, - A.map((alias) => `-${alias},`), - A.push(`--${option.name}`), - A.join(" "), - ), - color, - ); - } - - export function render(values: string[]) { - // eslint-disable-next-line no-console - console.log(...values); - } -} diff --git a/scripts/dataParser/parsers/file.ts b/scripts/dataParser/parsers/file.ts index 2fb483e..f2553a0 100644 --- a/scripts/dataParser/parsers/file.ts +++ b/scripts/dataParser/parsers/file.ts @@ -110,7 +110,12 @@ export function file< || self.definition.maxSize !== undefined || self.definition.minSize !== undefined ) { - return DP.SymbolDataParserErrorPromiseIssue; + return DP.addIssue( + error, + "async data parser", + data, + self.definition.errorMessage, + ); } let fileInterface = data; @@ -120,7 +125,12 @@ export function file< } if (!isFileInterface(fileInterface)) { - return DP.SymbolDataParserErrorIssue; + return DP.addIssue( + error, + "file", + data, + self.definition.errorMessage, + ); } if ( @@ -130,8 +140,12 @@ export function file< .mimeType .test(fileInterface.getMimeType() ?? "") ) { - DP.addIssue(error, self, data, "Wrong mimeType."); - return DP.SymbolDataParserError; + return DP.addIssue( + error, + `file with mime type matching ${self.definition.mimeType.source}`, + data, + "Wrong mimeType.", + ); } return fileInterface; @@ -144,7 +158,12 @@ export function file< } if (!isFileInterface(fileInterface)) { - return DP.SymbolDataParserErrorIssue; + return DP.addIssue( + error, + "file", + data, + self.definition.errorMessage, + ); } if ( @@ -154,8 +173,12 @@ export function file< .mimeType .test(fileInterface.getMimeType() ?? "") ) { - DP.addIssue(error, self, data, "Wrong mimeType."); - return DP.SymbolDataParserError; + return DP.addIssue( + error, + `file with mime type matching ${self.definition.mimeType.source}`, + fileInterface, + "Wrong mimeType.", + ); } if ( @@ -166,31 +189,47 @@ export function file< const resultStats = await fileInterface.stat(); if (E.isLeft(resultStats)) { - DP.addIssue(error, self, data, "File not exist."); - return DP.SymbolDataParserError; + return DP.addIssue( + error, + "existing file", + fileInterface, + "File not exist.", + ); } const stat = unwrap(resultStats); if (!stat.isFile) { - DP.addIssue(error, self, data, "Is not file."); - return DP.SymbolDataParserError; + return DP.addIssue( + error, + "file", + stat, + "Is not file.", + ); } if ( self.definition.maxSize !== undefined - && stat.sizeBytes > self.definition.maxSize + && stat.sizeBytes > self.definition.maxSize ) { - DP.addIssue(error, self, data, "File is to large."); - return DP.SymbolDataParserError; + return DP.addIssue( + error, + `file with sizeBytes <= ${self.definition.maxSize}`, + stat.sizeBytes, + "File is to large.", + ); } if ( self.definition.minSize !== undefined - && stat.sizeBytes < self.definition.minSize + && stat.sizeBytes < self.definition.minSize ) { - DP.addIssue(error, self, data, "File is to small."); - return DP.SymbolDataParserError; + return DP.addIssue( + error, + `file with sizeBytes >= ${self.definition.minSize}`, + stat.sizeBytes, + "File is to small.", + ); } } diff --git a/tests/command/create.test.ts b/tests/command/create.test.ts index dd00cf4..eee83df 100644 --- a/tests/command/create.test.ts +++ b/tests/command/create.test.ts @@ -100,9 +100,11 @@ describe("create", () => { expect(exitSpy).toHaveBeenCalledWith(0); }); - it("throws CommandManyArgumentsError when data parser subject receives many arguments", async() => { + it("renders command error when data parser subject receives many arguments", async() => { setEnvironment("TEST"); - TESTImplementation.set("exitProcess", vi.fn()); + const exitSpy = vi.fn(); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + TESTImplementation.set("exitProcess", exitSpy); const command = DServerCommand.create( "root", @@ -120,7 +122,13 @@ describe("create", () => { }, ); - await expect(command.execute(["one", "two"])).rejects.toThrowError(DServerCommand.CommandManyArgumentsError); + await command.execute(["one", "two"]); + + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(errorSpy.mock.calls[0]![0]).toContain("Command failed"); + expect(errorSpy.mock.calls[0]![0]).toContain("subject"); + expect(errorSpy.mock.calls[0]![0]).toContain("Expected exactly one subject argument, received 2."); + expect(exitSpy).toHaveBeenCalledWith(1); }); it("runs help flow when help option is provided", async() => { @@ -139,6 +147,36 @@ describe("create", () => { expect(exitSpy).toHaveBeenCalledWith(0); }); + it("renders command error when help option is malformed", async() => { + setEnvironment("TEST"); + const exitSpy = vi.fn(); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + TESTImplementation.set("exitProcess", exitSpy); + + const command = DServerCommand.create( + "root", + () => undefined, + ); + + await command.execute(["--help=true"]); + + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(errorSpy.mock.calls[0]![0]).toContain("option without value --help"); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("does not print root error when malformed help is executed with a shared error", async() => { + setEnvironment("TEST"); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + const command = DServerCommand.create( + "root", + () => undefined, + ); + + await expect(command.execute(["--help=true"], DServerCommand.createError("parent"))).resolves.toBe(DServerCommand.SymbolCommandError); + expect(errorSpy).not.toHaveBeenCalled(); + }); + it("executes data parser subject branch with a single argument", async() => { setEnvironment("TEST"); const exitSpy = vi.fn(); @@ -187,6 +225,95 @@ describe("create", () => { expect(exitSpy).toHaveBeenCalledWith(0); }); + it("renders command error when tuple subject parsing fails", async() => { + setEnvironment("TEST"); + const exitSpy = vi.fn(); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + TESTImplementation.set("exitProcess", exitSpy); + + const command = DServerCommand.create( + "root", + { + subject: DP.tuple([DP.number()]) as never, + }, + () => undefined, + ); + + await command.execute(["bad"]); + + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(errorSpy.mock.calls[0]![0]).toContain("SUBJECT:"); + expect(errorSpy.mock.calls[0]![0]).toContain("number"); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("returns subject parse error without printing when tuple subject has a shared error", async() => { + setEnvironment("TEST"); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + const command = DServerCommand.create( + "root", + { + subject: DP.tuple([DP.number()]) as never, + }, + () => undefined, + ); + + await expect(command.execute(["bad"], DServerCommand.createError("parent"))).resolves.toBe(DServerCommand.SymbolCommandError); + expect(errorSpy).not.toHaveBeenCalled(); + }); + + it("renders command error when single subject parsing fails", async() => { + setEnvironment("TEST"); + const exitSpy = vi.fn(); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + TESTImplementation.set("exitProcess", exitSpy); + + const command = DServerCommand.create( + "root", + { + subject: DP.number() as never, + }, + () => undefined, + ); + + await command.execute(["bad"]); + + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(errorSpy.mock.calls[0]![0]).toContain("expected"); + expect(errorSpy.mock.calls[0]![0]).toContain("number"); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("returns many-arguments error without printing when single subject has a shared error", async() => { + setEnvironment("TEST"); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + const command = DServerCommand.create( + "root", + { + subject: DP.string() as never, + }, + () => undefined, + ); + + await expect(command.execute(["one", "two"], DServerCommand.createError("parent"))).resolves.toBe(DServerCommand.SymbolCommandError); + expect(errorSpy).not.toHaveBeenCalled(); + }); + + it("returns single subject parse error without printing when a shared error is provided", async() => { + setEnvironment("TEST"); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + const command = DServerCommand.create( + "root", + { + subject: DP.number() as never, + }, + () => undefined, + ); + + await expect(command.execute(["bad"], DServerCommand.createError("parent"))).resolves.toBe(DServerCommand.SymbolCommandError); + expect(errorSpy).not.toHaveBeenCalled(); + }); + it("infers typed params with array subject", async() => { setEnvironment("TEST"); const exitSpy = vi.fn(); @@ -267,6 +394,78 @@ describe("create", () => { expect(exitSpy).toHaveBeenNthCalledWith(2, 0); }); + it("renders command error when option value parsing fails", async() => { + setEnvironment("TEST"); + const exitSpy = vi.fn(); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + TESTImplementation.set("exitProcess", exitSpy); + + const command = DServerCommand.create( + "root", + { + options: [DServerCommand.createOption("enabled", DP.boolean() as never)], + }, + () => undefined, + ); + + await command.execute(["--enabled=yes"]); + + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(errorSpy.mock.calls[0]![0]).toContain("Command failed"); + expect(errorSpy.mock.calls[0]![0]).toContain("OPTION:"); + expect(errorSpy.mock.calls[0]![0]).toContain("--enabled"); + expect(errorSpy.mock.calls[0]![0]).toContain("expected"); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("stops option reduction after a first option error", async() => { + setEnvironment("TEST"); + const exitSpy = vi.fn(); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + const secondOptionSpy = vi.fn(); + TESTImplementation.set("exitProcess", exitSpy); + + const command = DServerCommand.create( + "root", + { + options: [ + DServerCommand.createOption("enabled", DP.boolean() as never), + DServerCommand.initOption("later", () => { + secondOptionSpy(); + + return true; + }), + ], + }, + () => undefined, + ); + + await command.execute(["--enabled=yes"]); + + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(secondOptionSpy).not.toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("does not capture user command execution errors", async() => { + setEnvironment("TEST"); + const exitSpy = vi.fn(); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + TESTImplementation.set("exitProcess", exitSpy); + const userError = new Error("user crash"); + + const command = DServerCommand.create( + "root", + () => { + throw userError; + }, + ); + + await expect(command.execute([])).rejects.toThrow(userError); + expect(errorSpy).not.toHaveBeenCalled(); + expect(exitSpy).not.toHaveBeenCalledWith(1); + }); + it("executes matching child command when subject is a command list", async() => { setEnvironment("TEST"); const exitSpy = vi.fn(); @@ -344,4 +543,33 @@ describe("create", () => { expect(renderedHelp).not.toContain("root"); expect(exitSpy).toHaveBeenCalledWith(0); }); + + it("renders root command error when a child command fails", async() => { + setEnvironment("TEST"); + const exitSpy = vi.fn(); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + TESTImplementation.set("exitProcess", exitSpy); + + const child = DServerCommand.create( + "child", + { + options: [DServerCommand.createOption("enabled", DP.boolean() as never)], + }, + () => undefined, + ); + const root = DServerCommand.create( + "root", + { + subject: [child], + }, + () => undefined, + ); + + await root.execute(["child", "--enabled=yes"]); + + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(errorSpy.mock.calls[0]![0]).toContain("COMMAND:"); + expect(errorSpy.mock.calls[0]![0]).toContain("root child"); + expect(exitSpy).toHaveBeenCalledWith(1); + }); }); diff --git a/tests/command/error.test.ts b/tests/command/error.test.ts new file mode 100644 index 0000000..edbbedf --- /dev/null +++ b/tests/command/error.test.ts @@ -0,0 +1,104 @@ +import { type ExpectType, DP, E, unwrap } from "@duplojs/utils"; +import { addDataParserError, addIssue, createError, interpretError, popErrorPath, setErrorPath, SymbolCommandError } from "@scripts/command/error"; + +describe("error", () => { + it("creates and mutates a command error path", () => { + const error = createError("root"); + + type _CheckError = ExpectType< + typeof error.currentCommandPath, + string[], + "strict" + >; + + expect(error.currentCommandPath).toEqual(["root"]); + expect(setErrorPath(error, "child", 1)).toBe(error); + expect(error.currentCommandPath).toEqual(["root", "child"]); + expect(popErrorPath(error)).toBe(error); + expect(error.currentCommandPath).toEqual(["root"]); + }); + + it("adds command issues and renders command context", () => { + const error = createError("root"); + + expect( + addIssue( + error, + { + type: "command", + expected: "exactly one subject argument", + received: ["one", "two"], + message: "Expected exactly one subject argument, received 2.", + }, + ), + ).toBe(SymbolCommandError); + + expect(error.issues).toEqual([ + { + type: "command", + commandPath: ["root"], + expected: "exactly one subject argument", + received: ["one", "two"], + message: "Expected exactly one subject argument, received 2.", + }, + ]); + expect(interpretError(error)).toContain("Expected exactly one subject argument, received 2."); + }); + + it("adds data parser issues and renders option and subject contexts", () => { + const error = createError("root"); + const optionResult = DP.boolean().parse("yes"); + + expect(E.isLeft(optionResult)).toBe(true); + if (!E.isLeft(optionResult)) { + return; + } + + expect( + addDataParserError( + error, + unwrap(optionResult), + { + type: "option", + target: "enabled", + }, + ), + ).toBe(SymbolCommandError); + + setErrorPath(error, "child", 1); + + const subjectResult = DP.tuple([DP.string(), DP.number()]).parse(["name", "bad"]); + + expect(E.isLeft(subjectResult)).toBe(true); + if (!E.isLeft(subjectResult)) { + return; + } + + expect( + addDataParserError( + error, + unwrap(subjectResult), + { + type: "subject", + }, + ), + ).toBe(SymbolCommandError); + + const output = interpretError(error); + + expect(output).toContain("COMMAND:"); + expect(output).toContain("root"); + expect(output).toContain("OPTION:"); + expect(output).toContain("--enabled"); + expect(output).toContain("SUBJECT:"); + expect(output).toContain("expected"); + expect(output).toContain("boolean"); + expect(error.issues[0]?.target).toBe("enabled"); + expect(error.issues[1]?.commandPath).toEqual(["root", "child"]); + expect(error.issues[1]?.parserPath).toBe("[1]"); + }); + + it("renders fallback text when no issue exists", () => { + expect(interpretError(createError("root"))).toContain("No issue found"); + }); +}); diff --git a/tests/command/help.test.ts b/tests/command/help.test.ts index bec8f94..6cc6b5c 100644 --- a/tests/command/help.test.ts +++ b/tests/command/help.test.ts @@ -1,14 +1,13 @@ -import { DP } from "@duplojs/utils"; +import { type ExpectType, DP, Printer } from "@duplojs/utils"; import { DServerCommand } from "@scripts"; -describe("logHelp", () => { +describe("help", () => { afterEach(() => { vi.clearAllMocks(); vi.restoreAllMocks(); }); - it("renders name, description, and options blocks", () => { - const renderSpy = vi.spyOn(DServerCommand.Printer, "render").mockImplementation(() => undefined); + it("renders name, description, options and subject blocks", () => { const command = DServerCommand.create( "root", { @@ -28,26 +27,49 @@ describe("logHelp", () => { }, ), ], + subject: DP.string(), }, () => Promise.resolve(undefined), ); - DServerCommand.logHelp(command, 1); + const lines = DServerCommand.renderCommandHelp(command, 1); + + type _CheckLines = ExpectType< + typeof lines, + string[], + "strict" + >; + + expect(lines).toContain( + `${Printer.indent(1)}${Printer.colorizedBold("NAME:", "green")}root`, + ); + expect(lines.join("\n")).toContain("Root command description"); + expect(lines.join("\n")).toContain(Printer.colorizedBold("OPTIONS:", "blue")); + expect(lines.join("\n")).toContain("--silent"); + expect(lines.join("\n")).toContain("Enable verbose mode"); + expect(lines).toContain( + `${Printer.indent(2)}${Printer.colorizedBold("SUBJECT:", "magenta")}`, + ); + }); - expect(renderSpy).toHaveBeenCalledTimes(3); - expect(renderSpy.mock.calls[0]![0]).toEqual([ - DServerCommand.Printer.indent(1), - DServerCommand.Printer.colorized("NAME:", "GREEN"), + it("logs rendered help lines", () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + const command = DServerCommand.create( "root", + () => Promise.resolve(undefined), + ); + + DServerCommand.logHelp(command, 1); + + expect(logSpy).toHaveBeenCalledTimes(1); + expect(logSpy.mock.calls[0]).toEqual([ + Printer.renderParagraph( + DServerCommand.renderCommandHelp(command, 1), + ), ]); - expect(renderSpy.mock.calls[1]![0]).toContain("Root command description"); - expect(renderSpy.mock.calls[2]![0]).toContain(DServerCommand.Printer.colorized("OPTIONS:", "BLUE")); - expect(renderSpy.mock.calls[2]![0].join("")).toContain("Enable verbose mode"); }); it("recursively renders child commands when subject is a command list", () => { - const renderSpy = vi.spyOn(DServerCommand.Printer, "render").mockImplementation(() => undefined); - const child = DServerCommand.create( "child", () => Promise.resolve(undefined), @@ -60,52 +82,64 @@ describe("logHelp", () => { () => Promise.resolve(undefined), ); - DServerCommand.logHelp(root); + const lines = DServerCommand.renderCommandHelp(root, 0); + const output = lines.join("\n"); - const renderedNames = renderSpy.mock.calls.map((call) => call[0]).flat(); + expect(output).toContain("root"); + expect(output).toContain("child"); + }); - expect(renderedNames).toContain("root"); - expect(renderedNames).toContain("child"); + it("renders tuple subjects without angle brackets", () => { + const command = DServerCommand.create( + "root", + { + subject: DP.tuple([DP.string(), DP.coerce.number()]), + }, + () => Promise.resolve(undefined), + ); + + expect(DServerCommand.renderCommandHelp(command, 0)).toContain( + `${Printer.indent(1)}${Printer.colorizedBold("SUBJECT:", "magenta")}[string, number]`, + ); }); it("formats supported subject data parser kinds", () => { - const renderSpy = vi.spyOn(DServerCommand.Printer, "render").mockImplementation(() => undefined); const cases = [ { subject: DP.string(), - expected: "", + expected: "string", }, { subject: DP.number(), - expected: "", + expected: "number", }, { subject: DP.bigint(), - expected: "", + expected: "bigint", }, { subject: DP.date(), - expected: "", + expected: "date", }, { subject: DP.time(), - expected: "