diff --git a/.changeset/.markdownlint.json b/.changeset/.markdownlint.json new file mode 100644 index 0000000..c5d0396 --- /dev/null +++ b/.changeset/.markdownlint.json @@ -0,0 +1,3 @@ +{ + "MD041": false +} \ No newline at end of file diff --git a/.changeset/healthy-carpets-learn.md b/.changeset/healthy-carpets-learn.md new file mode 100644 index 0000000..05fb2d4 --- /dev/null +++ b/.changeset/healthy-carpets-learn.md @@ -0,0 +1,5 @@ +--- +'clap-js': patch +--- + +Implement utils functions to create clap command instance diff --git a/.changeset/nasty-bugs-jog.md b/.changeset/nasty-bugs-jog.md new file mode 100644 index 0000000..aa86a6f --- /dev/null +++ b/.changeset/nasty-bugs-jog.md @@ -0,0 +1,5 @@ +--- +'clap-js': patch +--- + +Call callback functions with context object after merged parsed args diff --git a/.changeset/orange-bugs-rest.md b/.changeset/orange-bugs-rest.md new file mode 100644 index 0000000..52f5e12 --- /dev/null +++ b/.changeset/orange-bugs-rest.md @@ -0,0 +1,5 @@ +--- +'clap-js': patch +--- + +Merge parsed args by `clap-rs` to context args object diff --git a/.changeset/poor-cats-grow.md b/.changeset/poor-cats-grow.md new file mode 100644 index 0000000..ca80873 --- /dev/null +++ b/.changeset/poor-cats-grow.md @@ -0,0 +1,5 @@ +--- +'clap-js': patch +--- + +Remove features for clap-rs diff --git a/.changeset/two-keys-thank.md b/.changeset/two-keys-thank.md new file mode 100644 index 0000000..1a50aaa --- /dev/null +++ b/.changeset/two-keys-thank.md @@ -0,0 +1,5 @@ +--- +'clap-js': patch +--- + +Implement command definition types diff --git a/Cargo.toml b/Cargo.toml index 533720b..1037c34 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] authors = ["苏向夜 "] edition = "2021" -name = "clap-rs" +name = "clap-js" version = "0.1.0" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -10,8 +10,8 @@ version = "0.1.0" crate-type = ["cdylib"] [dependencies] -clap = "4.5.9" -napi = { version = "2", features = ["full"] } +clap = "4" +napi = "2" napi-derive = "2" [build-dependencies] diff --git a/__test__/index.spec.ts b/__test__/index.spec.ts index 92eadf2..ce0a1d5 100644 --- a/__test__/index.spec.ts +++ b/__test__/index.spec.ts @@ -1,11 +1,19 @@ import test from 'ava' -import { defineCommand } from '../index' +import { Command, defineCommand, run } from '../index' test('define command', (t) => { - const cmd = { - meta: {}, - options: {}, + const cmd: Command = { + meta: { + name: 'test', + version: '1.0.0', + about: 'test command', + }, + options: { + foo: { + type: 'positional', + }, + }, callback: (ctx: any) => { console.log(ctx) }, diff --git a/cspell.json b/cspell.json index d9ce26a..70adc26 100644 --- a/cspell.json +++ b/cspell.json @@ -3,11 +3,11 @@ "words": [ "aarch", "androideabi", + "bindgen", "cdylib", "gnueabihf", "msvc", "napi", - "ntscl", "oxlint", "prebuild", "taplo", diff --git a/index.d.ts b/index.d.ts index b3d19c6..bfbffc9 100644 --- a/index.d.ts +++ b/index.d.ts @@ -3,21 +3,60 @@ /* auto-generated by NAPI-RS */ +export declare function defineCommand(options: Command): Command +export declare function run(cmd: Command, args?: Array | undefined | null): void +export const VERSION: string export interface Context { - + args: object + rawArgs: Array } +/** Command metadata */ export interface CommandMeta { + /** + * Command name + * + * This is the name of the command that will be used to call it from the CLI. + * If the command is the main command, the name will be the name of the binary. + * If the command is a subcommand, the name will be the name of the subcommand. + */ name?: string + /** + * CLI version + * + * This is optional and can be used to display the version of the CLI + * when the command is called with the `--version` flag or `-V` option. + * + * This option will be ignored if the command is subcommand. + */ version?: string - description?: string - hidden?: boolean + /** + * Command description + * + * Command description will be displayed in the help output. + */ + about?: string } export interface CommandOption { - + type?: 'positional' | 'flag' | 'option' + parser?: + | 'string' + | 'str' + | 'string[]' + | 'str[]' + | 'number' + | 'boolean' + | 'bool' + short?: string + long?: string + alias?: Array + hiddenAlias?: Array + required?: boolean + default?: string + hidden?: boolean } export interface Command { meta: CommandMeta options: Record - callback: (ctx: Context) => void + callback?: (ctx: Context) => void + subcommands?: Record } -export declare function defineCommand(options: Command): Command diff --git a/index.js b/index.js index 241723e..d6921c9 100644 --- a/index.js +++ b/index.js @@ -310,7 +310,8 @@ if (!nativeBinding) { throw new Error(`Failed to load native binding`) } -const { defineCommand, run } = nativeBinding +const { defineCommand, run, VERSION } = nativeBinding module.exports.defineCommand = defineCommand module.exports.run = run +module.exports.VERSION = VERSION diff --git a/package.json b/package.json index b39c89a..93ea1fd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "clap-js", - "version": "1.0.0", + "version": "0.1.0", "description": "Fast and elegant CLI build tool based on clap-rs", "main": "index.js", "repository": "https://github.com/noctisynth/clap-js", diff --git a/simple.js b/simple.js new file mode 100644 index 0000000..fe52caa --- /dev/null +++ b/simple.js @@ -0,0 +1,38 @@ +const { defineCommand, run } = require('./index.js') + +const dev = defineCommand({ + meta: { + name: 'dev', + about: 'Run development server', + }, + options: { + port: { + type: 'option', + parser: 'number', + default: '3000', + }, + }, + callback: (ctx) => { + console.log(ctx); + } +}) + +const main = defineCommand({ + meta: { + name: 'simple', + version: '0.0.1', + about: 'A simple command line tool', + alias: ['dev'] + }, + options: { + verbose: { + type: 'flag', + parser: 'boolean', + }, + }, + subcommands: { + dev, + }, +}) + +run(main) diff --git a/src/command.rs b/src/command.rs new file mode 100644 index 0000000..f8f04dd --- /dev/null +++ b/src/command.rs @@ -0,0 +1,44 @@ +use napi::{bindgen_prelude::*, JsNull}; +use napi_derive::napi; + +use crate::types::{Command, Context}; +use crate::utils::{merge_args_matches, resolve_command, resolve_option_args}; + +#[napi] +pub fn define_command(options: Command) -> Command { + options +} + +#[napi] +pub fn run(env: Env, cmd: Command, args: Option>) -> Result<()> { + let args = resolve_option_args(args); + let clap = resolve_command(clap::Command::default(), Default::default(), &cmd); + let matches = clap.clone().get_matches_from(&args); + + let mut parsed_args = env.create_object()?; + + merge_args_matches(&mut parsed_args, &cmd, &matches)?; + + if let Some((sub_command, sub_matches)) = matches.subcommand() { + let sub_commands = &cmd.subcommands.unwrap_or_default(); + let sub_command = sub_commands.get(sub_command).unwrap(); + let cb = sub_command.callback.as_ref().unwrap(); + merge_args_matches(&mut parsed_args, &sub_command, &sub_matches)?; + let context = Context { + args: parsed_args, + raw_args: args, + }; + cb.call1::(context)?; + } else { + let context = Context { + args: parsed_args, + raw_args: args, + }; + if let Some(cb) = cmd.callback.as_ref() { + cb.call1::(context)?; + } else { + env.throw_error("No callback function found for command", None)?; + }; + } + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index eecdd01..5ba755d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,33 +1,5 @@ #![deny(clippy::all)] -use std::collections::HashMap; - -use napi::JsFunction; -use napi_derive::napi; - -#[napi(object)] -pub struct Context {} - -#[napi(object)] -pub struct CommandMeta { - pub name: Option, - pub version: Option, - pub description: Option, - pub hidden: Option, -} - -#[napi(object)] -pub struct CommandOption {} - -#[napi(object)] -pub struct Command { - pub meta: CommandMeta, - pub options: HashMap, - #[napi(ts_type = "(ctx: Context) => void")] - pub callback: JsFunction, -} - -#[napi] -pub fn define_command(options: Command) -> Command { - options -} +pub mod command; +pub mod types; +pub mod utils; diff --git a/src/types.rs b/src/types.rs new file mode 100644 index 0000000..9fa41b9 --- /dev/null +++ b/src/types.rs @@ -0,0 +1,68 @@ +use std::collections::HashMap; + +use napi::{JsFunction, JsObject}; +use napi_derive::napi; + +#[napi] +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[napi(object)] +pub struct Context { + pub args: JsObject, + pub raw_args: Vec, +} + +/// Command metadata +#[napi(object)] +#[derive(Clone)] +pub struct CommandMeta { + /// Command name + /// + /// This is the name of the command that will be used to call it from the CLI. + /// If the command is the main command, the name will be the name of the binary. + /// If the command is a subcommand, the name will be the name of the subcommand. + pub name: Option, + /// CLI version + /// + /// This is optional and can be used to display the version of the CLI + /// when the command is called with the `--version` flag or `-V` option. + /// + /// This option will be ignored if the command is subcommand. + pub version: Option, + /// Command description + /// + /// Command description will be displayed in the help output. + pub about: Option, +} + +#[napi(object)] +#[derive(Clone)] +pub struct CommandOption { + #[napi(js_name = "type", ts_type = "'positional' | 'flag' | 'option'")] + pub _type: Option, + #[napi(ts_type = r#" + | 'string' + | 'str' + | 'string[]' + | 'str[]' + | 'number' + | 'boolean' + | 'bool'"#)] + pub parser: Option, + pub short: Option, + pub long: Option, + pub alias: Option>, + pub hidden_alias: Option>, + pub required: Option, + pub default: Option, + pub hidden: Option, +} + +#[napi(object)] +pub struct Command { + pub meta: CommandMeta, + pub options: HashMap, + #[napi(ts_type = "(ctx: Context) => void")] + pub callback: Option, + pub subcommands: Option>, +} diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..4ec6d85 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,158 @@ +use std::collections::HashMap; + +use napi::{JsObject, Result}; + +use crate::types::{Command, CommandMeta, CommandOption}; + +pub(crate) fn leak_str(s: String) -> &'static str { + s.leak() +} + +pub(crate) fn leak_borrowed_str_or_default(s: Option<&String>, default: &str) -> &'static str { + s.map_or_else(|| leak_str(default.to_string()), |s| leak_str(s.clone())) +} + +pub(crate) fn leak_borrowed_str(s: &String) -> &'static str { + s.clone().leak() +} + +pub(crate) fn resolve_option_args(args: Option>) -> Vec { + let mut args = args.unwrap_or(std::env::args().collect()); + args.remove(0); // remove `node.exe` + args +} + +pub(crate) fn resolve_command_meta( + mut clap: clap::Command, + bin_name: Option, + meta: &CommandMeta, +) -> clap::Command { + let name: &'static str = if let Some(name) = &meta.name { + leak_borrowed_str(name) + } else { + leak_str(bin_name.unwrap()) + }; + clap = clap.name(name); + if let Some(version) = &meta.version { + clap = clap.version(leak_borrowed_str(version)); + } + if let Some(about) = &meta.about { + clap = clap.about(leak_borrowed_str(about)); + } + clap +} + +pub(crate) fn resolve_action(_type: &str, parser: Option<&str>) -> Option { + match _type { + "positional" | "option" => { + if parser.is_some() && parser.unwrap().ends_with("[]") { + Some(clap::ArgAction::Append) + } else { + None + } + } + "flag" => match parser { + Some("bool" | "boolean") | None => Some(clap::ArgAction::SetTrue), + Some("number") => Some(clap::ArgAction::Count), + _ => panic!("Invalid flag parser: `{:?}`", parser), + }, + _ => panic!("Unsupported option type: `{}`", _type), + } +} + +pub(crate) fn resolve_command_options( + clap: clap::Command, + meta: &HashMap, +) -> clap::Command { + clap.args( + meta + .iter() + .map(|(name, opt)| { + let mut arg = clap::Arg::new(leak_borrowed_str(name)); + arg = arg.action(resolve_action( + opt._type.as_deref().unwrap_or("option"), + opt.parser.as_deref(), + )); + if opt._type.as_deref() != Some("positional") { + let long = leak_borrowed_str_or_default(opt.long.as_ref(), &name); + arg = arg.long(long).short( + leak_borrowed_str_or_default(opt.short.as_ref(), &long) + .chars() + .next(), + ); + } + if let Some(alias) = &opt.alias { + let alias = alias + .into_iter() + .map(|a| leak_borrowed_str(a)) + .collect::>(); + arg = arg.visible_aliases(&alias); + } + if let Some(hidden_alias) = &opt.hidden_alias { + let hidden_alias = hidden_alias + .into_iter() + .map(|a| leak_borrowed_str(a)) + .collect::>(); + arg = arg.aliases(&hidden_alias); + } + if let Some(required) = opt.required { + arg = arg.required(required); + } + if let Some(default) = &opt.default { + arg = arg.default_value(leak_borrowed_str(default)); + } + if let Some(hidden) = opt.hidden { + arg = arg.hide(hidden); + } + arg + }) + .collect::>(), + ) +} + +pub(crate) fn resolve_command( + mut clap: clap::Command, + name: String, + cmd: &Command, +) -> clap::Command { + clap = resolve_command_meta(clap, Some(name), &cmd.meta); + clap = resolve_command_options(clap, &cmd.options); + if let Some(subcommands) = &cmd.subcommands { + clap = clap.subcommands( + subcommands + .iter() + .map(|(name, sub_cmd)| resolve_command(clap::Command::default(), name.clone(), sub_cmd)) + .collect::>(), + ); + } + clap +} + +pub(crate) fn merge_args_matches( + parsed_args: &mut JsObject, + cmd: &Command, + matches: &clap::ArgMatches, +) -> Result<()> { + for id in matches.ids() { + let cmd = &cmd; + let opts = cmd + .options + .iter() + .find(|&(name, _)| name == id) + .map(|(_, t)| t) + .unwrap(); + match opts._type.as_deref().unwrap_or("option") { + "option" => { + parsed_args.set(id, matches.get_one::(id.as_str()))?; + } + "flag" => { + parsed_args.set(id, matches.get_flag(id.as_str()))?; + } + "positional" => { + parsed_args.set(id, matches.get_one::(id.as_str()))?; + } + _ => panic!("Unsupported option type"), + } + } + Ok(()) +}