diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f094d69..cc2c408 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,96 +2,152 @@ Contributions are welcome! Whether you’re fixing a bug, suggesting a feature, or improving documentation, your help is much appreciated. -## Introduction +--- -The goal behing creating this framework was to add a extra layer of modularization, performance, security, and usability which would help developers around the world on writing small or larger Node.js CLI tools. +## **Introduction** -## Developers Guide +The goal behind creating this framework was to provide an additional layer of modularization, performance, security, and usability to help developers worldwide write small or large Node.js CLI tools effortlessly. -### Basic Workflow +--- -1. Fork the original repo `https://github.com/supitsdu/climonad.js`. -2. Clone it locally `git clone ` -3. Create a new branch: `git checkout -b feat/my-feature`. -4. Make your changes, commit, and push. -5. Open a PR. +## **Developer Guide** -### Code Style +### **Basic Workflow** -Please follow the existing code style (simple, clean, and easy to follow). +1. **Fork the repository**: [https://github.com/supitsdu/climonad.js](https://github.com/supitsdu/climonad.js) +2. **Clone your fork locally**: + ```bash + git clone + ``` +3. **Create a new branch**: + ```bash + git checkout -b feat/my-feature + ``` +4. **Make changes, commit, and push**: + ```bash + git add . + git commit -m "feat: add a cool feature" + git push origin feat/my-feature + ``` +5. **Open a pull request**: Compare your branch against `main` in the original repository and submit your PR. -- Run linter: `npm run lint` -- Fix linting issues: `npm run lint:fix` -- Format Markdown files: `npm run format` +--- -### Tests +### **Code Style** + +Adhere to the existing code style to ensure consistency across the project. + +- **Lint your code**: + ```bash + npm run lint + ``` +- **Fix linting issues**: + ```bash + npm run lint:fix + ``` +- **Format Markdown files**: + ```bash + npm run format + ``` + +--- + +### **Testing** If you're adding a feature or fixing a bug, please add tests using **Vitest**. Ensure all tests pass before submitting your PR: -- Run tests: `npm run test` -- Watch tests: `npm run test:watch` -- Check test coverage: `npm run test:coverage` +- Run tests: + ```bash + npm run test + ``` +- Watch tests: + ```bash + npm run test:watch + ``` +- Check test coverage: + ```bash + npm run test:coverage + ``` + +--- -### Build +### **Building the Project** -Before submitting your PR, ensure the project builds successfully: +Ensure the project builds successfully before submitting your PR: -- Build the project: `npm run build` -- Clean build artifacts: `npm run clean` +- Build the project: + ```bash + npm run build + ``` +- Clean build artifacts: + ```bash + npm run clean + ``` -### Benchmarks +--- + +### **Benchmarking** For performance improvements, run benchmarks using **Deno's bench tool**: -- Run benchmarks: `npm run bench` +- Run benchmarks: + ```bash + npm run bench + ``` + +--- -### Project Structure +### **Project Structure** -```toml -src\ - cli.ts # CLI entry point - flags.ts # Flag parsing logic +```plaintext +src/ + Command.ts # Command definition logic + Flag.ts # Flag definition logic main.ts # Exposes public API - parser.ts # Command-line argument parser + Scope.ts # Scope management logic + Setup.ts # CLI setup management logic types.ts # Type definitions - usageGenerator.ts # Usage information generator - utils.ts # Utility functions -test\ +test/ bench.ts # Benchmark tests - cli.test.ts # Tests for CLI functionality - flags.test.ts # Tests for flag parsing - parser.test.ts # Tests for argument parser + Command.test.ts # Tests for Command definition + Flag.test.ts # Tests for Flag definition + Scope.test.ts # Tests for Scope management + Setup.test.ts # Tests for CLI setup types.test.ts # Tests for type definitions - usageGenerator.test.ts # Tests for usage generator - utils.test.ts # Tests for utility functions ``` -## Help Needed +--- -Climonad is a community-driven project and any help you can provide is much appreciated. Here are some areas where you can contribute: +## **How to Contribute** -### Issue Triage +### **Help with Issues** -Help us manage issues by: +Assist us in managing issues by: - Reproducing reported bugs - Clarifying issue descriptions - Tagging issues with appropriate labels -### Pull Requests +### **Open Pull Requests** + +We encourage you to open PRs, especially for issues tagged with the `help-needed` label. -We encourage you to open pull requests, especially for issues tagged with the help-needed label. +### **Community Support** -### Community Support +Engage with other developers by participating in discussions on the issue tracker and GitHub discussions. Your expertise helps improve the framework for everyone. -Assist other users by participating in the issue tracker, and GitHub discussions. Your expertise can help others solve problems and improve their experience with Climonad.js. +--- -## Resources +## **Resources** -- [Deno Docs](https://docs.deno.com/) +- [Deno Docs](https://deno.land/manual) - [Node.js Documentation](https://nodejs.org/docs/latest/api/) - [Vitest Documentation](https://vitest.dev/guide/) - [Rollup Documentation](https://rollupjs.org/introduction/) - [Eslint Documentation](https://eslint.org/) - [Prettier Documentation](https://prettier.io/docs/en/) - [Conventional Commits](https://www.conventionalcommits.org/) + +--- + +Thank you for contributing to Climonad.js! Your support is invaluable to the growth and success of this project. diff --git a/README.md b/README.md index 8d7e4c9..064e3be 100644 --- a/README.md +++ b/README.md @@ -1,191 +1,91 @@ -# Climonad.js +
-> [!WARNING] -> This library is in **early development**, and APIs may change without notice. +# **climonad.js** -**Next-Gen CLI framework built for Node.js.** +**Next-Gen CLI framework** -## Usage +[![NPM](https://img.shields.io/npm/v/climonad?color=blue)](https://www.npmjs.com/package/climonad) +[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE) -```javascript -import { Cli } from "climonad" +
-// Create a new command-line interface -const cli = Cli.createCli({ - name: "pm", - description: "Project management CLI", - commands: [Cli.cmd({ name: "init", description: "Initialize the project" })], - flags: [ - Cli.bool({ - name: "verboseOption", - flag: "--verbose", - description: "Enable verbose output", - }), - ], -}) +--- -try { - const cliArgs = cli.parse(process.argv.slice(2)) - const help = cliArgs.generateHelp() +## **Overview** - if (help) { - console.log(JSON.stringify(help, null, 2)) - } +`climonad.js` is a feature-rich framework for building structured and maintainable command-line tools. - if (cliArgs.commands.has("init")) { - // Handle the init command - } -} catch (err) { - console.error(error) -} -``` - -## Argument Parsing and Handling +> [!WARNING] +> This library is in early development, and APIs may change. -Climonad uses declarative configuration to define and parse CLI arguments: +### Key Features: -### Commands +- 🌳 **Hierarchical Commands**: Build nested commands and subcommands effortlessly. +- 🛠️ **Powerful Flag Parsing**: Manage flags with defaults, requirements, and validation. +- 📋 **Custom Usage Messages**: Provide clear and tailored help text for every command. +- 🗂️ **Scoped Management**: Separate global and local flags for better organization. -Commands represent distinct functionalities, like "build" or "serve." Define them using `Cli.cmd()` and assign descriptions, aliases, and flags. For example: +--- -```javascript -Cli.cmd({ name: "serve", description: "Start the development server", alias: "s" }) -``` +## **Installation** -Invoke commands using their name or alias: +Install via npm: ```bash -my-app serve -my-app s +npm install climonad ``` -### Flags +--- -Flags modify command behavior and can be defined as: +## **Quick Example** -- **Boolean Flags**: Toggle features on or off. -- **String Flags**: Accept string values. -- **Number Flags**: Accept numeric inputs. -- **Required Flags**: Mark flags as required, enforcing their presence. -- **Default Values**: Provide fallback values when flags are not specified. +Here’s a simple CLI configuration: -Example: +```typescript +const app = cli({ + name: "cli", + description: "A simple CLI", -```javascript -Cli.str({ - name: "host", - flag: "--host", - description: "Specify the hostname", - default: "localhost", -}) -Cli.num({ - name: "port", - flag: "--port", - description: "Set the port number", -}) -Cli.bool({ - name: "verbose", - flag: "--verbose", - description: "Enable verbose logging", -}) -Cli.str({ - name: "config", - flag: "--config", - description: "Configuration file", - required: true, // Marking the option as required + flags: [str({ name: "config", alias: "c", description: "Config file", required: true })], + + commands: [ + cmd({ + name: "init", + description: "Initialize a new project", + flags: [str({ name: "name", description: "Project name", required: true })], + action: async ({ flags }) => { + console.log("Initializing project:", flags.get("name")) + console.log("Using config file:", flags.get("config")) + }, + }), + ], }) + +app.run(process.argv) ``` -Pass flags as: +**Run your CLI:** ```bash -my-app serve --host localhost --port 8080 --verbose -my-app serve --config app.config +node cli init --name my-project -c config.json ``` -If the required flag is missing, climonad will throw an error. - -### Parsing Logic - -- **Positional Arguments**: Commands are identified by position (e.g., `my-app serve`). -- **Flag Arguments**: Flags prefixed with `--` or aliases like `-v` are parsed. -- **Default Values**: If a flag is not provided, Climonad uses the default value. - -Example: +**Output:** -```javascript -const result = cli.parse(["serve", "--host", "example.com", "--port", "3000"]) ``` - -Produces: - -```javascript -{ - commands: Set(1) { "serve" }, - flags: Map(2) { - "host": "example.com", - "port": 3000, - }, - generateHelp: [Function], -} +Initializing project: my-project +Using config file: config.json ``` -### Auto Help Generation - -Invoke `-h` or `--help` to display detailed help: - -- **Global Help**: Provides help for the entire CLI. -- **Command-Scoped Help**: Displays help specific to a command. - -Example: - -```bash -my-app --help -my-app serve --help -``` - -### Error Handling - -Climonad includes robust error handling: - -- Invalid commands or flags throws a `CliError`. -- Missing required flags will result in an error. -- Invalid values for typed flags (e.g., `--port not-a-number`) raise descriptive errors. - -## Performance - -> [!NOTE] -> Metrics are preliminary and subject to change as the library evolves. - -Benchmarks conducted using **Deno's [`bench`](https://docs.deno.com/api/deno/~/Deno.bench)**: - -| **Operation** | **Time (avg)** | **Ops/Second** | -| ----------------------- | -------------- | -------------- | -| CLI Initialization | ~725.4 ns | 1,379,000 | -| Basic Command Execution | ~190.5 ns | 5,249,000 | -| Command with flags | ~654.5 ns | 1,528,000 | - -### Algorithmic Complexity - -- **CLI Initialization**: O(n) for commands and flags. -- **Command/Option Lookup**: O(1) with optimized caching. -- **Argument Parsing**: O(n) for input arguments. -- **Help Generation**: O(m) for scoped commands/flags. -- **Space Complexity**: O(n) with minimal overhead. - -Efficient for small scripts and large CLI applications alike. +--- -## Contributing +## **Learn More** -We welcome [contributions](/CONTRIBUTING.md)! Here's how you can help: +- **[API Documentation](docs/api/README.md)** +- **[Contribution Guide](CONTRIBUTING.md)** -1. **Report Bugs**: Found a bug? [Open an issue](https://github.com/supitsdu/climonad/issues). -2. **Suggest Features**: Got an idea? Let us know. -3. **Submit Pull Requests**: - - Fork the repository. - - Create a feature branch. - - Submit a pull request. +--- -## License +## **License** -Licensed under the [MIT License](LICENSE). +This project is licensed under the [MIT License](LICENSE). diff --git a/docs/api/Command.md b/docs/api/Command.md new file mode 100644 index 0000000..083f819 --- /dev/null +++ b/docs/api/Command.md @@ -0,0 +1,102 @@ +# `Command` Class - API Reference + +The `Command` class is a fundamental building block in Climonad.js, representing an individual CLI command with associated flags, subcommands, and actions. + +--- + +## **Constructor** + +| Signature | Description | +| ------------------------------------ | ----------------------------------------------------------- | +| `constructor(config: CommandConfig)` | Initializes a new command with the specified configuration. | + +--- + +## **Properties** + +| Property | Type | Description | +| ----------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| `name` | `string` | The name of the command. | +| `description` | `string` | A brief description of the command's functionality. | +| `alias` | `string \| undefined` | An optional shorthand name for the command. | +| `required` | `boolean` | Indicates whether this command is mandatory in the CLI context. Defaults to `false`. | +| `flags` | `Flag[] \| undefined` | An array of flags associated with the command. | +| `commands` | `Command[] \| undefined` | Subcommands nested under this command. | +| `action` | `CommandAction \| undefined` | The function to execute when the command is invoked. | +| `onUsageReporter` | `(command: Command) => Promise \| void \| undefined` | Custom function to generate usage information for the command. | + +--- + +## **Methods** + +The `Command` class itself does not define methods, but actions and behaviors are configured via the `action` and `onUsageReporter` properties. + +--- + +## **Command Factory Function** + +| Function | Parameters | Returns | Description | +| -------- | ---------------------------------- | --------- | ------------------------------------------------------------ | +| `cmd` | `config: CommandConfig \| Command` | `Command` | Creates a new `Command` instance or returns an existing one. | + +--- + +## **Type Definitions** + +### Parsed Types + +| Name | Type | Description | +| ---------------- | -------------------------------------------------- | -------------------------------------------- | +| `ParsedFlags` | `Map` | A map of parsed flags from CLI input. | +| `ParsedCommands` | `Map` | A map of parsed commands from CLI input. | +| `ParsedArgs` | `{ flags: ParsedFlags; commands: ParsedCommands }` | Encapsulates both parsed flags and commands. | + +### `CommandConfig` Interface + +| Property | Type | Description | +| ----------------- | ---------------------------------------------------------- | --------------------------------------------------------------- | +| `name` | `string` | The name of the command. | +| `description` | `string` | A brief description of the command's purpose. | +| `alias` | `string \| undefined` | Optional shorthand for the command. | +| `required` | `boolean \| undefined` | Indicates whether the command is required. Defaults to `false`. | +| `flags` | `Flag[] \| undefined` | Optional array of flags associated with the command. | +| `commands` | `Command[] \| undefined` | Optional array of nested subcommands. | +| `action` | `CommandAction \| undefined` | Function executed when the command is invoked. | +| `onUsageReporter` | `(command: Command) => Promise \| void \| undefined` | Optional custom usage reporting function. | + +--- + +## **Quick Usage** + +```typescript +// Define a command with flags and a nested subcommand +const startCommand = cmd({ + name: "start", + description: "Start the application", + flags: [ + bool({ + name: "verbose", + alias: "v", + description: "Enable verbose output", + }), + str({ + name: "env", + alias: "e", + description: "Specify the environment", + required: true, + }), + ], + action: async ({ flags }) => { + console.log("Starting application with environment:", flags.get("env")) + }, + commands: [ + cmd({ + name: "server", + description: "Start the server", + action: async () => { + console.log("Server is starting...") + }, + }), + ], +}) +``` diff --git a/docs/api/Flag.md b/docs/api/Flag.md new file mode 100644 index 0000000..39eeaf7 --- /dev/null +++ b/docs/api/Flag.md @@ -0,0 +1,137 @@ +# `Flag` Class - API Reference + +The `Flag` class defines flags for command-line interfaces, including their metadata, type, default values, and parsers. It supports predefined constructors for boolean, string, and numeric flags, as well as customizable parsers. + +--- + +## **Constructor** + +| Signature | Description | +| ----------------------------------------------- | --------------------------------------------------- | +| `constructor(config: FlagConstructorConfig)` | Initializes a flag with the provided configuration. | + +--- + +## **Properties** + +| Property | Type | Description | +| ------------- | --------------------- | ------------------------------------------------------------------ | +| `type` | `string` | The type of the flag (`"boolean"`, `"string"`, `"number"`, etc.). | +| `name` | `string` | The unique name of the flag. | +| `description` | `string` | A brief description of the flag's purpose. | +| `required` | `boolean` | Indicates if the flag is mandatory. Defaults to `false`. | +| `default` | `T \| undefined` | The default value for the flag, if not provided by the user. | +| `alias` | `string \| undefined` | An optional shorthand for the flag. | +| `parser` | `Parser` | The parser function used to validate and process the flag's value. | + +--- + +## **Methods** + +| Method | Parameters | Returns | Description | +| ------ | ---------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| `N/A` | N/A | N/A | No instance methods defined for the `Flag` class. Flag behavior is determined through the `parser` property and usage within CLI logic. | + +--- + +## **Predefined Flag Constructors** + +| Constructor | Parameters | Returns | Description | +| ----------------------------------- | ----------------------------- | --------------- | ----------------------- | +| `bool(config: FlagConfig)` | `config: FlagConfig` | `Flag` | Creates a boolean flag. | +| `str(config: FlagConfig)` | `config: FlagConfig` | `Flag` | Creates a string flag. | +| `num(config: FlagConfig)` | `config: FlagConfig` | `Flag` | Creates a numeric flag. | + +--- + +## **Flag Parsing** + +### Parser Functions + +| Parser | Parameters | Returns | Description | +| ---------------- | ---------------------- | ------------------------- | --------------------------------------------------------------------------------- | +| `boolFlagParser` | `config: ParserConfig` | `Promise` | Parses a boolean value. Defaults to `true` if no value is provided. | +| `strFlagParser` | `config: ParserConfig` | `Promise` | Parses a string value, ensuring it doesn't conflict with other flags or commands. | +| `numFlagParser` | `config: ParserConfig` | `Promise` | Parses a numeric value, returning `null` if the value is not valid. | + +### **Default Parser** + +| Name | Description | +| --------------- | ----------------------------------------------------------------------------------- | +| `defaultParser` | Throws an error if a parser is not provided. Used as the fallback parser for flags. | + +--- + +## **Type Definitions** + +### **FlagConfig Interface** + +| Property | Type | Description | +| ------------- | ------------------------ | -------------------------------------------------- | +| `name` | `string` | The name of the flag. | +| `description` | `string` | A description of the flag. | +| `alias` | `string \| undefined` | An optional shorthand for the flag. | +| `required` | `boolean \| undefined` | Whether the flag is required. Defaults to `false`. | +| `default` | `T \| undefined` | The default value for the flag. | +| `parser` | `Parser \| undefined` | A function to parse the flag's value. | + +--- + +### **ParserConfig Interface** + +| Property | Type | Description | +| ---------- | -------------------------- | -------------------------------------- | +| `next` | `string[]` | Array of tokens from the CLI input. | +| `index` | `number` | Current index in the `next` array. | +| `hasFlag` | `(key: string) => boolean` | Checks if a flag exists. | +| `hasCmd` | `(key: string) => boolean` | Checks if a command exists. | +| `setIndex` | `(index: number) => void` | Updates the index in the `next` array. | + +--- + +### **Parser Type** + +| Signature | Description | +| -------------------------------------------------------------------------- | ------------------------------------------------------- | +| `(this: Flag, config: ParserConfig) => T \| null \| Promise` | Defines the function signature for parsing flag values. | + +--- + +## **Quick Usage** + +```typescript +import { bool, str, num } from "climonad" + +// Define a boolean flag +const verbose = bool({ + name: "verbose", + description: "Enable verbose mode", + alias: "v", + default: false, +}) + +// Define a string flag +const configPath = str({ + name: "config", + description: "Path to the configuration file", + required: true, +}) + +// Define a numeric flag +const timeout = num({ + name: "timeout", + description: "Request timeout in seconds", + default: 30, +}) + +// Use flags in a CLI setup +console.log(verbose, configPath, timeout) +``` + +--- + +## **Notes** + +- The `Flag` class allows seamless flag creation with flexible parsing options. +- Use the predefined constructors (`bool`, `str`, `num`) for common flag types. +- Custom parsers can be provided for more advanced flag processing requirements. diff --git a/docs/api/README.md b/docs/api/README.md new file mode 100644 index 0000000..db4b40f --- /dev/null +++ b/docs/api/README.md @@ -0,0 +1,26 @@ +# API References + +This directory contains detailed documentation for the core components of the CLI framework. Each component's API reference provides an in-depth look at its structure, methods, and usage. + +## Available References + +- [Command API](./Command.md): Documentation for the `Command` class, which represents individual CLI commands. +- [Flag API](./Flag.md): Documentation for the `Flag` class, which represents CLI flags and their metadata. +- [Setup API](./Setup.md): Documentation for the `Setup` class, which manages the configuration and execution of the CLI application. +- [Scope API](./Scope.md): **Internal Use Only** Documentation for the `Scope` class, which manages flags, commands, and their metadata within specific contexts. + +--- + +## **How to Use This Reference** + +Each document provides: + +- **Type Definitions**: Definitions for types and interfaces relevant to the module. +- **Class Details**: Overview of the class constructor, properties, and methods. +- **Examples**: Sample code snippets to demonstrate how to use the component in real-world scenarios. + +--- + +## **Getting Started** + +If you're new to Climonad.js, we recommend starting with the [Setup documentation](Setup.md) to understand how to initialize and configure your CLI application. diff --git a/docs/api/Scope.md b/docs/api/Scope.md new file mode 100644 index 0000000..aeb5451 --- /dev/null +++ b/docs/api/Scope.md @@ -0,0 +1,78 @@ +### `Scope` Class API Reference (Internal Use Only) + +The `Scope` class is an internal component of the framework, responsible for managing flags, commands, and their associated metadata. It facilitates the validation, organization, and execution of CLI components. + +--- + +#### Constructor + +| Signature | Description | +| --------------- | -------------------------------------- | +| `constructor()` | Initializes an empty `Scope` instance. | + +--- + +#### Properties + +| Property | Type | Description | +| ------------------------ | ---------------------------------------------------------- | ------------------------------------------------ | +| `flags` | `Map` | Maps flag names and aliases to their indices. | +| `commands` | `Map` | Maps command names and aliases to their indices. | +| `flagsList` | `Flag[]` | Stores all added flags. | +| `commandsList` | `Command[]` | Stores all added commands. | +| `requiredFlags` | `number[]` | Indices of required flags. | +| `requiredCommands` | `number[]` | Indices of required commands. | +| `flagsWithDefaultValues` | `number[]` | Indices of flags with default values. | +| `commandsStack` | `number[]` | Indices of commands in the stack. | +| `usageReporters` | `Map Promise \| void>` | Maps command names to their usage reporters. | + +--- + +#### Methods + +##### Debugging + +| Method | Description | +| ----------------- | ----------------------------------------------------------------------------------- | +| `debug(): object` | Returns the internal state of the `Scope`, including flags, commands, and metadata. | + +##### Flag Management + +| Method | Description | +| ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | +| `forEachRequiredFlag(callback: (flag: Flag, index: number) => void): void` | Executes a callback for each required flag. | +| `forEachFlagWithDefaultValue(callback: (flag: Flag, index: number) => void): void` | Executes a callback for each flag with a default value. | +| `hasFlag(key: string): boolean` | Checks if a flag exists. | +| `getFlagIndex(key: string): number \| null` | Retrieves the index of a flag by its name or alias. | +| `getFlag(key: string): Flag \| null` | Retrieves a flag by its name or alias. | +| `addFlag(entry: Flag): void` | Adds a flag to the `Scope`. Validates the entry as an instance of `Flag`. | + +##### Command Management + +| Method | Description | +| ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| `forEachRequiredCommand(callback: (command: Command, index: number) => void): void` | Executes a callback for each required command. | +| `hasCmd(key: string): boolean` | Checks if a command exists. | +| `getCmdIndex(key: string): number \| null` | Retrieves the index of a command by its name or alias. | +| `getCmd(key: string): Command \| null` | Retrieves a command by its name or alias. | +| `addCmd(entry: Command): void` | Adds a command to the `Scope`. Validates the entry as an instance of `Command`. | + +##### Usage Reporter Management + +| Method | Description | +| --------------------------------------------------------------------------------------------- | --------------------------------------------------------- | +| `setUsageReporter(name: string, reporter: (command: Command) => Promise \| void): void` | Sets a usage reporter for a specific command. | +| `hasUsageReporter(name: string): boolean` | Checks if a usage reporter exists for a specific command. | +| `getUsageReporter(name: string): (command: Command) => Promise \| void` | Retrieves the usage reporter for a specific command. | + +##### General Utility + +| Method | Description | +| --------------------------- | ---------------------------------------------------- | +| `has(key: number): boolean` | Checks if an index corresponds to a flag or command. | + +--- + +### Notes + +The `Scope` class is not directly exposed as part of the public API. It is intended for internal use within the CLI framework to organize and validate flags, commands, and their associated behaviors. diff --git a/docs/api/Setup.md b/docs/api/Setup.md new file mode 100644 index 0000000..376d6a2 --- /dev/null +++ b/docs/api/Setup.md @@ -0,0 +1,148 @@ +# `Setup` Class - API Reference + +The `Setup` class serves as the central component of Climonad.js, managing CLI initialization, configuration, command execution, and flag validation. + +--- + +## **Constructor** + +| Signature | Description | +| ---------------------------------- | --------------------------------------------------------------- | +| `constructor(config: SetupConfig)` | Initializes the CLI with the provided configuration and scopes. | + +--- + +## **Properties** + +| Property | Type | Description | +| --------------- | ----------------------------------- | ----------------------------------------------------------------------------------------- | +| `name` | `string` | The name of the CLI application. | +| `scopes` | `{ global: Scope; current: Scope }` | Contains global and current scopes for managing flags and commands. | +| `config` | `SetupConfig` | Configuration object defining CLI name, description, flags, commands, and usage handling. | +| `latestCommand` | `string` | Tracks the most recently invoked command. | + +--- + +## **Methods** + +### **Debugging** + +| Method | Returns | Description | +| --------- | ------------------------------- | -------------------------------------------------------- | +| `debug()` | `{ global: any; current: any }` | Returns debug information for global and current scopes. | + +--- + +### **Execution** + +| Method | Parameters | Returns | Description | +| ----------------------------------------------------------------- | ------------------------------------------------ | --------------- | ----------------------------------------------------------- | +| `run(argv: string[])` | `argv: string[]` | `Promise` | Processes CLI arguments and executes corresponding actions. | +| `runCommandActions(commands: ParsedCommands, flags: ParsedFlags)` | `commands: ParsedCommands`, `flags: ParsedFlags` | `Promise` | Executes the actions associated with parsed commands. | + +--- + +### **Usage Reporting** + +| Method | Parameters | Returns | Description | +| ------------------------------------ | -------------------- | ----------------------------------------------------- | ------------------------------------------------------ | +| `handleUsageReporting()` | N/A | `Promise` | Handles usage reporting based on the `usageFlag`. | +| `hasUsageReporter(command: Command)` | `command: Command` | `boolean` | Checks if a usage reporter is available for a command. | +| `getUsageReporter(command: Command)` | `command: Command` | `(command: Command) => Promise \| void \| null` | Retrieves the usage reporter for a command. | +| `hasUsageFlag(flags: ParsedFlags)` | `flags: ParsedFlags` | `boolean` | Checks if the usage flag is present. | + +--- + +### **Flag Management** + +| Method | Parameters | Returns | Description | +| ------------------------------------------------------------------ | ----------------------------------------------------- | -------------- | ----------------------------------------------------------- | +| `hasFlag(key: string)` | `key: string` | `boolean` | Checks if a flag exists in the global or current scope. | +| `getFlag(key: string)` | `key: string` | `Flag \| null` | Retrieves a flag by name from the global or current scope. | +| `applyDefaults(scope: Scope, flags: Map)` | `scope: Scope`, `flags: Map` | `void` | Applies default values to flags within the specified scope. | + +--- + +### **Command Management** + +| Method | Parameters | Returns | Description | +| ------------------------------------------------------------------- | ------------------------------------------------- | ----------------- | ---------------------------------------------------------------------------------- | +| `hasCmd(key: string)` | `key: string` | `boolean` | Checks if a command exists in the global or current scope. | +| `getCmd(key: string)` | `key: string` | `Command \| null` | Retrieves a command by name from the global or current scope. | +| `updateCommandScope(command: Command \| SetupConfig, scope: Scope)` | `command: Command \| SetupConfig`, `scope: Scope` | `void` | Updates a scope with new flags and commands from a command or setup configuration. | + +--- + +### **Argument Parsing** + +| Method | Parameters | Returns | Description | +| ----------------------- | ---------------- | --------------------- | ----------------------------------------------------------- | +| `parse(argv: string[])` | `argv: string[]` | `Promise` | Parses CLI arguments and returns parsed flags and commands. | + +--- + +### **Validation** + +| Method | Parameters | Returns | Description | +| ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | ------- | ----------------------------------------------------------- | +| `fromScopeCheckRequirements(scope: Scope, flags: ParsedFlags, commands: ParsedCommands)` | `scope: Scope`, `flags: ParsedFlags`, `commands: ParsedCommands` | `void` | Validates that all required flags and commands are present. | + +--- + +## **Type Definitions** + +### Parsed Types + +| Name | Type | Description | +| ---------------- | -------------------------------------------------- | -------------------------------------------- | +| `ParsedFlags` | `Map` | A map of parsed flags from CLI input. | +| `ParsedCommands` | `Map` | A map of parsed commands from CLI input. | +| `ParsedArgs` | `{ flags: ParsedFlags; commands: ParsedCommands }` | Encapsulates both parsed flags and commands. | + +--- + +### `SetupConfig` Interface + +| Property | Type | Description | +| ----------------- | ---------------------------------------------------------- | --------------------------------------------- | +| `name` | `string` | The name of the CLI application. | +| `description` | `string` | A brief description of the CLI's purpose. | +| `flags` | `Flag[] \| undefined` | Optional array of global flags. | +| `commands` | `Command[] \| undefined` | Optional array of top-level commands. | +| `onUsageReporter` | `(command: Command) => Promise \| void \| undefined` | Optional custom usage reporting function. | +| `usageFlag` | `string \| undefined` | Name of the usage flag. Defaults to `"help"`. | + +--- + +## **Quick Usage** + +```typescript +const app = cli({ + name: "my-cli", + description: "An example CLI application", + flags: [ + bool({ name: "verbose", description: "Enable verbose mode" }), + str({ name: "config", description: "Path to the configuration file" }), + ], + commands: [ + cmd({ + name: "build", + description: "Build the project", + action: async () => { + console.log("Building project...") + }, + }), + ], +}) + +// Execute the CLI +app.run(process.argv).catch(console.error) +``` + +--- + +## **Notes** + +- The `cli` factory simplifies CLI initialization and reuse. +- Usage reporting is handled seamlessly via the `usageFlag` and `onUsageReporter`. +- Scopes (`global` and `current`) ensure proper separation of flags and commands. diff --git a/eslint.config.js b/eslint.config.js index da9e75c..75f77e0 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -6,7 +6,6 @@ import prettier from "eslint-config-prettier" /** @type {import('eslint').Linter.Config[]} */ export default [ - { files: ["docs/**/*.{ts,js,vue}", "src/*.{ts}"] }, { languageOptions: { globals: { ...globals.browser, ...globals.node } } }, pluginJs.configs.recommended, ...tseslint.configs.recommended, @@ -30,4 +29,7 @@ export default [ }, }, prettier, + { + ignores: ["**/node_modules/**", "**/dist/**", "**/build/**", "**/coverage/**"], + }, ] diff --git a/package.json b/package.json index 7020367..92df014 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "climonad", - "version": "0.3.0", - "description": "A high-performance, low-overhead library for building modern command-line interfaces in Node.js", + "version": "0.4.0", + "description": "A feature-rich framework for building structured and maintainable command-line tools", "main": "./dist/main.cjs", "type": "module", "module": "./dist/main.mjs", @@ -24,8 +24,8 @@ "test": "vitest -c vitest.config.ts", "lint": "eslint .", "lint:fix": "eslint --fix .", - "format": "prettier --check {*.md,*.ts,eslint.config.js}", - "format:fix": "prettier --write {*.md,*.ts,eslint.config.js}", + "format": "prettier --check {test,src}/**/*.ts docs/**/*.md {*.md,*.ts,eslint.config.js}", + "format:fix": "prettier --write {test,src}/**/*.ts docs/**/*.md {*.md,*.ts,eslint.config.js}", "prebench": "npm run build", "bench": "deno bench test/bench.ts", "test:watch": "vitest -c vitest.config.ts --watch", diff --git a/src/Command.ts b/src/Command.ts new file mode 100644 index 0000000..bdddf0b --- /dev/null +++ b/src/Command.ts @@ -0,0 +1,51 @@ +import { Flag } from "./Flag" +import { PrimitiveValues } from "./types" + +// === Type Definitions for Parsing === +export type ParsedFlags = Map +export type ParsedCommands = Map +export type ParsedArgs = { flags: ParsedFlags; commands: ParsedCommands } +export type CommandAction = (parsedArgs: ParsedArgs, command: string) => void | Promise + +// === Command Configuration Interface === +export interface CommandConfig { + name: string + description: string + alias?: string + required?: boolean + flags?: Flag[] + commands?: Command[] + action?: CommandAction + onUsageReporter?: (command: Command) => Promise | void +} + +// === Command Class Implementation === +export class Command { + public readonly name: string + public readonly description: string + public readonly alias?: string + public readonly required: boolean + public readonly flags?: Flag[] + public readonly commands?: Command[] + public readonly action?: CommandAction + public readonly onUsageReporter?: (command: Command) => Promise | void + + // Initialize command properties + constructor(config: CommandConfig) { + this.name = config.name + this.description = config.description + this.required = config.required || false + this.alias = config.alias + this.flags = config.flags + this.commands = config.commands + this.action = config.action + this.onUsageReporter = config.onUsageReporter + } +} + +// === Command Factory Function === +export function cmd(config: CommandConfig | Command) { + // Create a new Command instance or return existing one + if (config instanceof Command) return config + return new Command(config) +} diff --git a/src/Flag.ts b/src/Flag.ts new file mode 100644 index 0000000..b7b8a75 --- /dev/null +++ b/src/Flag.ts @@ -0,0 +1,103 @@ +// === Parser Configuration Interfaces === +export interface ParserConfig { + next: string[] + index: number + hasFlag: (key: string) => boolean + hasCmd: (key: string) => boolean + setIndex: (index: number) => void +} + +export type Parser = (this: Flag, config: ParserConfig) => T | null | Promise + +// === Flag Configuration Interfaces === +export interface FlagConfig { + name: string + description: string + alias?: string + required?: boolean + default?: T + parser?: Parser +} + +export interface FlagConstructorConfig extends FlagConfig { + type: string +} + +export const defaultParser: Parser = async function () { + throw new Error("Missing implementation") +} + +// === Flag Class Definition === +export class Flag { + public readonly type: string + public readonly name: string + public readonly description: string + public readonly required: boolean + public readonly default?: T + public readonly alias?: string + public readonly parser: Parser + + // Initialize flag properties + constructor(config: FlagConstructorConfig) { + this.type = config.type + this.name = config.name + this.description = config.description + this.required = config.required || false + this.default = config.default + this.alias = config.alias + this.parser = config.parser || defaultParser + } +} + +// === Predefined Flag Constructors === +export const bool = (config: FlagConfig) => + new Flag({ ...config, type: "boolean", parser: boolFlagParser }) + +export const str = (config: FlagConfig) => + new Flag({ ...config, type: "string", parser: strFlagParser }) + +export const num = (config: FlagConfig) => + new Flag({ ...config, type: "number", parser: numFlagParser }) + +// === Flag Parser Functions === +export const boolFlagParser: Parser = async function ({ next, index, setIndex }) { + // Parse boolean flag values + const value = next[index] + + if (value === "true" || value === "false") { + setIndex(index + 1) + return value === "true" + } + + if (this.default !== undefined) return this.default + + return true +} + +export const strFlagParser: Parser = async function ({ next, hasFlag, hasCmd, index, setIndex }) { + // Parse string flag values, ensuring it doesn't clash with existing flags or commands + const value = next[index] + + if (hasFlag(value) || hasCmd(value)) { + if (this.default !== undefined) return this.default + return null + } + + setIndex(index + 1) + return value +} + +export const numFlagParser: Parser = async function ({ next, index, setIndex }) { + // Parse numeric flag values + const value = next[index] + const num = Number(value) + + if (!isNaN(num)) { + setIndex(index + 1) + return num + } + + if (this.default !== undefined) return this.default + + return null +} diff --git a/src/Scope.ts b/src/Scope.ts new file mode 100644 index 0000000..cba321b --- /dev/null +++ b/src/Scope.ts @@ -0,0 +1,153 @@ +import { Command } from "./Command" +import { Flag } from "./Flag" + +// === Scope Properties === +export class Scope { + private readonly flags: Map = new Map() + private readonly commands: Map = new Map() + + private readonly flagsList: Flag[] = [] + private readonly commandsList: Command[] = [] + + private readonly requiredFlags: number[] = [] + private readonly requiredCommands: number[] = [] + + private readonly flagsWithDefaultValues: number[] = [] + + private readonly commandsStack: number[] = [] + + private readonly usageReporters: Map Promise | void> = new Map() + + // === Constructor === + constructor() {} + + // === Debugging Methods === + debug() { + return { + flagsList: this.flagsList, + commandsList: this.commandsList, + + flags: this.flags, + commands: this.commands, + + requiredFlags: this.requiredFlags, + requiredCommands: this.requiredCommands, + + commandsStack: this.commandsStack, + + usageReporters: this.usageReporters, + } + } + + // === Flag Management Methods === + forEachRequiredFlag(callback: (flag: Flag, index: number) => void): void { + for (const index of this.requiredFlags) { + callback(this.flagsList[index], index) + } + } + + forEachFlagWithDefaultValue(callback: (flag: Flag, index: number) => void): void { + for (const index of this.flagsWithDefaultValues) { + callback(this.flagsList[index], index) + } + } + + // === Command Management Methods === + forEachRequiredCommand(callback: (command: Command, index: number) => void): void { + for (const index of this.requiredCommands) { + callback(this.commandsList[index], index) + } + } + + // === Usage Reporter Management Methods === + setUsageReporter(name: string, reporter: (command: Command) => Promise | void): void { + this.usageReporters.set(name, reporter) + } + + hasUsageReporter(name: string): boolean { + return this.usageReporters.has(name) + } + + getUsageReporter(name: string): (command: Command) => Promise | void { + return this.usageReporters.get(name)! + } + + // === General Utility Methods === + has(key: number): boolean { + return this.flagsList[key] !== undefined || this.commandsList[key] !== undefined + } + + hasFlag(key: string): boolean { + return this.flags.has(key) + } + + hasCmd(key: string): boolean { + return this.commands.has(key) + } + + getFlagIndex(key: string): number | null { + return this.flags.get(key) ?? null + } + + getCmdIndex(key: string): number | null { + return this.commands.get(key) ?? null + } + + getFlag(key: string): Flag | null { + return (this.flagsList[this.getFlagIndex(key)!] as Flag) ?? null + } + + getCmd(key: string): Command | null { + return (this.commandsList[this.getCmdIndex(key)!] as Command) ?? null + } + + // === Methods to Add Flags and Commands === + addFlag(entry: Flag): void { + // Ensure the entry is an instance of Flag before adding + if (!(entry instanceof Flag)) { + throw new Error("Failed to add flag: entry is not an instance of Flag") + } + + const { key, alias } = { key: `--${entry.name}`, alias: entry.alias ? `-${entry.alias}` : undefined } + const index = this.flagsList.length + + // Map flag keys and aliases to their index + this.flags.set(key, index) + + if (alias) this.flags.set(alias, index) + + // Track required flags + if (entry.required) this.requiredFlags.push(index) + + // Track flags with default values + if (entry.default !== undefined) this.flagsWithDefaultValues.push(index) + + // Add the flag to the flags list + this.flagsList[index] = entry + } + + addCmd(entry: Command): void { + // Ensure the entry is an instance of Command before adding + if (!(entry instanceof Command)) { + throw new Error("Failed to add command: entry is not an instance of Command") + } + + const { key, alias } = { key: entry.name, alias: entry.alias } + const index = this.commandsList.length + + // Map command keys and aliases to their index + this.commands.set(key, index) + + if (alias) this.commands.set(alias, index) + + // Track required commands + if (entry.required) this.requiredCommands.push(index) + + // Set usage reporter if defined + if (typeof entry.onUsageReporter === "function") this.setUsageReporter(entry.name, entry.onUsageReporter) + + // Add the command to the commands list and stack + this.commandsList[index] = entry + this.commandsStack.push(index) + } +} diff --git a/src/Setup.ts b/src/Setup.ts new file mode 100644 index 0000000..5624a6f --- /dev/null +++ b/src/Setup.ts @@ -0,0 +1,240 @@ +import { Command, ParsedArgs, ParsedCommands, ParsedFlags } from "./Command" +import { Flag } from "./Flag" +import { Scope } from "./Scope" +import { PrimitiveValues } from "./types" + +// === Scope Interfaces === +interface Scopes { + global: Scope + current: Scope +} + +// === Setup Configuration Interface === +export interface SetupConfig { + name: string + description: string + flags?: Flag[] + commands?: Command[] + onUsageReporter?: (command: Command) => Promise | void + usageFlag?: string +} + +// === Setup Class Definition === +export class Setup { + // === Setup Properties === + private readonly name: string + private readonly scopes: Scopes + private readonly config: SetupConfig + private latestCommand: string + + // === Constructor Method === + constructor(config: SetupConfig) { + // Initialize setup with configuration and scopes + this.name = config.name + this.scopes = { global: new Scope(), current: new Scope() } + this.latestCommand = this.name + this.config = config + + // Update the global scope with the initial configuration + this.updateCommandScope(config, this.scopes.global) + } + + // === Debugging Methods === + public debug() { + return { global: this.scopes.global.debug(), current: this.scopes.current.debug() } + } + + // === Default Flag Application === + public applyDefaults(scope: Scope, flags: Map) { + scope.forEachFlagWithDefaultValue((flag) => { + if (!flags.has(flag.name)) { + flags.set(flag.name, flag.default) + } + }) + } + + // === Requirement Validation Methods === + public fromScopeCheckRequirements(scope: Scope, flags: ParsedFlags, commands: ParsedCommands) { + scope.forEachRequiredFlag((flag) => { + if (!flags.has(flag.name)) { + throw new Error(`Missing required flag: ${flag.name}`) + } + }) + + scope.forEachRequiredCommand((command) => { + if (!commands.has(command.name)) { + throw new Error(`Missing required command: ${command.name}`) + } + }) + } + + // === Command Action Execution === + public async runCommandActions(commands: ParsedCommands, flags: ParsedFlags) { + for (const [name, action] of commands) { + if (action) await action({ flags, commands }, name) + } + } + + // === Main Execution Method === + public async run(argv: string[]): Promise { + // Parse command-line arguments + const { flags, commands } = await this.parse(argv) + + // Apply default flag values to current and global scopes + this.applyDefaults(this.scopes.current, flags) + this.applyDefaults(this.scopes.global, flags) + + // Handle usage reporting if usage flag is present + if (this.hasUsageFlag(flags)) { + return await this.handleUsageReporting() + } + + // Validate required flags and commands in scopes + this.fromScopeCheckRequirements(this.scopes.global, flags, commands) + this.fromScopeCheckRequirements(this.scopes.current, flags, commands) + + // Execute actions associated with parsed commands + await this.runCommandActions(commands, flags) + } + + // === Usage Reporting Methods === + private async handleUsageReporting(): Promise { + // Retrieve the latest command or default to the setup configuration + const command: Command = this.getCmd(this.latestCommand) || new Command(this.config) + let reporter: Command["onUsageReporter"] | null = null + + // Determine if a usage reporter is available + if (this.hasUsageReporter(command)) reporter = this.getUsageReporter(command) + + if (!reporter) { + throw new Error(`No usage reporter found for command: ${this.latestCommand}`) + } + + // Execute the usage reporter + await reporter(command) + + return + } + + private hasUsageReporter(command: Command) { + return this.scopes.current.hasUsageReporter(command.name) || this.scopes.global.hasUsageReporter(this.name) + } + + private getUsageReporter(command: Command) { + return this.scopes.current.getUsageReporter(command.name) || this.scopes.global.getUsageReporter(this.name) || null + } + + private hasUsageFlag(flags: ParsedFlags) { + return flags.get(this.config.usageFlag || "help") === true + } + + // === Flag Management Methods === + public hasFlag(key: string): boolean { + return this.scopes.global.hasFlag(key) || this.scopes.current.hasFlag(key) + } + + public getFlag(key: string): Flag | null { + return this.scopes.global.getFlag(key) || this.scopes.current.getFlag(key) || null + } + + // === Command Management Methods === + public hasCmd(key: string): boolean { + return this.scopes.global.hasCmd(key) || this.scopes.current.hasCmd(key) + } + + public getCmd(key: string): Command | null { + return this.scopes.global.getCmd(key) || this.scopes.current.getCmd(key) || null + } + + // === Argument Parsing Method === + public async parse(argv: string[]): Promise { + const { flags, commands } = { flags: new Map(), commands: new Map() } as ParsedArgs + + // Slice the first two arguments (node and script) and initialize the index + const tokens = argv.slice(2) + let i = 0 + + // Iterate through each token in the command-line arguments + while (i < tokens.length) { + const token = tokens[i] + + // If the usage flag is present, return the current flags and commands + if (this.hasUsageFlag(flags)) { + return { flags, commands } + } + + // Check if the current token is a recognized flag + if (this.hasFlag(token)) { + let currentIndex = i + 1 // Move to the next token after the flag + const flag = this.getFlag(token)! + + try { + // Bind the parser function to the flag instance + const parser = flag.parser?.bind(flag) + + const value = await parser({ + next: tokens, + index: currentIndex, + setIndex: (index) => { + currentIndex = index + }, + hasFlag: (key: string) => this.hasFlag(key), + hasCmd: (key: string) => this.hasCmd(key), + }) + + if (value != null) { + flags.set(flag.name, value) + + // Update the index based on the parser's consumption of tokens + i = currentIndex > i ? currentIndex : i + 1 + continue + } + + throw new Error(`Invalid value for flag: ${flag.name}`) + } catch (error) { + if (error instanceof Error) { + throw error + } + throw new Error(`Error while validating flag: ${flag.name}`) + } + } + + // Check if the current token is a recognized command + if (this.getCmd(token)) { + const command = this.getCmd(token)! + commands.set(command.name, command.action) + + // Update the latest command and its scope + this.latestCommand = token + this.updateCommandScope(command, this.scopes.current) + i++ + continue + } + + throw new Error(`Unknown token: ${token}`) + } + + return { + flags, + commands, + } + } + + // === Scope Update Methods === + public updateCommandScope(command: Command | SetupConfig, scope: Scope): void { + const flags = command.flags || [] + const commands = command.commands || [] + + for (const flag of flags) scope.addFlag(flag) + + for (const cmd of commands) scope.addCmd(cmd) + + if (typeof command.onUsageReporter === "function") scope.setUsageReporter(command.name, command.onUsageReporter) + } +} + +// === CLI Initialization Function === +export function cli(config: SetupConfig | Setup) { + if (config instanceof Setup) return config + return new Setup(config) +} diff --git a/src/cli.ts b/src/cli.ts deleted file mode 100644 index 34d494c..0000000 --- a/src/cli.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { Flags } from "./flags" -import { Parser } from "./parser" -import * as Types from "./types" -import { UsageGenerator } from "./usageGenerator" -import { Utils } from "./utils" - -export class CliError extends Error { - constructor( - message: string, - public code?: string, - ) { - super(message) - this.name = "CliError" - } -} - -export namespace Cli { - export namespace Core { - export interface ParseResult { - /** - * Set of commands parsed from the input arguments. - */ - commands: Set - - /** - * Map of flag names to their parsed values. - */ - flags: Map - - /** - * Generates a help message for the current command. - * @returns Command usage information or `null` if help was not requested. - */ - generateHelp: () => Types.CommandUsage | null - } - } - - /** - * Manages the setup and parsing of CLI commands and flags. - */ - export class Setup { - private readonly tree = new Parser.Tree() - private readonly scopeCache = new Map() - private readonly rootCommand: Types.Command - private readonly helpFlag: Types.Flag - private readonly globalFlags: Types.Flag[] - private readonly usageGenerator: UsageGenerator - - /** - * Initializes the CLI setup with the given configuration. - * @param config - The CLI configuration object. - */ - constructor(config: Types.CliConfig) { - this.helpFlag = UsageGenerator.createHelpFlag() - this.globalFlags = [...(config.flags || []), this.helpFlag] - this.rootCommand = new Types.Command({ - ...config, - flags: this.globalFlags, - }) - - this.globalFlags.forEach((f) => this.tree.insert(f.flag, f)) - this.rootCommand.commands?.forEach((cmd) => { - this.tree.insert(cmd.name, cmd) - if (cmd.alias) this.tree.insert(cmd.alias, cmd) - }) - - this.usageGenerator = new UsageGenerator(this.rootCommand, this.globalFlags) - } - - /** - * Parses CLI arguments. - * @param args - The command-line arguments to parse. - * @returns A `ParseResult` object containing commands, flags, errors, and help generation. - */ - parse(args: string[]): Core.ParseResult { - const result: Core.ParseResult = { - commands: new Set(), - flags: new Map(), - generateHelp: () => null, - } - - let scope: Parser.Scope | null = null - let currentCommand: Types.Command | null = this.rootCommand - let helpRequested = false - const seenFlags = new Set() - - for (let i = 0; i < args.length; i++) { - const arg = args[i] - - const entry = scope?.search(arg) ?? this.tree.search(arg) - - if (!entry) { - throw new CliError( - `Unknown argument '${arg}'. Use '--help' to see available commands and options.`, - "UNKNOWN_ARGUMENT", - ) - } - - if (entry instanceof Types.Command) { - result.commands.add(arg) - currentCommand = entry - scope = this.scope(arg) - } else if (entry instanceof Types.Flag) { - seenFlags.add(entry.flag) - if (entry === this.helpFlag) { - helpRequested = true - break - } - - const values = [] - while (args[i + 1] && !args[i + 1].startsWith("-")) { - i++ - const nextArg = args[i] - if (!entry.isValid(nextArg)) { - throw new CliError( - `Invalid value '${nextArg}' for flag '${entry.name}'. Expected type: ${entry.type}.`, - "INVALID_OPTION_VALUE", - ) - } - values.push(entry.convert(nextArg)) - if (!entry.multiple) { - break - } - } - - if (values.length === 0) { - if (entry.default !== undefined) { - result.flags.set(entry.name, entry.default) - } else if (entry.type === "boolean") { - result.flags.set(entry.name, true) - } else { - throw new CliError( - `Missing value for flag '${entry.name}'. Expected type: ${entry.type}.`, - "MISSING_OPTION_VALUE", - ) - } - } else { - const value = entry.multiple ? values : values[0] - result.flags.set(entry.name, value) - } - } - } - - this.postProcess(result, currentCommand) - - result.generateHelp = () => - helpRequested ? this.usageGenerator.generate(currentCommand, Array.from(result.commands)) : null - - return result - } - - /** - * Post-processes the parsed results by applying default values and validating required flags. - * @param result - The ParseResult object to update. - * @param currentCommand - The current command being processed. - */ - private postProcess(result: Core.ParseResult, currentCommand: Types.Command | null) { - const allFlags = [...(currentCommand?.flags || []), ...this.globalFlags] - - for (const flag of allFlags) { - const isFlagPresent = result.flags.has(flag.name) - const hasDefaultValue = flag.default !== undefined - - if (!isFlagPresent && hasDefaultValue) { - result.flags.set(flag.name, flag.default) - } - - const isFlagRequired = flag.required === true - - if (isFlagRequired && !result.flags.has(flag.name)) { - throw new CliError(`Missing required flag '${flag.name}'.`, "MISSING_REQUIRED_OPTION") - } - } - } - - /** - * Retrieves or creates a scope object for a command. - * @param commandName - The name of the command to retrieve the scope for. - * @returns The scope associated with the command. - */ - private scope(commandName: string): Parser.Scope { - if (this.scopeCache.has(commandName)) { - return this.scopeCache.get(commandName)! - } - const command = this.tree.search(commandName) - const scope = new Parser.Scope(command as Types.Command) - this.scopeCache.set(commandName, scope) - return scope - } - } - - /** - * Creates a new CLI setup instance. - * @param config - The CLI configuration object. - * @returns A new instance of `Setup`. - */ - export const createCli = (config: Types.CliConfig): Setup => new Setup(config) - - /** - * Creates a new command instance. - * @param config - The configuration for the command. - * @returns A new `Command` instance. - */ - export const cmd = (config: Types.CommandConfig): Types.Command => new Types.Command(config) - - /** - * Creates a string flag. - * @param config - The configuration for the flag. - * @param pattern - Optional regex to validate the flag value. - * @returns A new string `TypedFlag` instance. - */ - export const str = (config: Types.FlagConfig, pattern?: RegExp) => - new Flags.TypedFlag( - { ...config, type: "string" }, - (value) => String(value), - (value) => typeof value === "string" && (!pattern || pattern.test(value as string)), - ) - - /** - * Creates a boolean flag. - * @param config - The configuration for the flag. - * @returns A new boolean `TypedFlag` instance. - */ - export const bool = (config: Types.FlagConfig) => - new Flags.TypedFlag( - { ...config, type: "boolean" }, - (value) => Utils.toBooleanValue(value), - (value) => Utils.isValidBoolean(value), - ) - - /** - * Creates a number flag. - * @param config - The configuration for the flag. - * @param min - Optional minimum value for the flag. - * @param max - Optional maximum value for the flag. - * @returns A new number `TypedFlag` instance. - */ - export const num = (config: Types.FlagConfig, min?: number, max?: number) => - new Flags.TypedFlag( - { ...config, type: "number" }, - (value) => Number(value), - (value) => Utils.isValidNumber(value, min, max), - ) -} diff --git a/src/flags.ts b/src/flags.ts deleted file mode 100644 index 0873c8c..0000000 --- a/src/flags.ts +++ /dev/null @@ -1,21 +0,0 @@ -import * as Types from "./types" - -export namespace Flags { - export class TypedFlag extends Types.Flag { - constructor( - config: Types.FlagConfig, - private readonly converter: (value: unknown) => T, - private readonly validator?: (value: unknown) => boolean, - ) { - super(config) - } - - isValid(value: unknown): boolean { - return this.validator ? this.validator(value) : true - } - - convert(value: unknown): T | T[] { - return this.converter(value) - } - } -} diff --git a/src/main.ts b/src/main.ts index 4947442..e1165d7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,3 +1,3 @@ -export { Cli, CliError } from "./cli" -export * from "./types" -export { Flags } from "./flags" +export { cmd, Command } from "./Command" +export { bool, Flag, num, str } from "./Flag" +export { cli, Setup, SetupConfig } from "./Setup" diff --git a/src/parser.ts b/src/parser.ts deleted file mode 100644 index af2aa9f..0000000 --- a/src/parser.ts +++ /dev/null @@ -1,120 +0,0 @@ -import type * as Types from "./types" - -export namespace Parser { - /** - * Represents a node in the parsing tree. - * @template T The type of value stored in the node. - */ - export class Node { - children: Map> - value: T | null - - constructor() { - this.children = new Map() - this.value = null - } - } - - /** - * Represents a tree structure used for parsing commands and options. - * @template T The type of values stored in the tree nodes. - */ - export class Tree { - root: Node - cache: Map - - constructor() { - this.root = new Node() - this.cache = new Map() - } - - /** - * Inserts a path and associated value into the tree. - * @param path The path string to insert. - * @param value The value associated with the path. - */ - insert(path: string, value: T) { - if (!path) return - const paths = [path, (value as any).alias].filter(Boolean) as string[] - for (const p of paths) { - const parts = p.split(" ") - this._insertPath(parts, value) - } - this.clearCache() - } - - /** - * Clears the cache of search results. - */ - clearCache() { - this.cache.clear() - } - - _insertPath(parts: string[], value: T) { - if (!parts?.length) return - let node = this.root - if (!node) { - node = this.root = new Node() - } - for (const part of parts) { - if (!node.children.has(part)) { - node.children.set(part, new Node()) - } - node = node.children.get(part)! - } - node.value = value - } - - /** - * Searches for a value associated with a given path. - * @param path The path string to search for. - * @returns The value associated with the path, or null if not found. - */ - search(path: string): T | null { - if (!this.root) { - this.cache.set(path, null) - return null - } - - if (this.cache.has(path)) { - return this.cache.get(path)! - } - - let node = this.root - const parts = path?.split(" ") - - for (const part of parts) { - if (!node.children.has(part)) { - this.cache.set(path, null) - return null - } - node = node.children.get(part)! - } - - const result = node.value - this.cache.set(path, result) - return result - } - - has(path: string): boolean { - return this.search(path) !== null - } - } - - export class Scope extends Tree { - constructor(command: Types.Command | null) { - super() - if (command) { - command.commands?.forEach((cmd) => { - this.insert(cmd.name, cmd) - if (cmd.alias) this.insert(cmd.alias, cmd) - }) - command.flags?.forEach((opt) => { - // renamed from command.options - this.insert(opt.flag, opt) - if (opt.alias) this.insert(opt.alias, opt) - }) - } - } - } -} diff --git a/src/types.ts b/src/types.ts index 9ec3e05..fac7e25 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,140 +1 @@ -/** - * Base configuration for commands and flags. - */ -export interface BaseConfig { - name: string - flag?: string - alias?: string - description: string -} - -/** - * The available types for flags. - */ -export type FlagType = "string" | "boolean" | "number" - -/** - * Configuration interface for defining a flag. - */ -export interface FlagConfig extends Omit { - flag: string - type?: FlagType - default?: any - multiple?: boolean - required?: boolean -} - -/** - * Configuration interface for defining a command. - */ -export interface CommandConfig extends BaseConfig { - commands?: Command[] - flags?: Flag[] // renamed from options -} - -/** - * Structure representing the usage information for a command. - */ -export interface CommandUsage { - name: string - description: string - commands?: CommandInfo[] - flags?: FlagInfo[] -} - -/** - * Information about a command, used in help display. - */ -export type CommandInfo = BaseConfig - -/** - * Information about a flag, used in help display. - */ -export interface FlagInfo extends BaseConfig { - type: FlagType -} - -/** - * Configuration for initializing the CLI. - */ -export interface CliConfig { - name: string - description: string - commands?: Command[] - flags?: Flag[] -} - -/** - * Specialized flag configuration for different types. - */ -export interface StringFlagConfig extends FlagConfig { - default?: string -} - -export interface BooleanFlagConfig extends FlagConfig { - default?: boolean -} - -export interface NumberFlagConfig extends FlagConfig { - default?: number -} - -/** - * Abstract base class for flags. - */ -export abstract class Flag { - public readonly type: FlagType - public readonly name: string - public readonly flag: string - public readonly description: string - public readonly alias?: string - public readonly default?: any - public readonly multiple?: boolean - public readonly required?: boolean - - constructor(config: FlagConfig) { - this.type = config.type || "string" - this.name = config.name - this.flag = config.flag - this.description = config.description - this.alias = config.alias - this.default = config.default - this.multiple = config.multiple - this.required = config.required - } - - /** - * Checks if the provided value is valid for this flag. - * @param value The value to validate. - * @returns True if valid, false otherwise. - */ - abstract isValid(value: unknown): boolean - - /** - * Converts the provided value to the appropriate type. - * @param value The value to convert. - * @returns The converted value. - */ - abstract convert(value: unknown): any -} - -/** - * Class representing a command in the CLI. - */ -export class Command { - public readonly name: string - public readonly flag?: string - public readonly description: string - public readonly alias?: string - public readonly commands?: Command[] - public flags?: Flag[] // renamed from options - - constructor(config: CommandConfig) { - this.name = config.name - this.flag = config.flag - this.description = config.description - this.alias = config.alias - this.commands = config.commands - this.flags = config.flags - } -} +export type PrimitiveValues = string | number | boolean diff --git a/src/usageGenerator.ts b/src/usageGenerator.ts deleted file mode 100644 index 330a14c..0000000 --- a/src/usageGenerator.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { Flags } from "./flags" -import type * as Types from "./types" -import { Utils } from "./utils" - -export class UsageGenerator { - /** - * Creates the default help option flag. - * @returns A boolean flag representing the help option. - */ - static createHelpFlag(): Types.Flag { - return new Flags.TypedFlag( - { - type: "boolean", - name: "help", - flag: "--help", - description: "Show help", - alias: "-h", - }, - () => true, - ) - } - - constructor( - private readonly rootCommand: Types.Command, - private readonly globalFlags: Types.Flag[], - ) {} - - /** - * Generates usage information for the current command or root command. - * @param currentCommand The command to generate help for. - * @param path The command path leading to the current command. - * @returns The command usage information. - */ - generate(currentCommand: Types.Command | null, path: string[]): Types.CommandUsage { - return currentCommand ? this.generateCommandHelp(currentCommand, path) : this.generateRootHelp() - } - - /** - * Generates usage information for the root command. - * @returns The usage information for the root command. - */ - private generateRootHelp(): Types.CommandUsage { - return { - name: this.rootCommand.name, - description: this.rootCommand.description, - flags: this.formatFlags(this.globalFlags), - } - } - - /** - * Generates usage information for a specific command. - * @param command The command to generate help for. - * @param path The command path leading to the command. - * @returns The command usage information. - */ - private generateCommandHelp(command: Types.Command, path: string[]): Types.CommandUsage { - const commandPath = this.getCommandPath(command, path) - const commandFlags = this.getCommandFlags(command) - - return { - name: commandPath, - description: command.description, - commands: this.formatCommands(command.commands), - flags: this.formatFlags(commandFlags), - } - } - - /** - * Constructs the full command path as a string. - * @param command The command to get the path for. - * @param path The command path leading to the command. - * @returns The full command path. - */ - private getCommandPath(command: Types.Command, path: string[]): string { - return path.length ? path.join(" ") : command.name - } - - /** - * Retrieves all options for a command, combining command-specific options with global options. - * @param command The command to get options for. - * @returns An array of flags applicable to the command. - */ - private getCommandFlags(command: Types.Command): Types.Flag[] { - const commandFlags = command.flags || [] - return [...commandFlags.filter((f) => !this.globalFlags.includes(f)), ...this.globalFlags] - } - - /** - * Formats a list of commands into CommandInfo objects for display. - * @param commands The list of commands to format. - * @returns An array of formatted command information. - */ - private formatCommands(commands?: Types.Command[]): Types.CommandInfo[] | undefined { - return commands?.map((cmd) => ({ - name: cmd.name, - flag: cmd.name, - alias: cmd.alias, - description: cmd.description, - })) - } - - /** - * Formats a list of options into FlagInfo objects for display. - * @param options The list of options to format. - * @returns An array of formatted flag information. - */ - private formatFlags(flags: Types.Flag[]): Types.FlagInfo[] { - return flags.map((opt) => ({ - name: Utils.formatFlag(opt.flag, opt.alias), - alias: opt.alias, - type: opt.type, - description: opt.description, - flag: opt.flag, - multiple: opt.multiple, - })) - } -} diff --git a/src/utils.ts b/src/utils.ts deleted file mode 100644 index e6ebdba..0000000 --- a/src/utils.ts +++ /dev/null @@ -1,40 +0,0 @@ -export namespace Utils { - /** - * Determines if a value is a valid boolean representation. - * Accepts boolean values or strings "true", "false", "1", "0". - */ - export function isValidBoolean(value: unknown): boolean { - return typeof value === "boolean" || ["true", "false", "1", "0"].includes(String(value)) - } - - /** - * Checks if a value is a valid number within an optional range. - * Converts the value to a number and checks if it's not NaN and within the specified min and max. - */ - export function isValidNumber(value: unknown, min?: number, max?: number): boolean { - const num = Number(value) - if (isNaN(num)) return false - if (min !== undefined && num < min) return false - if (max !== undefined && num > max) return false - return true - } - - /** - * Converts a value to a boolean. - * Returns true for boolean true, or strings "true", "1"; otherwise false. - */ - export function toBooleanValue(value: unknown): boolean { - if (typeof value === "boolean") return value - return ["true", "1"].includes(String(value)) - } - - // String formatting utilities - - /** - * Formats a flag string by combining primary and secondary flags. - * If a secondary flag is provided, returns "primary, secondary"; otherwise just "primary". - */ - export function formatFlag(primary: string, secondary?: string): string { - return secondary ? `${primary}, ${secondary}` : primary - } -} diff --git a/test/Command.test.ts b/test/Command.test.ts new file mode 100644 index 0000000..d196258 --- /dev/null +++ b/test/Command.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "vitest" +import { cmd, Command, CommandConfig, ParsedArgs } from "../src/Command" +import { bool, str } from "../src/Flag" + +describe("Command", () => { + describe("Constructor", () => { + it("should create a command with basic properties", () => { + const command = new Command({ + name: "test", + description: "test command", + }) + + expect(command.name).toBe("test") + expect(command.description).toBe("test command") + expect(command.required).toBe(false) + expect(command.alias).toBeUndefined() + expect(command.flags).toBeUndefined() + expect(command.commands).toBeUndefined() + expect(command.action).toBeUndefined() + }) + + it("should create a command with all properties", () => { + const flags = [str({ name: "test", description: "test flag" })] + const subcommands = [new Command({ name: "sub", description: "sub command" })] + const action = async () => {} + const usageReporter = async () => {} + + const command = new Command({ + name: "test", + description: "test command", + required: true, + alias: "t", + flags, + commands: subcommands, + action, + onUsageReporter: usageReporter, + }) + + expect(command.name).toBe("test") + expect(command.description).toBe("test command") + expect(command.required).toBe(true) + expect(command.alias).toBe("t") + expect(command.flags).toEqual(flags) + expect(command.commands).toEqual(subcommands) + expect(command.action).toBe(action) + expect(command.onUsageReporter).toBe(usageReporter) + }) + }) + + describe("Command Factory", () => { + it("should create new command instance from config", () => { + const config: CommandConfig = { + name: "test", + description: "test command", + } + + const command = cmd(config) + expect(command).toBeInstanceOf(Command) + expect(command.name).toBe("test") + }) + + it("should return existing command instance", () => { + const existing = new Command({ + name: "test", + description: "test command", + }) + + const command = cmd(existing) + expect(command).toBe(existing) + }) + }) + + describe("Command Actions", () => { + it("should execute command action with parsed args", async () => { + let executed = false + const parsedArgs: ParsedArgs = { + flags: new Map([["verbose", true]]), + commands: new Map(), + } + + const command = new Command({ + name: "test", + description: "test command", + action: async (args) => { + expect(args).toBe(parsedArgs) + executed = true + }, + }) + + await command.action!(parsedArgs, command.name) + expect(executed).toBe(true) + }) + + it("should handle commands with subcommands", () => { + const subcommand = new Command({ + name: "sub", + description: "subcommand", + }) + + const command = new Command({ + name: "main", + description: "main command", + commands: [subcommand], + }) + + expect(command.commands).toContain(subcommand) + }) + + it("should handle commands with flags", () => { + const flag = bool({ + name: "verbose", + description: "verbose mode", + }) + + const command = new Command({ + name: "test", + description: "test command", + flags: [flag], + }) + + expect(command.flags).toContain(flag) + }) + }) + + describe("Usage Reporter", () => { + it("should execute usage reporter", async () => { + let reported = false + const command = new Command({ + name: "test", + description: "test command", + onUsageReporter: async (cmd) => { + expect(cmd.name).toBe("test") + reported = true + }, + }) + + await command.onUsageReporter!(command) + expect(reported).toBe(true) + }) + }) +}) diff --git a/test/Flag.test.ts b/test/Flag.test.ts new file mode 100644 index 0000000..98016e3 --- /dev/null +++ b/test/Flag.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it } from "vitest" +import { bool, Flag, num, ParserConfig, str } from "../src/Flag" + +describe("Flag", () => { + describe("Constructor", () => { + it("should create a flag with basic properties", () => { + const flag = new Flag({ + type: "string", + name: "test", + description: "test flag", + parser: async () => "test", + }) + + expect(flag.name).toBe("test") + expect(flag.type).toBe("string") + expect(flag.description).toBe("test flag") + expect(flag.required).toBe(false) + expect(flag.default).toBeUndefined() + expect(flag.alias).toBeUndefined() + }) + + it("should create a flag with all properties", () => { + const flag = new Flag({ + type: "number", + name: "count", + description: "count items", + required: true, + default: 42, + alias: "c", + parser: async () => 42, + }) + + expect(flag.name).toBe("count") + expect(flag.type).toBe("number") + expect(flag.required).toBe(true) + expect(flag.default).toBe(42) + expect(flag.alias).toBe("c") + }) + + it("should throw when parser is not implemented", async () => { + const flag = new Flag({ + type: "string", + name: "test", + description: "test flag", + }) + + await expect(flag.parser({} as ParserConfig)).rejects.toThrow() + }) + }) + + describe("Flag Constructors", () => { + it("should create boolean flag", () => { + const flag = bool({ + name: "verbose", + description: "verbose mode", + }) + + expect(flag).toBeInstanceOf(Flag) + expect(flag.type).toBe("boolean") + }) + + it("should create string flag", () => { + const flag = str({ + name: "name", + description: "user name", + }) + + expect(flag).toBeInstanceOf(Flag) + expect(flag.type).toBe("string") + }) + + it("should create number flag", () => { + const flag = num({ + name: "port", + description: "port number", + }) + + expect(flag).toBeInstanceOf(Flag) + expect(flag.type).toBe("number") + }) + }) + + describe("Flag Parsers", () => { + describe("Boolean Parser", () => { + it("should parse true/false strings", async () => { + const flag = bool({ name: "test", description: "test" }) + const mockConfig = (value: string): ParserConfig => ({ + next: [value], + index: 0, + hasFlag: () => false, + hasCmd: () => false, + setIndex: () => {}, + }) + + expect(await flag.parser(mockConfig("true"))).toBe(true) + expect(await flag.parser(mockConfig("false"))).toBe(false) + }) + + it("should return default value when no value provided", async () => { + const flag = bool({ name: "test", description: "test", default: false }) + const config: ParserConfig = { + next: [], + index: 0, + hasFlag: () => false, + hasCmd: () => false, + setIndex: () => {}, + } + + expect(await flag.parser(config)).toBe(false) + }) + + it("should return true for flags without values", async () => { + const flag = bool({ name: "test", description: "test" }) + const config: ParserConfig = { + next: [], + index: 0, + hasFlag: () => false, + hasCmd: () => false, + setIndex: () => {}, + } + + expect(await flag.parser(config)).toBe(true) + }) + }) + + describe("String Parser", () => { + it("should parse string values", async () => { + const flag = str({ name: "test", description: "test" }) + const config: ParserConfig = { + next: ["value"], + index: 0, + hasFlag: () => false, + hasCmd: () => false, + setIndex: () => {}, + } + + expect(await flag.parser(config)).toBe("value") + }) + + it("should return default when value is a flag", async () => { + const flag = str({ name: "test", description: "test", default: "default" }) + const config: ParserConfig = { + next: ["--flag"], + index: 0, + hasFlag: (key) => key === "--flag", + hasCmd: () => false, + setIndex: () => {}, + } + + expect(await flag.parser(config)).toBe("default") + }) + + it("should return null when invalid and no default", async () => { + const flag = str({ name: "test", description: "test" }) + const config: ParserConfig = { + next: ["--flag"], + index: 0, + hasFlag: (key) => key === "--flag", + hasCmd: () => false, + setIndex: () => {}, + } + + expect(await flag.parser(config)).toBeNull() + }) + }) + + describe("Number Parser", () => { + it("should parse valid numbers", async () => { + const flag = num({ name: "test", description: "test" }) + const config: ParserConfig = { + next: ["42"], + index: 0, + hasFlag: () => false, + hasCmd: () => false, + setIndex: () => {}, + } + + expect(await flag.parser(config)).toBe(42) + }) + + it("should parse negative numbers and decimals", async () => { + const flag = num({ name: "test", description: "test" }) + const mockConfig = (value: string): ParserConfig => ({ + next: [value], + index: 0, + hasFlag: () => false, + hasCmd: () => false, + setIndex: () => {}, + }) + + expect(await flag.parser(mockConfig("-42"))).toBe(-42) + expect(await flag.parser(mockConfig("3.14"))).toBe(3.14) + }) + + it("should return default for invalid numbers", async () => { + const flag = num({ name: "test", description: "test", default: 100 }) + const config: ParserConfig = { + next: ["invalid"], + index: 0, + hasFlag: () => false, + hasCmd: () => false, + setIndex: () => {}, + } + + expect(await flag.parser(config)).toBe(100) + }) + + it("should return null for invalid numbers without default", async () => { + const flag = num({ name: "test", description: "test" }) + const config: ParserConfig = { + next: ["invalid"], + index: 0, + hasFlag: () => false, + hasCmd: () => false, + setIndex: () => {}, + } + + expect(await flag.parser(config)).toBeNull() + }) + }) + }) +}) diff --git a/test/Scope.test.ts b/test/Scope.test.ts new file mode 100644 index 0000000..dc525f5 --- /dev/null +++ b/test/Scope.test.ts @@ -0,0 +1,184 @@ +import { beforeEach, describe, expect, it } from "vitest" +import { Command } from "../src/Command" +import { Flag } from "../src/Flag" +import { Scope } from "../src/Scope" + +describe("Scope", () => { + let scope: Scope + + beforeEach(() => { + scope = new Scope() + }) + + it("should add and retrieve flags correctly", () => { + const flag = new Flag({ + type: "string", + name: "testFlag", + description: "A test flag", + }) + + scope.addFlag(flag) + expect(scope.hasFlag("--testFlag")).toBe(true) + expect(scope.getFlag("--testFlag")).toBe(flag) + }) + + it("should add and retrieve commands correctly", () => { + const command = new Command({ + name: "testCmd", + description: "A test command", + action: async () => {}, + }) + + scope.addCmd(command) + expect(scope.hasCmd("testCmd")).toBe(true) + expect(scope.getCmd("testCmd")).toBe(command) + }) + + it("should track required flags", () => { + const requiredFlag = new Flag({ + type: "string", + name: "requiredFlag", + description: "A required flag", + required: true, + }) + + scope.addFlag(requiredFlag) + // Accessing private property for testing purposes + expect((scope as any).requiredFlags).toContain(scope.getFlagIndex("--requiredFlag")) + }) + + it("should track flags with default values", () => { + const defaultFlag = new Flag({ + type: "number", + name: "defaultFlag", + description: "A flag with a default value", + default: 42, + }) + + scope.addFlag(defaultFlag) + // Accessing private property for testing purposes + expect((scope as any).flagsWithDefaultValues).toContain(scope.getFlagIndex("--defaultFlag")) + }) + + it("should manage usage reporters correctly", () => { + const reporter = async (command: Command) => { + const _ = `Usage for ${command.name}` + } + + scope.setUsageReporter("testReporter", reporter) + expect(scope.hasUsageReporter("testReporter")).toBe(true) + expect(scope.getUsageReporter("testReporter")).toBe(reporter) + }) + + it("should correctly identify existing keys using has()", () => { + const flag = new Flag({ + type: "boolean", + name: "existingFlag", + description: "An existing flag", + }) + + scope.addFlag(flag) + + expect(scope.has(0)).toBe(true) + expect(scope.has(132)).toBe(false) + }) + + it("should handle flag aliases correctly", () => { + const flag = new Flag({ + type: "string", + name: "test", + alias: "t", + description: "A test flag", + }) + + scope.addFlag(flag) + expect(scope.hasFlag("--test")).toBe(true) + expect(scope.hasFlag("-t")).toBe(true) + expect(scope.getFlag("--test")).toBe(flag) + expect(scope.getFlag("-t")).toBe(flag) + }) + + it("should handle command aliases correctly", () => { + const command = new Command({ + name: "test", + alias: "t", + description: "A test command", + action: async () => {}, + }) + + scope.addCmd(command) + expect(scope.hasCmd("test")).toBe(true) + expect(scope.hasCmd("t")).toBe(true) + expect(scope.getCmd("test")).toBe(command) + expect(scope.getCmd("t")).toBe(command) + }) + + it("should throw error when adding invalid flag", () => { + expect(() => { + scope.addFlag({} as Flag) + }).toThrow("Failed to add flag: entry is not an instance of Flag") + }) + + it("should throw error when adding invalid command", () => { + expect(() => { + scope.addCmd({} as Command) + }).toThrow("Failed to add command: entry is not an instance of Command") + }) + + it("should track command stack correctly", () => { + const cmd1 = new Command({ + name: "cmd1", + description: "Command 1", + }) + const cmd2 = new Command({ + name: "cmd2", + description: "Command 2", + }) + + scope.addCmd(cmd1) + scope.addCmd(cmd2) + + const debug = scope.debug() + expect(debug.commandsStack).toEqual([0, 1]) + }) + + it("should return correct debug information", () => { + const flag = new Flag({ + type: "string", + name: "test", + description: "Test flag", + required: true, + }) + const command = new Command({ + name: "test", + description: "Test command", + required: true, + }) + + scope.addFlag(flag) + scope.addCmd(command) + + const debug = scope.debug() + expect(debug.flagsList).toHaveLength(1) + expect(debug.commandsList).toHaveLength(1) + expect(debug.requiredFlags).toHaveLength(1) + expect(debug.requiredCommands).toHaveLength(1) + expect(debug.flags.size).toBe(1) + expect(debug.commands.size).toBe(1) + }) + + it("should handle index-based access correctly", () => { + const flag = new Flag({ + type: "string", + name: "test", + description: "Test flag", + }) + + scope.addFlag(flag) + + expect(scope.has(0)).toBe(true) + expect(scope.has(1)).toBe(false) + expect(scope.getFlagIndex("--test")).toBe(0) + expect(scope.getFlagIndex("--nonexistent")).toBe(null) + }) +}) diff --git a/test/Setup.test.ts b/test/Setup.test.ts new file mode 100644 index 0000000..d040328 --- /dev/null +++ b/test/Setup.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it, vi } from "vitest" +import { Command } from "../src/Command" +import { bool, num, str } from "../src/Flag" +import { Setup, cli } from "../src/Setup" + +describe("Setup", () => { + describe("Constructor", () => { + it("should create Setup instance with basic config", () => { + const setup = new Setup({ + name: "test", + description: "test cli", + }) + + expect(setup).toBeInstanceOf(Setup) + const debug = setup.debug() + expect(debug.global).toBeDefined() + expect(debug.current).toBeDefined() + }) + + it("should initialize with flags and commands", () => { + const setup = new Setup({ + name: "test", + description: "test cli", + flags: [bool({ name: "verbose", description: "verbose mode" })], + commands: [ + new Command({ + name: "init", + description: "initialize", + }), + ], + }) + + const debug = setup.debug() + expect(debug.global.flagsList).toHaveLength(1) + expect(debug.global.commandsList).toHaveLength(1) + }) + }) + + describe("Flag and Command Management", () => { + it("should correctly check for flag existence", () => { + const setup = new Setup({ + name: "test", + description: "test cli", + flags: [bool({ name: "verbose", description: "verbose mode" })], + }) + + expect(setup.hasFlag("--verbose")).toBe(true) + expect(setup.hasFlag("--nonexistent")).toBe(false) + }) + + it("should correctly retrieve flags", () => { + const flag = bool({ name: "verbose", description: "verbose mode" }) + const setup = new Setup({ + name: "test", + description: "test cli", + flags: [flag], + }) + + expect(setup.getFlag("--verbose")).toBe(flag) + expect(setup.getFlag("--nonexistent")).toBeNull() + }) + + it("should correctly check for command existence", () => { + const setup = new Setup({ + name: "test", + description: "test cli", + commands: [ + new Command({ + name: "init", + description: "initialize", + }), + ], + }) + + expect(setup.hasCmd("init")).toBe(true) + expect(setup.hasCmd("nonexistent")).toBe(false) + }) + }) + + describe("Argument Parsing", () => { + it("should parse boolean flags", async () => { + const setup = new Setup({ + name: "test", + description: "test cli", + flags: [bool({ name: "verbose", description: "verbose mode" })], + }) + + const result = await setup.parse(["node", "cli", "--verbose"]) + expect(result.flags.get("verbose")).toBe(true) + }) + + it("should parse string flags", async () => { + const setup = new Setup({ + name: "test", + description: "test cli", + flags: [str({ name: "name", description: "project name" })], + }) + + const result = await setup.parse(["node", "cli", "--name", "test-project"]) + expect(result.flags.get("name")).toBe("test-project") + }) + + it("should parse number flags", async () => { + const setup = new Setup({ + name: "test", + description: "test cli", + flags: [num({ name: "port", description: "port number" })], + }) + + const result = await setup.parse(["node", "cli", "--port", "3000"]) + expect(result.flags.get("port")).toBe(3000) + }) + + it("should throw on unknown tokens", async () => { + const setup = new Setup({ + name: "test", + description: "test cli", + }) + + await expect(setup.parse(["node", "cli", "--unknown"])).rejects.toThrow("Unknown token: --unknown") + }) + }) + + describe("Requirement Validation", () => { + it("should validate required flags", async () => { + const setup = new Setup({ + name: "test", + description: "test cli", + flags: [str({ name: "name", description: "project name", required: true })], + }) + + await expect(setup.run(["node", "cli"])).rejects.toThrow("Missing required flag: name") + }) + + it("should validate required commands", async () => { + const setup = new Setup({ + name: "test", + description: "test cli", + commands: [ + new Command({ + name: "init", + description: "initialize", + required: true, + }), + ], + }) + + await expect(setup.run(["node", "cli"])).rejects.toThrow("Missing required command: init") + }) + }) + + describe("Usage Reporting", () => { + it("should handle usage reporting", async () => { + const reporter = vi.fn() + const setup = new Setup({ + name: "test", + description: "test cli", + onUsageReporter: reporter, + usageFlag: "help", + flags: [bool({ name: "help", description: "show help" })], + }) + + await setup.run(["node", "cli", "--help"]) + expect(reporter).toHaveBeenCalled() + }) + + it("should throw when no usage reporter is available", async () => { + const setup = new Setup({ + name: "test", + description: "test cli", + usageFlag: "help", + }) + + await expect(setup.run(["node", "cli", "--help"])).rejects.toThrow() + }) + }) + + describe("CLI Factory", () => { + it("should create new Setup instance", () => { + const instance = cli({ + name: "test", + description: "test cli", + }) + + expect(instance).toBeInstanceOf(Setup) + }) + + it("should return existing Setup instance", () => { + const setup = new Setup({ + name: "test", + description: "test cli", + }) + + const instance = cli(setup) + expect(instance).toBe(setup) + }) + }) +}) diff --git a/test/bench.ts b/test/bench.ts index 1a1b97b..fe7be4d 100644 --- a/test/bench.ts +++ b/test/bench.ts @@ -7,40 +7,39 @@ * CPU: AMD Ryzen 7 4800HS with Radeon Graphics * Runtime: Deno 2.0.4 (x86_64-pc-windows-msvc) * - * benchmark time/iter (avg) iter/s (min … max) p75 p99 p995 - * ------------------------------ ----------------------------- --------------------- -------------------------- - * Cli.createCli 725.4 ns 1,379,000 (670.4 ns … 1.2 µs) 722.6 ns 1.2 µs 1.2 µs - * Cli#parse (command only) 190.5 ns 5,249,000 (165.9 ns … 378.2 ns) 197.7 ns 291.2 ns 296.0 ns - * Cli#parse (option only) 570.2 ns 1,754,000 (230.4 ns … 1.3 µs) 721.1 ns 1.3 µs 1.3 µs - * Cli#parse (command + option) 654.5 ns 1,528,000 (323.9 ns … 1.3 µs) 809.4 ns 1.3 µs 1.3 µs - * Cli.createCli (with parse) 3.6 µs 281,000 ( 3.0 µs … 4.1 µs) 3.6 µs 4.1 µs 4.1 µs + * benchmark time/iter (avg) iter/s (min … max) p75 p99 p995 + * -------------------------- ----------------------------- --------------------- -------------------------- + * cli 412.4 ns 2,425,000 (346.7 ns … 637.9 ns) 433.2 ns 548.4 ns 637.9 ns + * parse (command only) 298.5 ns 3,350,000 (251.9 ns … 1.0 µs) 295.1 ns 867.2 ns 1.0 µs + * parse (flag only) 1.7 µs 604,000 (774.9 ns … 3.6 µs) 1.9 µs 3.6 µs 3.6 µs + * parse (command + option) 1.8 µs 566,300 (600.0 ns … 8.0 ms) 900.0 ns 3.5 µs 4.7 µs + * cli (with parse) 857.0 ns 1,167,000 (400.0 ns … 3.5 ms) 700.0 ns 1.9 µs 2.4 µs * * Usage: * First, build the project with `npm run build` then run benchmarkds with `deno bench test/bench.ts` */ -import { Cli } from "../dist/main.mjs" +import { cmd, bool, cli } from "../dist/main.mjs" // Setup test CLI instance with a sample command and options // This instance will be reused across benchmarks -const helloCmd = Cli.cmd({ - name: "hello", - description: "Say hello", - options: [ - Cli.bool({ - name: "loud", - flag: "--loud", - alias: "-l", - description: "Say hello loudly", - }), - ], +const helloCmd = cmd({ + name: "hello", + description: "Say hello", + flags: [ + bool({ + name: "loud", + alias: "l", + description: "Say hello loudly", + }), + ], }) -const cli = Cli.createCli({ - name: "my-cli", - version: "1.0.0", - description: "My awesome CLI", - commands: [helloCmd], - options: [Cli.bool({ name: "verbose", flag: "--verbose", alias: "-v", description: "Enable verbose output" })], +const app = cli({ + name: "my-cli", + version: "1.0.0", + description: "My awesome CLI", + commands: [helloCmd], + flags: [bool({ name: "verbose", alias: "v", description: "Enable verbose output" })], }) /** @@ -49,36 +48,36 @@ const cli = Cli.createCli({ */ // Scenario 1: Measure CLI creation performance -Deno.bench("Cli.createCli", () => { - Cli.createCli({ - name: "benchmark-cli", - version: "1.0.0", - description: "Benchmark CLI", - commands: [helloCmd], - }) +Deno.bench("cli", () => { + cli({ + name: "benchmark-cli", + version: "1.0.0", + description: "Benchmark CLI", + commands: [helloCmd], + }) }) // Scenario 2: Measure parsing performance for different input combinations -Deno.bench("Cli#parse (command only)", () => { - cli.parse(["hello"]) +Deno.bench("parse (command only)", () => { + app.parse(["node", "script", "hello"]) }) -Deno.bench("Cli#parse (option only)", () => { - cli.parse(["--verbose"]) +Deno.bench("parse (flag only)", () => { + app.parse(["node", "script", "--verbose"]) }) -Deno.bench("Cli#parse (command + option)", () => { - cli.parse(["hello", "--loud", "--verbose"]) +Deno.bench("parse (command + option)", () => { + app.parse(["node", "script", "hello", "--loud", "--verbose"]) }) // Scenario 3: Measure parsing performance for a CLI instance with parse method -Deno.bench("Cli.createCli (with parse)", () => { - const c = Cli.createCli({ - name: "benchmark-cli", - version: "1.0.0", - description: "Benchmark CLI", - commands: [helloCmd], - }) +Deno.bench("cli (with parse)", () => { + const a = cli({ + name: "benchmark-cli", + version: "1.0.0", + description: "Benchmark CLI", + commands: [helloCmd], + }) - c.parse(["hello"]) + a.parse(["node", "script", "hello"]) }) diff --git a/test/cli.test.ts b/test/cli.test.ts deleted file mode 100644 index 87ed5a5..0000000 --- a/test/cli.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { describe, expect, it } from "vitest" -import { Cli, CliError } from "../src/cli" - -describe("Cli.Setup", () => { - it("should create a CLI with basic configuration", () => { - const cli = Cli.createCli({ - name: "test-cli", - description: "Test CLI", - }) - expect(cli).toBeInstanceOf(Cli.Setup) - }) - - describe("Command Parsing", () => { - const cli = Cli.createCli({ - name: "test-cli", - description: "Test CLI", - commands: [ - Cli.cmd({ - name: "serve", - description: "Start server", - alias: "s", - }), - Cli.cmd({ - name: "build", - description: "Build project", - }), - ], - }) - - it("should parse simple command", () => { - const result = cli.parse(["serve"]) - expect(result.commands.has("serve")).toBe(true) - }) - - it("should parse command alias", () => { - const result = cli.parse(["s"]) - expect(result.commands.has("s")).toBe(true) - }) - }) - - describe("Flag Parsing", () => { - const cli = Cli.createCli({ - name: "test-cli", - description: "Test CLI", - flags: [ - Cli.str({ name: "host", flag: "--host", description: "Host name" }), - Cli.num({ name: "port", flag: "--port", description: "Port number" }), - Cli.bool({ name: "verbose", flag: "--verbose", description: "Verbose mode" }), - ], - }) - - it("should parse string flag correctly", () => { - const result = cli.parse(["--host", "localhost"]) - expect(result.flags.get("host")).toBe("localhost") - }) - - it("should parse number flag correctly", () => { - const result = cli.parse(["--port", "3000"]) - expect(result.flags.get("port")).toBe(3000) - }) - - it("should parse boolean flag correctly", () => { - const result = cli.parse(["--verbose"]) - expect(result.flags.get("verbose")).toBe(true) - }) - }) - - describe("Error Handling", () => { - const cli = Cli.createCli({ - name: "test-cli", - description: "Test CLI", - flags: [Cli.num({ name: "port", flag: "--port", description: "Port number" })], - }) - - it("should throw an error on unknown argument", () => { - expect(() => cli.parse(["--unknown"])).toThrow(CliError) - }) - - it("should throw an error on invalid number value", () => { - expect(() => cli.parse(["--port", "invalid"])).toThrow(CliError) - }) - }) - - describe("Help Generation", () => { - const cli = Cli.createCli({ - name: "test-cli", - description: "Test CLI", - commands: [ - Cli.cmd({ - name: "serve", - description: "Start server", - }), - ], - }) - - it("should generate help when --help is used", () => { - const result = cli.parse(["--help"]) - const help = result.generateHelp() - expect(help).toBeTruthy() - expect(help?.name).toBe("test-cli") - }) - - it("should generate command-specific help", () => { - const result = cli.parse(["serve", "--help"]) - const help = result.generateHelp() - expect(help).toBeTruthy() - expect(help?.name).toBe("serve") - }) - }) - - describe("Default Values", () => { - const cli = Cli.createCli({ - name: "test-cli", - description: "Test CLI", - flags: [ - Cli.str({ - name: "env", - flag: "--env", - description: "Environment", - default: "development", - }), - ], - }) - - it("should use default value when flag is not provided", () => { - const result = cli.parse([]) - expect(result.flags.get("env")).toBe("development") - }) - }) - - describe("Required Flags", () => { - const cli = Cli.createCli({ - name: "test-cli", - description: "Test CLI", - flags: [ - Cli.str({ - name: "config", - flag: "--config", - description: "Configuration file", - required: true, - }), - ], - }) - - it("should parse required flag when provided", () => { - const result = cli.parse(["--config", "app.config"]) - expect(result.flags.get("config")).toBe("app.config") - }) - - it("should throw an error when required flag is missing", () => { - expect(() => cli.parse([])).toThrow(CliError) - }) - }) - - describe("Required Flags with Default Values", () => { - const cli = Cli.createCli({ - name: "test-cli", - description: "Test CLI", - flags: [ - Cli.str({ - name: "config", - flag: "--config", - description: "Configuration file", - required: true, - default: "default.config", - }), - ], - }) - - it("should use default value when required flag is not provided", () => { - const result = cli.parse([]) - expect(result.flags.get("config")).toBe("default.config") - }) - - it("should use provided value over default for required flag", () => { - const result = cli.parse(["--config", "user.config"]) - expect(result.flags.get("config")).toBe("user.config") - }) - }) -}) diff --git a/test/flags.test.ts b/test/flags.test.ts deleted file mode 100644 index b5b96ad..0000000 --- a/test/flags.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { describe, expect, it } from "vitest" -import { Flags } from "../src/flags" -import type { FlagConfig } from "../src/types" - -describe("Flags.TypedFlag", () => { - const basicConfig: FlagConfig = { - name: "test", - flag: "--test", - description: "test flag", - } - - it("should initialize correctly with basic configuration", () => { - const flag = new Flags.TypedFlag(basicConfig, v => String(v)) - - expect(flag.name).toBe("test") - expect(flag.flag).toBe("--test") - expect(flag.description).toBe("test flag") - expect(flag.type).toBe("string") - }) - - it("should validate input when validator is provided", () => { - const flag = new Flags.TypedFlag( - basicConfig, - v => String(v), - v => typeof v === "string", - ) - - expect(flag.isValid("test")).toBe(true) - expect(flag.isValid(123)).toBe(false) - }) - - it("should always return true when no validator is provided", () => { - const flag = new Flags.TypedFlag(basicConfig, v => String(v)) - - expect(flag.isValid("test")).toBe(true) - expect(flag.isValid(123)).toBe(true) - }) - - it("should convert values using provided converter", () => { - const flag = new Flags.TypedFlag(basicConfig, v => Number(v)) - - expect(flag.convert("123")).toBe(123) - }) - - it("should handle default values correctly", () => { - const flagWithDefault = new Flags.TypedFlag({ ...basicConfig, default: "default" }, v => String(v)) - - expect(flagWithDefault.default).toBe("default") - }) -}) diff --git a/test/parser.test.ts b/test/parser.test.ts deleted file mode 100644 index 50208be..0000000 --- a/test/parser.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { beforeEach, describe, expect, it } from "vitest" -import { Cli } from "../src/cli" -import { Parser } from "../src/parser" -import { Command, Flag } from "../src/types" - -describe("Parser.Node", () => { - it("should create an empty node", () => { - const node = new Parser.Node() - expect(node.children.size).toBe(0) - expect(node.value).toBeNull() - }) -}) - -describe("Parser.Tree", () => { - let tree: Parser.Tree - - beforeEach(() => { - tree = new Parser.Tree() - }) - - it("should insert and search simple paths", () => { - tree.insert("test", "test-value") - expect(tree.search("test")).toBe("test-value") - }) - - it("should handle compound paths", () => { - tree.insert("cmd subcmd", "nested-value") - expect(tree.search("cmd subcmd")).toBe("nested-value") - expect(tree.search("cmd")).toBeNull() - }) - - it("should handle cache correctly", () => { - tree.insert("cached", "cache-value") - - // First search should cache the result - expect(tree.search("cached")).toBe("cache-value") - expect(tree.cache.has("cached")).toBe(true) - - // Clear cache should remove the entry - tree.clearCache() - expect(tree.cache.has("cached")).toBe(false) - }) -}) - -describe("Parser.Scope", () => { - it("should handle command and flags registration", () => { - const cmd = Cli.cmd({ - name: "test", - description: "test command", - flags: [ - Cli.str({ - name: "input", - flag: "--input", - alias: "-i", - description: "input file", - }), - ], - commands: [ - Cli.cmd({ - name: "subtest", - description: "sub command", - }), - ], - }) - - const scope = new Parser.Scope(cmd) - - expect(scope.search("subtest")).toBeInstanceOf(Command) - expect(scope.search("--input")).toBeInstanceOf(Flag) - expect(scope.search("-i")).toBeInstanceOf(Flag) - }) - - it("should handle null command gracefully", () => { - const scope = new Parser.Scope(null) - expect(scope.search("anything")).toBeNull() - }) - - it("should handle aliases correctly", () => { - const cmd = Cli.cmd({ - name: "test", - alias: "t", - description: "test command", - }) - - const scope = new Parser.Scope(cmd) - const result = scope.search("t") - expect(result).toBeDefined() - }) -}) - -describe("Parser Integration Tests", () => { - it("should work with CLI-like command structure", () => { - const tree = new Parser.Tree() - - const rootCmd = Cli.cmd({ - name: "cli", - description: "root command", - commands: [ - Cli.cmd({ - name: "init", - description: "initialize", - flags: [ - Cli.bool({ - name: "force", - flag: "--force", - description: "force initialization", - }), - ], - }), - ], - }) - - tree.insert(rootCmd.name, rootCmd) - rootCmd.commands?.forEach((cmd) => { - tree.insert(cmd.name, cmd) - cmd.flags?.forEach((opt) => { - tree.insert(opt.flag, opt) - }) - }) - - expect(tree.search("cli")).toBeInstanceOf(Command) - expect(tree.search("init")).toBeInstanceOf(Command) - expect(tree.search("--force")).toBeInstanceOf(Flag) - }) -}) diff --git a/test/types.test.ts b/test/types.test.ts deleted file mode 100644 index 3d36fae..0000000 --- a/test/types.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { describe, expect, it } from "vitest" -import { Command, type CommandConfig, Flag, type FlagConfig } from "../src/types" - -// Concrete test class for Flag -class TestFlag extends Flag { - isValid(_: unknown): boolean { - return true - } - convert(value: unknown): any { - return value - } -} - -describe("Flag", () => { - it("should create a flag with default values", () => { - const config: FlagConfig = { - name: "test", - flag: "--test", - description: "test flag", - } - const flag = new TestFlag(config) - - expect(flag.type).toBe("string") - expect(flag.name).toBe("test") - expect(flag.flag).toBe("--test") - expect(flag.description).toBe("test flag") - expect(flag.alias).toBeUndefined() - expect(flag.default).toBeUndefined() - expect(flag.multiple).toBeUndefined() - }) - - it("should create a flag with all properties set", () => { - const config: FlagConfig = { - name: "test", - flag: "--test", - description: "test flag", - type: "boolean", - alias: "-t", - default: true, - multiple: true, - } - const flag = new TestFlag(config) - - expect(flag.type).toBe("boolean") - expect(flag.name).toBe("test") - expect(flag.flag).toBe("--test") - expect(flag.description).toBe("test flag") - expect(flag.alias).toBe("-t") - expect(flag.default).toBe(true) - expect(flag.multiple).toBe(true) - }) -}) - -describe("Command", () => { - it("should create a command with minimal properties", () => { - const config: CommandConfig = { - name: "test", - description: "test command", - } - const command = new Command(config) - - expect(command.name).toBe("test") - expect(command.description).toBe("test command") - expect(command.flag).toBeUndefined() - expect(command.alias).toBeUndefined() - expect(command.commands).toBeUndefined() - expect(command.flags).toBeUndefined() - }) - - it("should create a command with all properties set", () => { - const subCommand = new Command({ - name: "sub", - description: "sub command", - }) - const flag = new TestFlag({ - name: "flag", - flag: "--flag", - description: "test flag", - }) - - const config: CommandConfig = { - name: "test", - flag: "--test", - description: "test command", - alias: "-t", - commands: [subCommand], - flags: [flag], - } - const command = new Command(config) - - expect(command.name).toBe("test") - expect(command.flag).toBe("--test") - expect(command.description).toBe("test command") - expect(command.alias).toBe("-t") - expect(command.commands).toHaveLength(1) - expect(command.commands![0]).toBe(subCommand) - expect(command.flags).toHaveLength(1) - expect(command.flags![0]).toBe(flag) - }) -}) diff --git a/test/usageGenerator.test.ts b/test/usageGenerator.test.ts deleted file mode 100644 index 3964d36..0000000 --- a/test/usageGenerator.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { describe, expect, it } from "vitest" -import { Flags } from "../src/flags" -import { Command } from "../src/types" -import { UsageGenerator } from "../src/usageGenerator" - -describe("UsageGenerator", () => { - // Test fixtures - const helpFlag = new Flags.TypedFlag( - { - type: "boolean", - name: "help", - flag: "--help", - description: "Show help", - alias: "-h", - }, - () => true, - ) - - const verboseFlag = new Flags.TypedFlag( - { - type: "boolean", - name: "verbose", - flag: "--verbose", - description: "Verbose output", - alias: "-v", - }, - () => true, - ) - - const rootCommand = new Command({ - name: "cli", - description: "Root command", - commands: [ - new Command({ - name: "subcommand", - description: "A subcommand", - flags: [verboseFlag], - }), - ], - }) - - const globalOptions = [helpFlag] - const generator = new UsageGenerator(rootCommand, globalOptions) - - describe("createHelpOption", () => { - it("should create a help flag with correct properties", () => { - const helpOption = UsageGenerator.createHelpFlag() - expect(helpOption.name).toBe("help") - expect(helpOption.flag).toBe("--help") - expect(helpOption.alias).toBe("-h") - expect(helpOption.type).toBe("boolean") - }) - }) - - describe("generate", () => { - it("should generate root help when no current command is provided", () => { - const usage = generator.generate(null, []) - expect(usage.name).toBe("cli") - expect(usage.description).toBe("Root command") - expect(usage.flags).toHaveLength(1) - expect(usage.flags![0].name).toBe("--help, -h") - }) - - it("should generate command help for a specific command", () => { - const subcommand = rootCommand.commands![0] - const usage = generator.generate(subcommand, ["cli", "subcommand"]) - - expect(usage.name).toBe("cli subcommand") - expect(usage.description).toBe("A subcommand") - expect(usage.flags).toHaveLength(2) // verbose + help - expect(usage.flags!.some((f) => f.flag === "--verbose")).toBe(true) - expect(usage.flags!.some((f) => f.flag === "--help")).toBe(true) - }) - }) - - describe("generateRootHelp", () => { - it("should generate help for root command with global flags", () => { - const usage = generator.generate(null, []) - - expect(usage.name).toBe("cli") - expect(usage.description).toBe("Root command") - expect(usage.flags).toHaveLength(1) - expect(usage.flags![0].name).toBe("--help, -h") - }) - }) - - describe("generateCommandHelp", () => { - it("should generate help for subcommand with combined flags", () => { - const subcommand = rootCommand.commands![0] - const usage = generator.generate(subcommand, ["cli", "subcommand"]) - - expect(usage.name).toBe("cli subcommand") - expect(usage.description).toBe("A subcommand") - expect(usage.flags).toHaveLength(2) - expect(usage.flags!.map((f) => f.flag)).toContain("--verbose") - expect(usage.flags!.map((f) => f.flag)).toContain("--help") - }) - }) -}) diff --git a/test/utils.test.ts b/test/utils.test.ts deleted file mode 100644 index dafaf87..0000000 --- a/test/utils.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { describe, expect, it } from "vitest" -import { Utils } from "../src/utils" - -describe("Utils.isValidBoolean", () => { - it("should return true for valid boolean values", () => { - expect(Utils.isValidBoolean(true)).toBe(true) - expect(Utils.isValidBoolean(false)).toBe(true) - expect(Utils.isValidBoolean(1)).toBe(true) - expect(Utils.isValidBoolean(0)).toBe(true) - }) - - it("should return true for valid boolean strings", () => { - expect(Utils.isValidBoolean("true")).toBe(true) - expect(Utils.isValidBoolean("false")).toBe(true) - expect(Utils.isValidBoolean("1")).toBe(true) - expect(Utils.isValidBoolean("0")).toBe(true) - }) - - it("should return false for invalid boolean inputs", () => { - expect(Utils.isValidBoolean("yes")).toBe(false) - expect(Utils.isValidBoolean("no")).toBe(false) - expect(Utils.isValidBoolean(null)).toBe(false) - expect(Utils.isValidBoolean(undefined)).toBe(false) - }) -}) - -describe("Utils.isValidNumber", () => { - it("should return true for valid numbers", () => { - expect(Utils.isValidNumber(10)).toBe(true) - expect(Utils.isValidNumber("20")).toBe(true) - }) - - it("should return false for invalid numbers", () => { - expect(Utils.isValidNumber("abc")).toBe(false) - expect(Utils.isValidNumber(NaN)).toBe(false) - expect(Utils.isValidNumber(undefined)).toBe(false) - }) - - it("should respect specified minimum and maximum bounds", () => { - expect(Utils.isValidNumber(5, 1, 10)).toBe(true) - expect(Utils.isValidNumber(0, 1, 10)).toBe(false) - expect(Utils.isValidNumber(11, 1, 10)).toBe(false) - }) -}) - -describe("Utils.toBooleanValue", () => { - it("should convert valid true representations to true", () => { - expect(Utils.toBooleanValue(true)).toBe(true) - expect(Utils.toBooleanValue("true")).toBe(true) - expect(Utils.toBooleanValue("1")).toBe(true) - }) - - it("should convert other values to false", () => { - expect(Utils.toBooleanValue(false)).toBe(false) - expect(Utils.toBooleanValue("false")).toBe(false) - expect(Utils.toBooleanValue("0")).toBe(false) - expect(Utils.toBooleanValue("no")).toBe(false) - expect(Utils.toBooleanValue(null)).toBe(false) - expect(Utils.toBooleanValue(undefined)).toBe(false) - }) -}) - -describe("Utils.formatFlag", () => { - it("should format primary and secondary flags correctly", () => { - expect(Utils.formatFlag("--help", "-h")).toBe("--help, -h") - }) - - it("should return primary flag when secondary is not provided", () => { - expect(Utils.formatFlag("--version")).toBe("--version") - }) -}) diff --git a/vitest.config.ts b/vitest.config.ts index 1b81f5d..173d2f9 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,5 +7,14 @@ export default defineConfig({ name: "cli", environment: "node", include: ["test/**.test.ts"], + coverage: { + exclude: ["build/**", "dist/**", "node_modules/**", "test/**", "*.config.ts", "*.config.js"], + thresholds: { + functions: 90, + lines: 80, + statements: 80, + branches: 80, + }, + }, }, })