diff --git a/.junie/guidelines.md b/.junie/guidelines.md new file mode 100644 index 0000000..80af5d1 --- /dev/null +++ b/.junie/guidelines.md @@ -0,0 +1,239 @@ +# Development Guidelines for @kearisp/cli + +## Build Configuration + +### TypeScript Setup +The project uses TypeScript with two configuration files: + +- **`tsconfig.json`**: Main TypeScript configuration + - Target: ESNext + - Module: ESNext + - Module Resolution: node + - Output Directory: `./lib` + - Generates declaration files (`.d.ts`) + - Removes comments from output + - Incremental compilation with `.tsbuildinfo` file in `lib/` + +- **`tsconfig.build.json`**: Production build configuration + - Extends `tsconfig.json` + - Excludes test files (`**/*test.ts`, `**/*spec.ts`, `**/*e2e-spec.ts`) + - Used by the `npm run build` command + +### Build Commands + +```bash +# Build the project (used before publishing) +npm run build + +# Watch mode for development +npm run watch +``` + +The build process: +1. Compiles TypeScript files from `src/` to `lib/` +2. Generates type declaration files +3. Excludes all test files from the build output + +### Important Note on Module Type +The project uses `"type": "module"` in `package.json`, meaning it's an ES module package. + +## Testing + +### Test Configuration + +The project uses Jest with `ts-jest` preset for testing TypeScript files. + +**Key Jest Configuration Details** (`jest.config.ts`): +- **Test Location**: Tests can be in both `src/` and `test/` directories +- **Test Pattern**: Files matching `**/?(*.)+(spec|test).[tj]s?(x)` +- **Coverage**: Enabled by default, outputs to `coverage/` directory +- **Coverage Providers**: v8 (faster than babel) +- **Setup File**: `test/setup.ts` redirects console methods to custom Logger +- **Module Mapping**: `src/` is mapped for imports in tests +- **Important**: The `rootDir` should be set to `"."` (not `import.meta.dirname` due to TypeScript compatibility issues) + +### Test Environment Variable + +Tests should be run with `KP_LOG=disable` to suppress logging output: + +```bash +npm test +``` + +This environment variable is set automatically in the `package.json` test script. + +### Running Tests + +```bash +# Run all tests with coverage +npm test + +# Watch mode for all tests +npm run test-watch + +# Watch specific test files +npm run test-watch:cli # Watches Cli.spec.ts +npm run test-watch:command # Watches Command.spec.ts +npm run test-watch:parser # Watches Parser.spec.ts +``` + +After tests run, a coverage badge is automatically generated at `coverage/badge.svg` via the `posttest` script. + +### Writing Tests + +Tests are co-located with source files in the `src/` directory (e.g., `Command.ts` has `Command.spec.ts` in the same directory). + +**Basic Test Structure:** + +```typescript +import {expect, describe, it, beforeAll, afterEach} from "@jest/globals"; +import {Logger} from "../Logger"; + +describe("Your Test Suite", (): void => { + beforeAll((): void => { + Logger.mute(); // Mute logger output in tests + }); + + afterEach((): void => { + Logger.debug("-".repeat(15)); + Logger.mute(); + }); + + it("should test something", async (): Promise => { + // Your test code + expect(result).toBe(expected); + }); +}); +``` + +**Common Patterns in This Project:** +- Always import test functions from `@jest/globals` +- Use `Logger.mute()` in `beforeAll` to suppress console output +- Tests often use async/await with `Promise` return type +- Use explicit type annotations for test functions + +**Example Test:** + +```typescript +import {expect, describe, it} from "@jest/globals"; + +describe("Example Test Suite", (): void => { + it("should pass a basic test", (): void => { + const result = 2 + 2; + expect(result).toBe(4); + }); + + it("should work with arrays", (): void => { + const items = ["foo", "bar", "baz"]; + expect(items).toHaveLength(3); + expect(items).toContain("bar"); + }); +}); +``` + +### Adding New Tests + +1. Create a new file with `.spec.ts` extension in the `src/` directory (or subdirectory) +2. Import test functions from `@jest/globals` +3. Follow the existing patterns (use Logger.mute(), async functions, etc.) +4. Run `npm test` to execute all tests including your new ones + +## Code Style and Development Notes + +### Project Structure + +``` +kp-cli/ +├── src/ # Source code (TypeScript) +│ ├── makes/ # Core implementation +│ │ ├── Cli.ts # Main CLI class +│ │ ├── Cli.spec.ts # CLI tests +│ │ ├── Command.ts # Command implementation +│ │ ├── Command.spec.ts # Command tests +│ │ ├── CommandBuilder.ts # Command builder +│ │ ├── CommandInput.ts # Input handling +│ │ ├── CommandInput.spec.ts +│ │ ├── CommandParser.ts # Command parsing +│ │ ├── CommandParser.spec.ts +│ │ ├── Logger.ts # Custom logger +│ │ ├── Logger.spec.ts +│ │ ├── OptionParser.ts # Option parsing +│ │ ├── Parser.ts # Main parser +│ │ ├── Parser.spec.ts +│ │ └── index.ts # Exports +│ ├── errors/ # Custom error classes +│ │ ├── CommandNotFoundError.ts +│ │ ├── CommandWithoutAction.ts +│ │ ├── InvalidError.ts +│ │ └── index.ts +│ ├── types/ # TypeScript type definitions +│ │ ├── DefinitionMeta.ts +│ │ ├── Option.ts +│ │ ├── Param.ts +│ │ └── index.ts +│ ├── utils/ # Utility functions +│ │ ├── escapeRegExp.ts +│ │ ├── generateCompletion.ts +│ │ ├── isCommand.ts +│ │ ├── isSpread.ts +│ │ └── index.ts +│ ├── env.ts # Environment configuration +│ └── index.ts # Main entry point +├── test/ # Test utilities +│ └── setup.ts # Jest setup file +├── lib/ # Compiled output (generated, not in git) +├── coverage/ # Test coverage reports (generated) +├── jest.config.ts # Jest configuration +├── tsconfig.json # TypeScript configuration +├── tsconfig.build.json # Production build config +├── package.json # Package metadata and scripts +└── LICENSE # MIT License +``` + +### Import Conventions +- Use ES module imports (`import`/`export`) +- Path mapping for `src/` is configured in both TypeScript and Jest +- Import test utilities from `@jest/globals` + +### Logger Usage +The project has a custom Logger class that's used throughout: +- Console methods are redirected to Logger in test setup +- Use `Logger.mute()` to disable logging in tests +- Use `Logger.unmute()` when you need to debug tests +- The `KP_LOG=disable` environment variable controls logger behavior + +### Development Workflow + +1. **Making Changes:** + ```bash + npm run watch # Start TypeScript compiler in watch mode + ``` + +2. **Testing During Development:** + ```bash + npm run test-watch # Run tests in watch mode + # Or use specific watch scripts for individual test files + ``` + +3. **Before Committing:** + ```bash + npm test # Ensure all tests pass + npm run build # Ensure the project builds successfully + ``` + +4. **Pre-publish:** + The `prepublishOnly` script automatically runs `npm run build` before publishing + +### TypeScript Strictness +Note that strict type checking is NOT enabled in this project. The tsconfig has most strict flags commented out, allowing more flexibility but requiring developers to be careful with types. + +## Troubleshooting + +### Jest Configuration Issue +If you encounter an error about `import.meta.dirname` in `jest.config.ts`, ensure the `rootDir` is set to `"."` instead of `import.meta.dirname`. This is due to TypeScript module settings compatibility. + +### Test Failures Related to Logging +If tests are failing with unexpected console output, ensure: +1. The `KP_LOG=disable` environment variable is set +2. `Logger.mute()` is called in test setup +3. Check that `test/setup.ts` is being loaded (configured in `jest.config.ts`) diff --git a/.npmignore b/.npmignore index 83e8f0c..7185479 100644 --- a/.npmignore +++ b/.npmignore @@ -19,7 +19,6 @@ /package-lock.json /tsconfig.json /tsconfig.build.json -/.babelrc.js /jest.config.ts # Dependency directories @@ -30,3 +29,5 @@ jspm_packages/ # Development tools /.github +/.junie +/.aiassistant diff --git a/README.md b/README.md index eb6d561..94e216f 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,112 @@ # @kearisp/cli -## Description +[![npm version](https://img.shields.io/npm/v/@kearisp/cli.svg)](https://www.npmjs.com/package/@kearisp/cli) +[![Publish](https://github.com/kearisp/kp-cli/actions/workflows/publish-latest.yml/badge.svg?event=release)](https://github.com/kearisp/kp-cli/actions/workflows/publish-latest.yml) +[![License](https://img.shields.io/npm/l/@kearisp/cli)](https://github.com/kearisp/kp-cli/blob/master/LICENSE) -Command line interface for node.js +[![npm total downloads](https://img.shields.io/npm/dt/@kearisp/cli.svg)](https://www.npmjs.com/package/@kearisp/cli) +[![bundle size](https://img.shields.io/bundlephobia/minzip/@kearisp/cli)](https://bundlephobia.com/package/@kearisp/cli) +![Coverage](https://gist.githubusercontent.com/kearisp/f17f46c6332ea3bb043f27b0bddefa9f/raw/coverage-kp-cli-latest.svg) + + +## Overview + +A lightweight and flexible command-line interface framework for Node.js applications. This library provides a robust foundation for building CLI tools with support for commands, arguments, options, help documentation, and shell completion. + +### Features + +- **Type-safe command definitions** with TypeScript support +- **Flexible argument parsing** (required, optional, and spread arguments) +- **Rich option types** (string, boolean, number) +- **Built-in help generation** for commands and options +- **Shell completion support** for Bash and other shells +- **Custom command actions** with promise-based execution +- **ES Module architecture** for modern JavaScript + + +## Tech Stack + +- **Language:** TypeScript (compiles to ESNext) +- **Module System:** ES Modules (ESM) +- **Package Manager:** npm +- **Testing Framework:** Jest with ts-jest +- **Build Tool:** TypeScript Compiler (tsc) +- **Node.js Types:** @types/node + + +## Requirements + +- **Node.js:** Version supporting ES Modules (recommended: Node.js 14+) +- **npm:** For package installation and script execution ## Installation -```shell +Install the package using npm: + +```bash npm install @kearisp/cli ``` -## Usage -### Command +## Setup + +### For Usage in Your Project + +After installing via npm, import the library in your code: ```typescript import {Cli} from "@kearisp/cli"; +const cli = new Cli(); +// Define your commands... +``` + +## Breaking Changes in Version 3.0.0 + +The `v3.0.0` release introduces several breaking changes aimed at improving API consistency and usability. + +* **Argument and Option API:** The methods for accessing command arguments and options have been redesigned. + * The `input.arguments()` method, which previously returned an object with all arguments, has been replaced by `input.argument("argumentName")` to retrieve the value of a single argument. + * Similarly, `input.options()`, which returned an object with all options, has been replaced by `input.option("optionName")` to get the value of a single option. + * To get values from a spread argument, use `input.arguments("spreadArgumentName")`, which returns an array of values. + * To get all values for an option that can be specified multiple times, use `input.options("optionName")`, which returns an array of values. + +Here is a brief comparison: + +**Before (v2.x):** + +```typescript +// Get argument +const {bar} = input.arguments(); + +// Get option +const {baz} = input.options(); + +// Get spread argument +const items = input.argument("items"); +``` + +**After (v3.0.0):** + +```typescript +// Get argument +const bar = input.argument("bar"); + +// Get option +const baz = input.option("baz"); + +// Get spread argument (returns an array) +const items = input.arguments("items"); +``` + + +## Usage + +### Basic Command + +```typescript +import {Cli} from "@kearisp/cli"; const cli = new Cli(); @@ -33,18 +122,17 @@ cli.run(process.argv).then((res) => { }); ``` -### Command argument +### Command Arguments -`` - required argument +Arguments can be required or optional, and support spread syntax: -`[bar]` - not required argument +- `` - Required argument +- `[bar]` - Optional argument +- `<...bars>` - Required spread argument (array) +- `[...bars]` - Optional spread argument (array) > ℹ️ The spread is not stable now -`<...bars>` - required spread argument - -`[...bars]` - not required spread argument - ```typescript cli.command("foo [foo2]") .action((input: CommandInput) => { @@ -55,17 +143,17 @@ cli.command("foo [foo2]") ```typescript cli.command("bar [...bars]") .action((input: CommandInput) => { - return "Bar result, Bars: " + input.argument("bars").join(", "); + return "Bar result, Bars: " + input.arguments("bars").join(", "); }); ``` -### Command option +### Command Options -Types: +Options support multiple types: -- string -- boolean -- number +- `string` +- `boolean` +- `number` ```typescript cli.command("foo") @@ -78,36 +166,34 @@ cli.command("foo") alias: "i" }) .action((input: CommandInput) => { - const { - bar = "", - init = false - } = input.options(); + const bar = input.option("bar"), + init = input.option("init"); return `Foo result, with options bar=${bar} init=${init.toString()}`; }); ``` -### Help +### Help Documentation ```typescript cli.command("foo") .help({ description: "Foo description" }) - .option("option", { + .option("foo-option", { alias: "o", description: "Option description" }) .action((input) => { - const { - option = "" - } = input.options(); + const option = input.option("foo-option"); return `option=${option}`; }); ``` -```shell +Display help: + +```bash ./cli.js foo -h ``` @@ -119,16 +205,13 @@ cli.command("foo") > --option, -o - Option description > ``` - -### Completion +### Shell Completion ```typescript cli.command("foo ") .completion("bar", () => ["value1", "value2", "value3"]) .action((input) => { - const { - bar = "" - } = input.arguments(); + const bar = input.argument("bar"); return `Foo result, with argument bar=${bar}`; }); @@ -139,8 +222,29 @@ cli.command("completion script") }); ``` -#### Bash completion +#### Bash Completion -```shell +Enable completion in your shell: + +```bash source <(your-script.js completion script) ``` + + +## License + +This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. + +**Copyright (c) 2021 Kris Papercut** + + +## Links + +- **npm Package:** https://www.npmjs.com/package/@kearisp/cli +- **GitHub Repository:** https://github.com/kearisp/kp-cli +- **Issue Tracker:** https://github.com/kearisp/kp-cli/issues + + +## Contributing + +Contributions are welcome! Please feel free to submit issues or pull requests to the GitHub repository. diff --git a/jest.config.ts b/jest.config.ts index 12aeaf0..6b7d5d7 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -41,12 +41,12 @@ const config: Config.InitialOptionsWithRootDir = { coverageProvider: "v8", // A list of reporter names that Jest uses when writing coverage reports - // coverageReporters: [ - // "json", - // "text", - // "lcov", - // "clover" - // ], + coverageReporters: [ + "text-summary", + "lcovonly", + "json-summary", + "html" + ], // An object that configures minimum threshold enforcement for coverage results // coverageThreshold: undefined, @@ -121,17 +121,17 @@ const config: Config.InitialOptionsWithRootDir = { // restoreMocks: false, // The root directory that Jest should scan for tests and modules within - rootDir: __dirname, + rootDir: import.meta.dirname, // A list of paths to directories that Jest should use to search for files in roots: [ "/src", - // "/test" + "/test" ], // The paths to modules that run some code to configure or set up the testing environment before each test setupFiles: [ - "/test/index.ts" + "/test/setup.ts" ], // A list of paths to modules that run some code to configure or set up the testing framework before each test // setupFilesAfterEnv: [ diff --git a/package.json b/package.json index f3f15a4..e5a4661 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "name": "@kearisp/cli", - "version": "2.0.9", + "type": "module", + "version": "3.0.0", "license": "MIT", "author": "Kris Papercut ", "description": "Command line interface for node.js", @@ -19,21 +20,23 @@ "url": "https://github.com/kearisp/kp-cli/issues" }, "scripts": { - "prepare": "npm run build", - "build": "tsc", - "watch": "tsc --watch", - "test": "KP_LOG=disable jest --colors --no-coverage", + "prepublishOnly": "npm run build", + "build": "tsc --project tsconfig.build.json", + "watch": "tsc -w --project tsconfig.build.json", + "test": "KP_LOG=disable jest --colors", + "posttest": "make-coverage-badge", "test-watch": "jest --colors --coverage --no-cache --watchAll", "test-watch:cli": "jest --colors --watchAll --runTestsByPath ./src/makes/Cli.spec.ts", "test-watch:command": "jest --colors --watchAll --runTestsByPath ./src/makes/Command.spec.ts", "test-watch:parser": "jest --colors --watchAll --runTestsByPath ./src/makes/Parser.spec.ts" }, "devDependencies": { - "@types/jest": "^29.5.14", - "@types/node": "^22.13.0", - "jest": "^29.7.0", - "ts-jest": "^29.2.5", + "@types/jest": "^30.0.0", + "@types/node": "^24.5.2", + "jest": "^30.1.3", + "make-coverage-badge": "^1.2.0", + "ts-jest": "^29.4.3", "ts-node": "^10.9.2", - "typescript": "^5.7.3" + "typescript": "^5.9.2" } } diff --git a/src/errors/CommandWithoutAction.ts b/src/errors/CommandWithoutAction.ts new file mode 100644 index 0000000..74c3782 --- /dev/null +++ b/src/errors/CommandWithoutAction.ts @@ -0,0 +1,5 @@ +export class CommandWithoutAction extends Error { + public constructor() { + super("Command without action"); + } +} diff --git a/src/errors/index.ts b/src/errors/index.ts index 23d6062..c8b40dc 100644 --- a/src/errors/index.ts +++ b/src/errors/index.ts @@ -1,2 +1,3 @@ export * from "./CommandNotFoundError"; +export * from "./CommandWithoutAction"; export * from "./InvalidError"; diff --git a/src/makes/Cli.spec.ts b/src/makes/Cli.spec.ts index 250f4ea..16c5a02 100644 --- a/src/makes/Cli.spec.ts +++ b/src/makes/Cli.spec.ts @@ -1,17 +1,19 @@ -import {expect, describe, it, beforeEach} from "@jest/globals"; +import {expect, describe, it, beforeEach, afterEach} from "@jest/globals"; import * as OS from "os"; -// import * as assert from "assert"; +import {Cli, Logger} from ".."; -import {Logger} from ".."; -import {Cli} from ".."; +describe("Cli.run", (): void => { + beforeEach((): void => { + Logger.mute(); + }); -describe("Cli.run", () => { - beforeEach(() => { + afterEach((): void => { + Logger.debug("-".repeat(10)); Logger.mute(); }); - it("Should be processed simple command", async (): Promise => { + it("should be processed simple command", async (): Promise => { const cli = new Cli(); cli.command("completion") @@ -24,7 +26,7 @@ describe("Cli.run", () => { expect(await cli.run(["node", "cli", "init"])).toBe("Init"); }); - it("Should be processed command with argument", async (): Promise => { + it("should be processed command with argument", async (): Promise => { const cli = new Cli(); cli.command("process ") @@ -37,7 +39,7 @@ describe("Cli.run", () => { expect(res).toBe("process-name"); }); - it("Should be processed with option", async (): Promise => { + it("should be processed with option", async (): Promise => { const cli = new Cli(); cli.command("process") @@ -55,7 +57,7 @@ describe("Cli.run", () => { expect(await cli.run(["node", "cli", "process", "-n", "test"])).toBe("test"); }); - it("Should be completed", async (): Promise => { + it("should be completed", async (): Promise => { const cli = new Cli(); cli.command("init"); @@ -86,7 +88,7 @@ describe("Cli.run", () => { .toEqual(""); }); - it("Should be help", async (): Promise => { + it("should be help", async (): Promise => { const cli = new Cli(); cli.command("completion").action(() => { @@ -117,7 +119,23 @@ describe("Cli.run", () => { expect(res).toContain("--option"); }); - it("Should handle empty command with options", async (): Promise => { + it("should be help without required argument", async (): Promise => { + Logger.unmute(); + + const cli = new Cli(); + + cli.command("run ") + .help({ + description: "Run description" + }); + + const res = await cli.run(["node", "cli", "run", "-h"]); + + expect(res).toContain("Run description"); + expect(res).toContain("run "); + }); + + it("should handle empty command with options", async (): Promise => { const cli = new Cli(); cli.command("") diff --git a/src/makes/Cli.ts b/src/makes/Cli.ts index 3711413..efffb00 100644 --- a/src/makes/Cli.ts +++ b/src/makes/Cli.ts @@ -1,11 +1,11 @@ import * as OS from "os"; import * as Path from "path"; - -import {CommandNotFoundError} from "../errors/CommandNotFoundError"; -import {InvalidError} from "../errors/InvalidError"; import {Command} from "./Command"; +import {CommandBuilder} from "./CommandBuilder"; import {Logger} from "./Logger"; +import {CommandNotFoundError, InvalidError} from "../errors"; import {generateCompletion} from "../utils"; +import {CommandInput} from "./CommandInput"; export class Cli { @@ -66,10 +66,8 @@ export class Cli { return args.length < index ? [...args, ""] : args; } - public command(name: string): Command { - let command = this.commands.find((command) => { - return command.name === name; - }); + public command(name: string): CommandBuilder { + let command = this.commands.find((command) => command.definition === name); if(!command) { command = new Command(name); @@ -77,14 +75,21 @@ export class Cli { this.commands.push(command); } - return command; + return new CommandBuilder(this, command); } protected async process(parts: string[]): Promise { + const unprocessed = new Map(); + for(const command of this.commands) { try { const input = command.parse(parts); + if(!input.processed) { + unprocessed.set(command, input); + continue; + } + return command.emit(this.name, input); } catch(err) { @@ -94,6 +99,12 @@ export class Cli { } } + for(const [command, input] of unprocessed.entries()) { + if(input.option("help")) { + return command.emit(this.name, input); + } + } + throw new CommandNotFoundError(); } @@ -123,10 +134,6 @@ export class Cli { this.name = Path.basename(scriptPath); this.command("complete [index] [command]") - .help({ - description: "Generate completion script", - disabled: true - }) .option("compbash", { type: "boolean" }) @@ -136,13 +143,15 @@ export class Cli { .option("compzsh", { type: "boolean" }) + .help({ + disabled: true, + description: "Generate completion script" + }) .action(async (input): Promise => { - const index = input.argument("index"); - const command = input.argument("command"); - - const parts = this.parseCommand(command, parseInt(index)); - - const res = await this.complete(parts); + const index = input.argument("index"), + command = input.argument("command"), + parts = this.parseCommand(command, parseInt(index)), + res = await this.complete(parts); return res .map((predict) => { diff --git a/src/makes/Command.spec.ts b/src/makes/Command.spec.ts index 681a684..f21ff23 100644 --- a/src/makes/Command.spec.ts +++ b/src/makes/Command.spec.ts @@ -1,6 +1,5 @@ import {expect, describe, it, afterEach, beforeAll} from "@jest/globals"; - -import {InvalidError} from "../errors/InvalidError"; +import {InvalidError} from "../errors"; import {Command} from "./Command"; import {Logger} from "./Logger"; @@ -15,7 +14,7 @@ describe("Command.parse", (): void => { Logger.mute(); }); - it("Should be parsed", async (): Promise => { + it("should be parsed", async (): Promise => { const command = (new Command("test [name]")) .setDescription("Test description") .help({ @@ -29,7 +28,7 @@ describe("Command.parse", (): void => { let input = command.parse(["test", "John"]); expect(input.argument("name")).toBe("John"); - expect(input.options()).toEqual([]); + expect(input.options("name")).toEqual([]); input = command.parse(["test", "John", "-n=test"]); @@ -37,27 +36,56 @@ describe("Command.parse", (): void => { expect(input.option("name")).toBe("test"); }); - it("Should be parsed without optional argument", async () => { + // it("should parse with missing required argument", async (): Promise => { + // const command = (new Command("test ")); + // + // let input = command.parse(["test"]); + // + // }); + + it("should be parsed without optional argument", async (): Promise => { const command = (new Command("use [name]")); expect(command.parse(["use"])).toEqual({ - _arguments: {}, - _options: [] + _arguments: [], + _options: [], + processed: true }); }); - it("Should be parsed spread", async (): Promise => { + it("should be parsed spread", async (): Promise => { const command = (new Command("config [...config]")); expect(command.parse(["config", "John", "--test", "-n=test"])).toEqual({ - _arguments: { - config: ["John", "--test", "-n=test"] - }, - _options: [] + _arguments: [ + {name: "config", value: "John"}, + {name: "config", value: "--test"}, + {name: "config", value: "-n=test"} + ], + _options: [], + processed: true + }); + }); + + it("should parse spread with options", async (): Promise => { + const command = (new Command("config [...rest]")) + .option("force", { + type: "boolean", + alias: "f" + }); + + expect(command.parse(["config", "-f", "foo"])).toEqual({ + _arguments: [ + {name: "rest", value: "foo"}, + ], + _options: [ + {name: "force", value: true} + ], + processed: true }); }); - it("Should be parsed multiple options", async (): Promise => { + it("should be parsed multiple options", async (): Promise => { const command = (new Command("cli")) .option("foo", { type: "boolean", @@ -74,36 +102,37 @@ describe("Command.parse", (): void => { expect(command.parse(["cli", "-fb"])) .toEqual({ - _arguments: {}, + _arguments: [], _options: [ {name: "foo", value: true}, {name: "bar", value: true} - ] + ], + processed: true }); expect(command.parse(["cli", "-bf"])) .toEqual({ - _arguments: {}, + _arguments: [], _options: [ {name: "bar", value: true}, {name: "foo", value: true} - ] + ], + processed: true }); - Logger.unmute(); - Logger.info(command.parse(["cli", "-bff"])); expect(command.parse(["cli", "-bff"])) .toEqual({ - _arguments: {}, + _arguments: [], _options: [ {name: "bar", value: true}, {name: "foo", value: true}, {name: "foo", value: true} - ] + ], + processed: true }); }); - it("Should be help", async (): Promise => { + it("should be help", async (): Promise => { const command = (new Command("test [name]")) .help({ description: "Test description" @@ -114,30 +143,28 @@ describe("Command.parse", (): void => { }); expect(command.parse(["test", "test", "--help"])).toEqual({ - _arguments: { - name: "test" - }, + _arguments: [ + {name: "name", value: "test"} + ], _options: [ {name: "help", value: true} - ] + ], + processed: true }); }); - it("Should be error without required argument", async (): Promise => { + it("should be parsed without required argument", async (): Promise => { const command = new Command("use "); - try { - command.parse(["use"]); - - throw new Error("Completed successfully"); - } - catch(err) { - expect(err).toBeInstanceOf(InvalidError); - } + expect(command.parse(["use"])).toEqual({ + _arguments: [], + _options: [], + processed: false + }); }); - it("Should be options", async (): Promise => { - const command = new Command("command") + it("should be options", async (): Promise => { + const command = new Command("command ") .option("name", { type: "string", alias: "n" @@ -151,9 +178,11 @@ describe("Command.parse", (): void => { const input = command.parse([ "command", "-n=test1", "-n=test2", "-n=test3", - "-d=desc1", "-d=desc2" + "-d=desc1", "-d=desc2", + "test-value" ]); + expect(input.argument("name")).toEqual("test-value"); expect(input.options("name")).toEqual(["test1", "test2", "test3"]); expect(input.option("description")).toBe("desc1"); expect(input.options("test")).toEqual([]); @@ -178,25 +207,25 @@ describe("Command.complete", (): void => { Logger.mute(); }); - it("Should predict first command", async (): Promise => { + it("should predict first command", async (): Promise => { const res = await command.complete(["te"]); expect(res).toEqual(["test"]); }); - it("Should predict optional argument", async (): Promise => { + it("should predict optional argument", async (): Promise => { const res = await command.complete(["test", ""]); expect(res).toEqual(["foo", "bar"]); }); - it("Should predict option name", async (): Promise => { + it("should predict option name", async (): Promise => { const res = await command.complete(["test", "foo", "-"]); expect(res).toEqual(["-n", "--name"]) }); - it("Should predict option value", async (): Promise => { + it("should predict option value", async (): Promise => { const command = (new Command("test")) .option("name", { type: "string", @@ -215,10 +244,10 @@ describe("Command.complete", (): void => { expect(await command.complete(["test", "-a=1", "-n", ""])).toEqual(["foo", "bar"]); }); - it("Should predict second argument depends on first", async (): Promise => { + it("should predict second argument depends on first", async (): Promise => { const command = (new Command("[arg1] [arg2]")) .completion("arg2", (input) => { - if(input.argument("arg1") === "foo") { + if (input.argument("arg1") === "foo") { return ["right"]; } @@ -230,41 +259,30 @@ describe("Command.complete", (): void => { expect(res).toEqual(["right"]); }); - it("Should predict spread", async (): Promise => { + it("should predict spread", async (): Promise => { const command = (new Command("[...name]")) .completion("name", () => { return ["foo", "bar"]; }); expect(await command.complete([""])).toEqual(["foo", "bar"]); + + Logger.unmute(); + expect(await command.complete(["foo", ""])).toEqual(["bar"]); }); - it("Should throw error on command", async (): Promise => { - const command = new Command("test [name]"); + it("should throw error on command", async (): Promise => { + const command = new Command("test [name]"); - try { - await command.complete(["lest", "lol"]); - - throw Error("Completed successfully"); - } - catch(err) { - expect(err).toBeInstanceOf(InvalidError); - } + await expect(command.complete(["lest", "lol"])).rejects.toBeInstanceOf(InvalidError); }); - it("Should throw error on option", async (): Promise => { - try { - await command.complete(["test", "foo", "--no"]); - - throw new Error("Completed successfully"); - } - catch(err) { - expect(err).toBeInstanceOf(Error); - } + it("should return an empty array if the option is not found", async (): Promise => { + await expect(command.complete(["test", "foo", "--no"])).resolves.toEqual([]); }); - it("Should has argument in input", async (): Promise => { + it("should has argument in input", async (): Promise => { const command = (new Command("[arg1] [arg2]")) .completion("arg1", () => { return ["test"]; @@ -278,7 +296,7 @@ describe("Command.complete", (): void => { await command.complete(["test"]); }); - it("Should has current argument in input", async (): Promise => { + it("should has current argument in input", async (): Promise => { const command = (new Command(" ")) .completion("arg1", (input): string[] => { expect(input.argument("arg1")).toBe("123"); diff --git a/src/makes/Command.ts b/src/makes/Command.ts index 08dbf5f..188986e 100644 --- a/src/makes/Command.ts +++ b/src/makes/Command.ts @@ -1,10 +1,11 @@ import * as OS from "os"; - -import {Option, OptionValue, Param} from "../types"; +import {Option, ParamValue, OptionValue, DefinitionMeta} from "../types"; import {escapeRegExp} from "../utils"; -import {InvalidError} from "../errors/InvalidError"; +import {InvalidError, CommandWithoutAction} from "../errors"; import {Parser} from "./Parser"; import {CommandInput} from "./CommandInput"; +import {CommandParser} from "./CommandParser"; +import {OptionParser} from "./OptionParser"; type HelpParams = false | { @@ -12,10 +13,6 @@ type HelpParams = false | { description?: string; }; -type OptionParams = Omit & { - type?: Option["type"]; -}; - type Completion = { name: string; isOption?: boolean; @@ -24,36 +21,34 @@ type Completion = { type Action = (input: CommandInput) => void | string | Promise; - export class Command { - protected _command: string; - protected _help: boolean; + protected _definitionMeta: DefinitionMeta[]; protected _description: string; + protected _help: boolean; protected _options: Option[] = []; - protected __options: { - [name: string]: OptionParams; - } = {}; - protected _action: Action; protected _completions: Completion[] = []; + protected _action: Action; - public constructor(command: string) { - this._command = command; + public constructor( + public readonly definition: string + ) { + this._definitionMeta = CommandParser.parse(this.definition); this._help = true; } - public get name(): string { - return this._command; + public get length(): number { + return this._definitionMeta.length; } - protected getCommandInput(params: any, optionValues: OptionValue[] = []): CommandInput { + protected getCommandInput(params: ParamValue[], optionValues: OptionValue[] = []): CommandInput { return new CommandInput(params, optionValues); } - public option(name: string, params: OptionParams) { + public option(name: string, params: Omit): this { const { - type = "boolean", - default: defaultValue, + type, help = true, + default: defaultValue, ...rest } = params || {}; @@ -66,32 +61,30 @@ export class Command { help, type: type as Option["type"], default: type === "boolean" - ? typeof defaultValue === "boolean" ? defaultValue : false + ? typeof defaultValue === "boolean" ? defaultValue : true : defaultValue, ...rest } ]; - this.__options[name] = params; - return this; } - protected getOptionSettings(name?: string, alias?: string) { + protected getOptionSettings(name?: string, alias?: string): Option { return this._options.find((option) => { return (name && option.name === name) || (alias && option.alias === alias); }); } - public setDescription(description: string) { + public setDescription(description: string): this { this._description = description; return this; } - public help(params: HelpParams) { + public help(params: HelpParams): this { const { - disabled= false, + disabled = false, description = "" } = typeof params === "boolean" ? { disabled: params, @@ -104,15 +97,15 @@ export class Command { this.option("help", { type: "boolean", alias: "h", - description: "Help", - help: false + help: false, + description: "Help" }); } return this; } - public completion(name: Completion["name"], handle: Completion["action"]) { + public completion(name: Completion["name"], handle: Completion["action"]): this { this._completions.push({ name, action: handle @@ -121,153 +114,132 @@ export class Command { return this; } - public action(action: Action) { + public action(action: Action): this { this._action = action; return this; } public parse(parts: string[]): CommandInput { - const commands = [ - "command", - ...this._command - ? this._command.trim().split(/\s+/g) - : [] - ]; - - const args: { - [name: string]: string | boolean | number | string[]; - } = {}; - const optionValues: OptionValue[] = []; - - const parser = new Parser(["command", ...parts]); - - for(let i = 0; i < commands.length; i++) { - const command = commands[i]; - const nextCommand = commands[i + 1]; - let spread = false; - - if(parser.isSpread(command)) { - spread = true; - } - else if(parser.isCommand(command)) { - const res = parser.getArguments(command); - - for(const name in res) { - args[name] = res[name]; - } + const input = [...parts], + parser = new CommandParser(this.definition, this._definitionMeta), + argumentValues: ParamValue[] = [], + optionValues: OptionValue[] = []; + + const applyOption = (option: Option, value?: string): void => { + switch(option.type) { + case "boolean": + optionValues.push({ + name: option.name, + value: typeof value === "undefined" + ? (option.default ?? true) + : value === "1" || value.toLowerCase() === "true" + }); + break; - parser.next(); - } - else { - throw new InvalidError("Invalid command"); - } + case "number": + optionValues.push({ + name: option.name, + value: typeof value === "undefined" + ? (option.default ?? 0) + : parseFloat(value) + }); + break; - if(nextCommand && parser.isSpread(nextCommand)) { - continue; + case "string": + optionValues.push({ + name: option.name, + value: typeof value === "undefined" + ? (option.default ?? "") + : value + }); + break; } + }; - while(parser.isOption()) { - if(parser.isRegOption()) { - const {name, alias} = parser.parseOption(); + let spread = false; - const option = this.getOptionSettings(name, alias); + while(input.length > 0) { + let part = input.shift(), + nextPart = input[0] || ""; - if(option) { - switch(option.type) { - case "boolean": - optionValues.push({ - name: option.name, - value: true - }); - break; + if(!spread && OptionParser.isSingleWithoutValue(part)) { + const { + name, + alias + } = OptionParser.parse(part); - case "number": - parser.next(); - optionValues.push({ - name: option.name, - value: parseFloat(parser.part) - }); - break; + const option = this.getOptionSettings(name, alias); - case "string": - parser.next(); - optionValues.push({ - name: option.name, - value: parser.part - }); - break; + if(option) { + switch(option.type) { + case "boolean": { + applyOption(option, undefined); + break; } + + case "number": + case "string": + applyOption(option, !nextPart.startsWith("-") ? input.shift() : undefined); + break; } } - else if(parser.isOptionWithValue()) { - const {name, alias, value} = parser.parseOptionWithValue(); + } + else if(!spread && OptionParser.isSingleWithValue(part)) { + const { + name, + alias, + value + } = OptionParser.parseWithValue(part); - const option = this.getOptionSettings(name, alias); + const option = this.getOptionSettings(name, alias); - if(option) { - switch(option.type) { - case "boolean": - optionValues.push({ - name: option.name, - value: true - }); - break; + if(option) { + applyOption(option, value); + } + } + else if(!spread && OptionParser.isMultiple(part)) { + const {alias} = OptionParser.parse(part); - case "number": - optionValues.push({ - name: option.name, - value: parseFloat(value) - }); - break; + alias.split("").forEach((alias: string) => { + const option = this.getOptionSettings(undefined, alias); - case "string": - optionValues.push({ - name: option.name, - value - }); - break; - } + if(option) { + applyOption(option, undefined); } - } - else if(parser.isMultipleOptions()) { - parser.parseOptionMultiple().forEach((alias: string) => { - const option = this.getOptionSettings(undefined, alias); + }); + } + else if(!parser.eol && parser.match(part)) { + const res = parser.parse(part); - if(option && option.type === "boolean") { - optionValues.push({ - name: option.name, - value: true - }); - } + for(const name in res) { + argumentValues.push({ + name, + value: res[name] }); } - parser.next(); - } - - if(spread) { - const name = parser.parseSpreadCommand(command); - - const values: string[] = []; - - while(!parser.eol) { - values.push(parser.part); - + if(!parser.isSpread()) { parser.next(); } - - args[name] = values; - - parser.next(); + else { + spread = true; + } + } + else { + throw new InvalidError("Invalid command"); } } - if(!parser.eol) { - throw new InvalidError("Haven't ended"); + while(!parser.eol) { + if(!parser.match("")) { + break; + } + + parser.next(); } - return this.getCommandInput(args, optionValues); + return new CommandInput(argumentValues, optionValues, parser.eol); } public async emit(name: string, input: CommandInput): Promise { @@ -278,7 +250,7 @@ export class Command { return [ "", - `Usage: ${name} ${this.name}`, + `Usage: ${name} ${this.definition}`, "", ...this._description ? [ this._description, @@ -297,7 +269,7 @@ export class Command { } if(!this._action) { - throw new Error("Command without action"); + throw new CommandWithoutAction(); } const res = await this._action(input); @@ -309,15 +281,163 @@ export class Command { return res; } - protected async predictCommand(command: string, part: string, input: CommandInput) { + public async complete(parts: string[]): Promise { + if(!this._help) { + return []; + } + + const commands = this.definition + ? this.definition.split(/\s+/g) + : []; + const parser = new Parser(parts), + options: any = {}, + paramValues: ParamValue[] = [], + optionValues: OptionValue[] = []; + + for(const command of commands) { + if(parser.isSpread(command)) { + const name = parser.parseSpreadCommand(command); + + while(!parser.eol) { + if(parser.part) { + paramValues.push({ + name, + value: parser.part + }); + } + + parser.next(); + } + + return this.predictCommand(command, parser.part, this.getCommandInput(paramValues, optionValues)); + } + else if(!parser.isLast && parser.isCommand(command)) { + const partArguments = parser.getArguments(command); + + for(const name in partArguments) { + paramValues.push({ + name, + value: partArguments[name], + }) + } + + parser.next(); + } + else if(parser.isLast && parser.isCommand(command, true)) { + const partArguments = parser.getArguments(command); + + for(const name in partArguments) { + paramValues.push({ + name, + value: partArguments[name], + }); + } + + return this.predictCommand(command, parser.part, this.getCommandInput(paramValues, optionValues)); + } + else { + throw new InvalidError("Error"); + } + + while(parser.isOption(true)) { + const { + dash, + name, + sign, + value + } = parser.parseOptionV2(); + + const option = name ? this._options.find((option) => { + if(dash === "-" && name.length === 1) { + return option.alias === name; + } + + return option.name === name; + }) : undefined; + + if(!option && !sign && parser.isLast) { + return this._options.reduce((res: string[], option) => { + if(dash === "-" && option.alias && (!name || name === option.alias)) { + res.push(`-${option.alias}`); + } + + if(!name || option.name.startsWith(name)) { + res.push(`--${option.name}`); + } + + return res; + }, []); + } + + if(option) { + switch(option.type) { + case "boolean": + options[option.name] = true; + optionValues.push({ + name: option.name, + value: true + }); + break; + + case "string": + case "number": + let v: any = value; + + if(!parser.isLast && sign !== "=") { + parser.next(); + + v = parser.part; + } + + if(option.type === "number") { + v = parseFloat(v); + } + + if(parser.isLast) { + const completion = this._completions.find((completion) => { + return completion.name === option.name; + }); + + if(!completion) { + return []; + } + + const predicts = await completion.action(this.getCommandInput(paramValues, optionValues)); + + return predicts.map((predict): string => { + if(sign === "=") { + return `${dash}${name}${sign}${predict}`; + } + + return predict; + }); + } + + options[option.name] = v; + optionValues.push({ + name: option.name, + value: v + }); + break; + } + } + + parser.next(); + } + } + + return []; + } + + protected async predictCommand(command: string, part: string, input: CommandInput): Promise { const comOther = /^([^\[\]<>{}]+)(.*)$/; - let exitCount = 0; - let reg = ""; - let restCommand = command; - let isAction = false; - let predict = ""; - let resPredicts = [""]; + let exitCount = 0, + reg = "", + restCommand = command, + isAction = false, + predict = "", + resPredicts = [""]; while(restCommand) { let stepReg: string; @@ -376,7 +496,7 @@ export class Command { if(completion) { let predicts: string[] = (await completion.action(input)); - const value = input.argument(predict) as string|string[]; + const value = input.arguments(predict); if(Array.isArray(value)) { predicts = predicts.filter((p) => { @@ -413,7 +533,7 @@ export class Command { return resPredicts; } - protected async predictOption(part: string, input: CommandInput) { + protected async predictOption(part: string, input: CommandInput): Promise { const [, dash, name, sign, value] = /^(--?)(\w+)?(=)?(.+)?/.exec(part) || []; const option = this._options.find((option) => { @@ -456,189 +576,4 @@ export class Command { return ``; }); } - - public async complete(parts: string[]): Promise { - if(!this._help) { - return []; - } - - const commands = this._command - ? this._command.split(/\s+/g) - : []; - const parser = new Parser(parts); - - const args: any = {}; - const options: any = {}; - const optionValues: OptionValue[] = []; - - for(const command of commands) { - if(parser.isSpread(command)) { - const name = parser.parseSpreadCommand(command); - const value = []; - - while(!parser.eol) { - if(parser.part) { - value.push(parser.part); - } - - parser.next(); - } - - args[name] = value; - - return this.predictCommand(command, parser.part, this.getCommandInput(args, optionValues)); - } - else if(!parser.isLast && parser.isCommand(command)) { - const partArguments = parser.getArguments(command); - - for(const name in partArguments) { - args[name] = partArguments[name]; - } - - parser.next(); - } - else if(parser.isLast && parser.isCommand(command, true)) { - const partArguments = parser.getArguments(command); - - for(const name in partArguments) { - args[name] = partArguments[name]; - } - - return this.predictCommand(command, parser.part, this.getCommandInput(args, optionValues)); - } - else { - throw new InvalidError("Error"); - } - - while(parser.isOption(true)) { - const { - dash, - name, - sign, - value - } = parser.parseOptionV2(); - - const option = name ? this._options.find((option) => { - if(dash === "-") { - return option.alias === name; - } - - return option.name === name; - }) : undefined; - - if(!option && !sign && parser.isLast) { - return this._options.reduce((res: string[], option) => { - if(dash === "-" && option.alias) { - res.push(`-${option.alias}`); - } - - res.push(`--${option.name}`); - - return res; - }, []); - } - - if(option) { - switch(option.type) { - case "boolean": - options[option.name] = true; - optionValues.push({ - name: option.name, - value: true - }); - break; - - case "string": - case "number": - let v: any = value; - - if(!parser.isLast && sign !== "=") { - parser.next(); - - v = parser.part; - } - - if(option.type === "number") { - v = parseFloat(v); - } - - if(parser.isLast) { - const completion = this._completions.find((completion) => { - return completion.name === option.name; - }); - - if(!completion) { - return []; - } - - const predicts = await completion.action(this.getCommandInput(args, optionValues)); - - return predicts.map((predict): string => { - if(sign === "=") { - return `${dash}${name}${sign}${predict}`; - } - - return predict; - }); - } - - options[option.name] = v; - optionValues.push({ - name: option.name, - value: v - }); - break; - } - } - - // if(parser.isLast) { - // return this.predictOption(parser.part, this.getCommandInput(args, options)); - // } - // else if(parser.isRegOption()) { - // const {name, alias} = parser.parseOption(); - // - // const option = this.getOptionSettings(name, alias); - // - // if(option) { - // switch(option.type) { - // case "boolean": - // break; - // - // case "number": - // break; - // - // case "string": - // parser.next(); - // options[option.name] = parser.part; - // break; - // } - // } - // } - // else if(parser.isOptionWithValue()) { - // const {name, alias, value} = parser.parseOptionWithValue(); - // - // const option = this.getOptionSettings(name, alias); - // - // if(option) { - // switch(option.type) { - // case "boolean": - // break; - // - // case "number": - // options[option.name] = parseFloat(value); - // break; - // - // case "string": - // options[option.name] = value; - // break; - // } - // } - // } - - parser.next(); - } - } - - return []; - } } diff --git a/src/makes/CommandBuilder.ts b/src/makes/CommandBuilder.ts new file mode 100644 index 0000000..03b501f --- /dev/null +++ b/src/makes/CommandBuilder.ts @@ -0,0 +1,63 @@ +import {Cli} from "./Cli"; +import {Option} from "../types"; +import {Command} from "./Command"; +import {CommandInput} from "./CommandInput"; + + +type HelpParams = false | { + disabled?: boolean; + description?: string; +}; + +type Completion = { + name: string; + isOption?: boolean; + action: (input: CommandInput) => string[] | Promise; +}; + +type Action = (input: CommandInput) => void | string | Promise; + +export class CommandBuilder { + public constructor( + protected readonly cli: Cli, + protected readonly command: Command + ) {} + + public option(name: string, params: Omit): this { + this.command.option(name, params); + + return this; + } + + public description(description: string): this { + this.command.setDescription(description); + + return this; + } + + /** + * @deprecated + * @see description + */ + public setDescription(description: string): this { + return this.description(description); + } + + public help(params: HelpParams): this { + this.command.help(params); + + return this; + } + + public action(action: Action): this { + this.command.action(action); + + return this; + } + + public completion(name: Completion["name"], handle: Completion["action"]): this { + this.command.completion(name, handle); + + return this; + } +} diff --git a/src/makes/CommandInput.spec.ts b/src/makes/CommandInput.spec.ts index c871688..0bac7ca 100644 --- a/src/makes/CommandInput.spec.ts +++ b/src/makes/CommandInput.spec.ts @@ -1,5 +1,4 @@ import {describe, it, afterEach, beforeAll, expect} from "@jest/globals"; - import {CommandInput} from "./CommandInput"; import {Logger} from "./Logger"; @@ -15,7 +14,9 @@ describe("CommandInput.option", (): void => { }); it("Should has values", async (): Promise => { - const input = new CommandInput({name: "test"}, [ + const input = new CommandInput([ + {name: "name", value: "test"} + ], [ {name: "test1", value: "test1"}, {name: "test2", value: "test2"}, {name: "test3", value: "test3"}, @@ -24,9 +25,7 @@ describe("CommandInput.option", (): void => { expect(input).toBeInstanceOf(CommandInput); expect(input.argument("name")).toBe("test"); expect(input.argument("name2")).toBeUndefined(); - expect(input.arguments()).toEqual({ - name: "test" - }); + expect(input.arguments("name")).toEqual(["test"]); expect(input.option("test")).toBeUndefined(); }); }); diff --git a/src/makes/CommandInput.ts b/src/makes/CommandInput.ts index 344dbc1..87719f7 100644 --- a/src/makes/CommandInput.ts +++ b/src/makes/CommandInput.ts @@ -1,5 +1,6 @@ import { Param, + ParamValue, Option, OptionValue } from "../types"; @@ -7,20 +8,27 @@ import { export class CommandInput { public constructor( - protected readonly _arguments: any, - protected readonly _options: OptionValue[] + protected readonly _arguments: ParamValue[], + protected readonly _options: OptionValue[], + public readonly processed: boolean = true, ) {} - public argument(key: string): string|undefined { - if(key in this._arguments) { - return this._arguments[key]; + public argument(name: string): undefined | string { + const paramValue = this._arguments.find((param) => param.name === name); + + if(!paramValue) { + return undefined; } - return undefined; + return paramValue.value; } - public arguments(): any { - return this._arguments; + public arguments(name: string): string[] { + return this._arguments.filter((param) => { + return param.name === name; + }).map((param) => { + return param.value; + }); } public option(key: string, defaultValue?: any): any { @@ -35,9 +43,9 @@ export class CommandInput { return optionValue.value; } - public options(key?: string): any[] { + public options(name: string): any[] { return this._options.filter((option) => { - return option.name === key; + return option.name === name; }).map((option) => { return option.value; }); diff --git a/src/makes/CommandParser.spec.ts b/src/makes/CommandParser.spec.ts new file mode 100644 index 0000000..316a9fb --- /dev/null +++ b/src/makes/CommandParser.spec.ts @@ -0,0 +1,26 @@ +import {describe, it, expect, beforeEach, afterEach} from "@jest/globals"; +import {CommandParser} from "./CommandParser"; +import {Logger} from "./Logger"; +import {OptionValue} from "../types"; + + +describe("CommandParser", (): void => { + beforeEach((): void => { + Logger.mute(); + }); + + afterEach((): void => { + Logger.debug("-".repeat(10)); + Logger.mute(); + }); + + it("should parse", (): void => { + Logger.unmute(); + + const parser = new CommandParser("init "); + + console.log(parser.parse("init")); + parser.next(); + console.log(parser.parse("foo")); + }); +}); diff --git a/src/makes/CommandParser.ts b/src/makes/CommandParser.ts new file mode 100644 index 0000000..92717b2 --- /dev/null +++ b/src/makes/CommandParser.ts @@ -0,0 +1,124 @@ +import {Parser} from "./Parser"; +import {DefinitionMeta} from "../types"; + + +export class CommandParser { + public static readonly paramRequiredRegexp = /^<([\w_-]+)>(.*)?$/; + public static readonly paramOptionalRegexp = /^\[([\w_-]+)](.*)?$/; + public static readonly spreadRequiredRegexp = /^<\.\.\.([0-9\w_-]+)>(.*)?$/; + public static readonly spreadOptionalRegexp = /^\[\.\.\.([0-9\w_-]+)](.*)$/ + public static readonly optionRegexp = /^-(?:-(\w[\w\d_-]*)|(\w))$/; + public static readonly optionMultipleRegexp = /^-(\w+)$/; + protected readonly command: string[]; + protected readonly definition: string; + protected readonly definitionMeta: DefinitionMeta[]; + protected index: number = 0; + + public constructor( + definition: string, + definitionMeta?: DefinitionMeta[] + ) { + this.command = definition + ? definition.split(/\s+/g) + : []; + this.definition = definition; + this.definitionMeta = definitionMeta || CommandParser.parse(definition); + } + + public get part(): string { + return this.command[this.index]; + } + + public meta() { + return this.definitionMeta[this.index]; + } + + public next(): void { + this.index++; + } + + public get eol(): boolean { + return this.command.length <= this.index; + } + + public parse(argument: string) { + const meta = this.meta(); + + if(meta.spread === true) { + const [name] = meta.names; + + return { + [name]: argument + }; + } + + const { + regex, + names + } = meta; + + const [, ...values] = regex.exec(argument) || []; + + return names.reduce((res: any, name, index) => { + res[name] = values[index]; + + return res; + }, {}); + } + + public match(arg: string): boolean { + const meta = this.meta(); + + if(meta.spread === true) { + return true; + } + + return meta.regex.test(arg); + } + + public isSpread(): boolean { + return this.meta().spread; + } + + public static parse(definition: string): DefinitionMeta[] { + const commands = definition + ? definition.split(/\s+/g) + : []; + + const parser = new Parser([]); + + return commands.map((command): DefinitionMeta => { + if(CommandParser.spreadRequiredRegexp.test(command)) { + const [, name] = CommandParser.spreadRequiredRegexp.exec(command); + + return { + names: [name], + spread: true, + required: true + }; + } + else if(CommandParser.spreadOptionalRegexp.test(command)) { + const [, name] = CommandParser.spreadOptionalRegexp.exec(command); + + return { + spread: true, + names: [name], + required: false + }; + } + + const { + names, + regex, + partRegex + } = parser.parse(command); + + return { + spread: false, + names, + regex, + partRegex + }; + }); + } +} diff --git a/src/makes/Logger.spec.ts b/src/makes/Logger.spec.ts new file mode 100644 index 0000000..b3abd29 --- /dev/null +++ b/src/makes/Logger.spec.ts @@ -0,0 +1,8 @@ +import {describe, it, expect} from "@jest/globals"; + + +describe("Logger", (): void => { + it("", (): void => { + + }); +}); diff --git a/src/makes/Logger.ts b/src/makes/Logger.ts index b2d9657..45707f8 100644 --- a/src/makes/Logger.ts +++ b/src/makes/Logger.ts @@ -44,12 +44,12 @@ export class Logger { }; const date = new Date(), - year = date.getFullYear(), - month = prepareValue(date.getMonth() + 1), - days = prepareValue(date.getDate()), - hours = prepareValue(date.getHours()), - minutes = prepareValue(date.getMinutes()), - seconds = prepareValue(date.getSeconds()); + year = date.getFullYear(), + month = prepareValue(date.getMonth() + 1), + days = prepareValue(date.getDate()), + hours = prepareValue(date.getHours()), + minutes = prepareValue(date.getMinutes()), + seconds = prepareValue(date.getSeconds()); return `${year}-${month}-${days} ${hours}:${minutes}:${seconds}`; }; diff --git a/src/makes/OptionParser.ts b/src/makes/OptionParser.ts new file mode 100644 index 0000000..b8d619d --- /dev/null +++ b/src/makes/OptionParser.ts @@ -0,0 +1,36 @@ +const fullReg = /^--(\w[\w0-9_-]*)$/, + shortReg = /^-(\w)$/, + regWithoutValue = /^-(?:-(\w[\w0-9_-]*)|(\w+))$/, + regWithValue = /^-(?:-(\w[\w0-9_-]*)|(\w))=(.*)$/, + shortMultiple = /^-(\w+)$/; + +export class OptionParser { + public static isSingleWithoutValue(arg: string): boolean { + return fullReg.test(arg) + || shortReg.test(arg); + } + + public static isSingleWithValue(arg: string): boolean { + return regWithValue.test(arg); + } + + public static isMultiple(arg: string): boolean { + return shortMultiple.test(arg); + } + + public static isOptionWithValue(arg: string): boolean { + return regWithValue.test(arg); + } + + public static parse(arg: string) { + const [, name, alias] = regWithoutValue.exec(arg) || []; + + return {name, alias}; + } + + public static parseWithValue(arg: string) { + const [, name, alias, value] = regWithValue.exec(arg); + + return {name, alias, value}; + } +} diff --git a/src/makes/Parser.spec.ts b/src/makes/Parser.spec.ts index 3f51028..a339e55 100644 --- a/src/makes/Parser.spec.ts +++ b/src/makes/Parser.spec.ts @@ -1,12 +1,11 @@ import {expect, describe, it} from "@jest/globals"; - import {Parser} from "./Parser"; -describe("Parser.next", () => { +describe("Parser.next", (): void => { const parser = new Parser(["foo", "bar"]); - it("Should correctly progress through parts", () => { + it("Should correctly progress through parts", (): void => { expect(parser.part).toBe("foo"); parser.next(); expect(parser.part).toBe("bar"); @@ -15,7 +14,7 @@ describe("Parser.next", () => { }); }); -describe("Parser.isCommand", () => { +describe("Parser.isCommand", (): void => { const parser = new Parser(["test"]); it("Should validate command", () => { diff --git a/src/makes/Parser.ts b/src/makes/Parser.ts index 26ba57b..ef26f48 100644 --- a/src/makes/Parser.ts +++ b/src/makes/Parser.ts @@ -1,3 +1,4 @@ +import {Logger} from "../makes/Logger"; import {escapeRegExp} from "../utils"; @@ -64,9 +65,9 @@ export class Parser { return true; } - return regOption.test(this.part) || - regOptionWithValue.test(this.part) || - regShortMultipleOption.test(this.part); + return regOption.test(this.part) + || regOptionWithValue.test(this.part) + || regShortMultipleOption.test(this.part); } public isRegOption() { @@ -143,7 +144,7 @@ export class Parser { return partRegex.exec(this.part); } - protected parse(part: string) { + public parse(part: string) { let restCommand = part, names: string[] = [], resReg = "", @@ -156,7 +157,6 @@ export class Parser { if(comAttrReq.test(restCommand)) { const [, name, rest] = comAttrReq.exec(restCommand); - // console.warn(name, rest); names.push(name); stepReg = "(.+?)"; restCommand = rest; @@ -164,7 +164,6 @@ export class Parser { else if(comAttrOpt.test(restCommand)) { const [, name, rest] = comAttrOpt.exec(restCommand); - // console.warn(name, rest); names.push(name); stepReg = "(.+?)?"; restCommand = rest; diff --git a/src/types/DefinitionMeta.ts b/src/types/DefinitionMeta.ts new file mode 100644 index 0000000..8e4203b --- /dev/null +++ b/src/types/DefinitionMeta.ts @@ -0,0 +1,14 @@ +export type ArgumentDefinitionMeta = { + spread: false; + names: string[]; + regex: RegExp; + partRegex: RegExp; +}; + +export type SpreadDefinitionMeta = { + spread: true; + names: string[]; + required: boolean; +}; + +export type DefinitionMeta = ArgumentDefinitionMeta | SpreadDefinitionMeta; diff --git a/src/types/Option.ts b/src/types/Option.ts index 61d2527..572aeee 100644 --- a/src/types/Option.ts +++ b/src/types/Option.ts @@ -1,13 +1,15 @@ +export type OptionType = boolean | string | number; + export type Option = { name: string; + alias?: string; type: "boolean" | "number" | "string"; help?: boolean; - alias?: string; description?: string; - default?: boolean | string | number; + default?: OptionType; }; export type OptionValue = { name: string; - value: boolean | string | number; + value: OptionType; }; diff --git a/src/types/Param.ts b/src/types/Param.ts index 9d61609..90b5064 100644 --- a/src/types/Param.ts +++ b/src/types/Param.ts @@ -1,3 +1,8 @@ export type Param = { - type: "string"|"array"; + type: "string" | "array"; +}; + +export type ParamValue = { + name: string; + value: string; }; diff --git a/src/types/index.ts b/src/types/index.ts index c7f562d..bd80a85 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,2 +1,3 @@ +export * from "./DefinitionMeta"; export * from "./Option"; export * from "./Param"; diff --git a/src/utils/escapeRegExp.ts b/src/utils/escapeRegExp.ts index b0e92e2..1e6d5d8 100644 --- a/src/utils/escapeRegExp.ts +++ b/src/utils/escapeRegExp.ts @@ -1,6 +1,3 @@ -const escapeRegExp = (string:string) => { +export const escapeRegExp = (string: string): string => { return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }; - - -export {escapeRegExp}; \ No newline at end of file diff --git a/src/utils/isCommand.ts b/src/utils/isCommand.ts index bd80855..2c47d31 100644 --- a/src/utils/isCommand.ts +++ b/src/utils/isCommand.ts @@ -1,7 +1,7 @@ import {generateCommandRegExp} from "./generateCommandRegExp"; -export const isCommand = (command: string) => { +export const isCommand = (command: string): boolean => { // return !part.startsWith("-"); return !!generateCommandRegExp(command); }; \ No newline at end of file diff --git a/src/utils/isSpread.ts b/src/utils/isSpread.ts index deefca4..09be71a 100644 --- a/src/utils/isSpread.ts +++ b/src/utils/isSpread.ts @@ -1,4 +1,4 @@ -export const isSpread = (command: string) => { +export const isSpread = (command: string): boolean => { const comSpread = /^\[\.\.\.([0-9\w_-]+)]$|^<\.\.\.([0-9\w_-]+)>$/; return comSpread.test(command); diff --git a/test/index.ts b/test/setup.ts similarity index 100% rename from test/index.ts rename to test/setup.ts diff --git a/tsconfig.json b/tsconfig.json index 4748c8c..07ed8f3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,18 +1,109 @@ { "compilerOptions": { - "baseUrl": "./", - "module": "commonjs", - "moduleResolution": "node", - "declaration": true, - "removeComments": true, - "target": "ES2020", - "sourceMap": false, - "allowJs": true, - "outDir": "./lib", - "incremental": true, - "skipLibCheck": true, - "types": ["node"] + /* Projects */ + "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ + "tsBuildInfoFile": "./lib/tsconfig.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ + // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ + // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ + // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ + + /* Language and Environment */ + "target": "ESNext", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + // "jsx": "preserve", /* Specify what JSX code is generated. */ + // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ + // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ + // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ + // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ + // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ + // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ + // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ + // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ + // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ + + /* Modules */ + "module": "ESNext", /* Specify what module code is generated. */ + // "rootDir": "./", /* Specify the root folder within your source files. */ + "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ + "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ + // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ + // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ + "types": ["node"], /* Specify type package names to be included without being referenced in a source file. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ + // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ + // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ + // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ + // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ + // "resolveJsonModule": true, /* Enable importing .json files. */ + // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ + // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ + + /* JavaScript Support */ + "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ + // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ + // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ + + /* Emit */ + "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + // "declarationMap": true, /* Create sourcemaps for d.ts files. */ + // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ + "sourceMap": false, /* Create source map files for emitted JavaScript files. */ + // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ + // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ + "outDir": "./lib", /* Specify an output folder for all emitted files. */ + "removeComments": true, /* Disable emitting comments. */ + // "noEmit": true, /* Disable emitting files from a compilation. */ + // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ + // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ + // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ + // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ + // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ + // "newLine": "crlf", /* Set the newline character for emitting files. */ + // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ + // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ + // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ + // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ + // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ + // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ + + /* Interop Constraints */ + // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ + // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ + // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ + "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ + // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ + // "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ + + /* Type Checking */ + // "strict": true, /* Enable all strict type-checking options. */ + // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ + // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ + // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ + // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ + // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ + // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ + // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ + // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ + // "exactOptionalPropertyTypes": false, /* Interpret optional property types as written, rather than adding 'undefined'. */ + // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ + // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ + // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ + // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ + // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ + + /* Completeness */ + // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ + "skipLibCheck": true }, - "include": ["./src/**/*"], - "exclude": ["**/*.spec.ts"] + "include": [ + "./src/**/*" + ] } \ No newline at end of file