From 941558cb92e16dd778cbb495c87b11236aa1118c Mon Sep 17 00:00:00 2001 From: CNWei Date: Fri, 4 Sep 2026 20:20:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(cli):=20=E4=BC=98=E5=8C=96=20log=20?= =?UTF-8?q?=E5=AD=90=E5=91=BD=E4=BB=A4=E5=B9=B6=E5=BC=95=E5=85=A5=20LogLev?= =?UTF-8?q?el=20=E5=BC=BA=E7=B1=BB=E5=9E=8B=E8=A7=A3=E6=9E=90,=E9=87=8D?= =?UTF-8?q?=E6=9E=84=20commands.lua=20=E6=B3=A8=E5=86=8C=E8=A1=A8=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E4=B8=8E=E6=A0=A1=E9=AA=8C=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 调整 LogLevel 的 FromStr 错误类型为 String,适配 clap 的 value_parser - log 子命令直接绑定 LogLevel 枚举,消除硬编码校验与类型转换 - 规范化日志级别更新与配置读取逻辑 - handler 类型有 Function 改为 String --- .gitignore | 2 + Cargo.lock | 181 ++- Cargo.toml | 23 +- commands/commands.lua | 37 + commands/log/init.lua | 24 + commands/log/utils.lua | 8 + mirror-cli/Cargo.toml | 18 + mirror-cli/src/app.rs | 35 + mirror-cli/src/cli.rs | 62 + mirror-cli/src/commands/builtin.rs | 35 + mirror-cli/src/commands/dynamic.rs | 139 +++ mirror-cli/src/commands/mod.rs | 2 + mirror-cli/src/main.rs | 14 + mirror-cli/src/validators.rs | 41 + mirror-core/Cargo.toml | 16 + {src => mirror-core/src}/error.rs | 0 {src => mirror-core/src}/layout.rs | 0 {src => mirror-core/src}/lib.rs | 5 +- {src => mirror-core/src}/logger.rs | 18 +- {src => mirror-core/src}/mirror.rs | 2 +- {src => mirror-core/src}/runtime.rs | 63 +- {src => mirror-core/src}/utils.rs | 0 {src => mirror-core/src}/validators.rs | 0 mirror-shim/Cargo.toml | 24 + {src => mirror-shim/src}/main.rs | 8 +- {src => mirror-shim/src}/sys.rs | 0 {src => mirror-shim/src}/sys/win.rs | 0 mirror.lua | 1 + vendor/argparse.lua | 1527 ++++++++++++++++++++++++ 29 files changed, 2238 insertions(+), 47 deletions(-) create mode 100644 commands/commands.lua create mode 100644 commands/log/init.lua create mode 100644 commands/log/utils.lua create mode 100644 mirror-cli/Cargo.toml create mode 100644 mirror-cli/src/app.rs create mode 100644 mirror-cli/src/cli.rs create mode 100644 mirror-cli/src/commands/builtin.rs create mode 100644 mirror-cli/src/commands/dynamic.rs create mode 100644 mirror-cli/src/commands/mod.rs create mode 100644 mirror-cli/src/main.rs create mode 100644 mirror-cli/src/validators.rs create mode 100644 mirror-core/Cargo.toml rename {src => mirror-core/src}/error.rs (100%) rename {src => mirror-core/src}/layout.rs (100%) rename {src => mirror-core/src}/lib.rs (75%) rename {src => mirror-core/src}/logger.rs (91%) rename {src => mirror-core/src}/mirror.rs (98%) rename {src => mirror-core/src}/runtime.rs (83%) rename {src => mirror-core/src}/utils.rs (100%) rename {src => mirror-core/src}/validators.rs (100%) create mode 100644 mirror-shim/Cargo.toml rename {src => mirror-shim/src}/main.rs (95%) rename {src => mirror-shim/src}/sys.rs (100%) rename {src => mirror-shim/src}/sys/win.rs (100%) create mode 100644 vendor/argparse.lua diff --git a/.gitignore b/.gitignore index bba9b17..014c388 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ /.idea *.exe ./Cargo.lock +**/target +**/Cargo.lock diff --git a/Cargo.lock b/Cargo.lock index db0f27c..f43cad4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,56 @@ dependencies = [ "memchr", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + [[package]] name = "anyhow" version = "1.0.104" @@ -41,9 +91,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.3" +version = "1.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" dependencies = [ "find-msvc-tools", "shlex", @@ -55,6 +105,52 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "crossbeam-channel" version = "0.5.16" @@ -84,9 +180,9 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] name = "either" -version = "1.17.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "find-msvc-tools" @@ -94,6 +190,18 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "lazy_static" version = "1.5.0" @@ -123,18 +231,18 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "lua-src" -version = "550.1.1" +version = "551.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75c110c2fa33f34e0de05448e1f3eb2e0631e7a69e2d8ae1586cffc9fc9f9949" +checksum = "087097f9936a7d819bda525b32d6e96f9c54f3d35ff23ff72fc0c1697d2127db" dependencies = [ "cc", ] [[package]] name = "luajit-src" -version = "210.7.2+b925b3e" +version = "210.7.3+1ee778a" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "920cf654b23d217c550ceea57c32cd2a413ea27b6d47ed77b5ee0cf655adefa6" +checksum = "869665372263eb337b14f480cfb864b89f12eade4eb42cb415f71517b4a67572" dependencies = [ "cc", "which", @@ -156,7 +264,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] -name = "mirror" +name = "mirror-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "clap_derive", + "mirror-core", + "mlua", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "mirror-core" version = "0.1.0" dependencies = [ "anyhow", @@ -170,11 +291,23 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "mirror-shim" +version = "0.1.0" +dependencies = [ + "anyhow", + "mirror-core", + "tracing", + "tracing-appender", + "tracing-subscriber", + "windows-sys", +] + [[package]] name = "mlua" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad72ffa037cf5970c9860674f32f703fda25d86cf217475fe7a79c5f9961bcaa" +checksum = "d96e5d00f19d8c46c71ceaced99593b90c31c57aa1fe2cb3e93a8b1698eedba9" dependencies = [ "bstr", "either", @@ -187,9 +320,9 @@ dependencies = [ [[package]] name = "mlua-sys" -version = "0.11.0" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92136787b906d4e55cfe96cd6c62e010bb1a56889d0d6cf83eb016dbad07576b" +checksum = "b806d7ade031f5d6607eae3e283fb034cb795a76247dd0d1ba753c8c42debccf" dependencies = [ "cc", "cfg-if", @@ -229,6 +362,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "parking_lot" version = "0.12.5" @@ -377,6 +516,12 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "symlink" version = "0.1.0" @@ -550,6 +695,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "valuable" version = "0.1.1" @@ -558,9 +709,9 @@ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" [[package]] name = "which" -version = "8.0.5" +version = "8.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f3ef584124b911bcc3875c2f1472e80f24361ceb789bd1c62b3e9a3df9ff43c" +checksum = "bae2f2b2b816647a1cab1acc91f5bd20812d53cb344382635ec2181940c8034f" dependencies = [ "libc", ] diff --git a/Cargo.toml b/Cargo.toml index 5f321df..5c8eeff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,16 +1,21 @@ -[package] -name = "mirror" +[workspace] +resolver = "2" +members = [ + "mirror-core", "mirror-cli", + "mirror-shim", +] + +#[package] +#name = "mirror" + +[workspace.package] version = "0.1.0" edition = "2024" rust-version = "1.94" license = "MIT OR Unlicense" description = "A fast, safe Rust shim launcher for Scoop" -[profile.release] -opt-level = "z" -panic = "abort" - -[dependencies] +[workspace.dependencies] anyhow = "1.0.104" mlua = { version = "0.12.0", features = ["lua54", "vendored","send"] } @@ -35,7 +40,3 @@ tinyjson="2.5.1" #toml = "1.1.4+spec-1.1.0" - -[features] -default = [] -args=[] \ No newline at end of file diff --git a/commands/commands.lua b/commands/commands.lua new file mode 100644 index 0000000..a32b699 --- /dev/null +++ b/commands/commands.lua @@ -0,0 +1,37 @@ +-- commands/commands.lua +function command(mod_path) + -- 无论输入 "a/b/c" 还是 "a\b\c",统一替换为标准的 "a.b.c" + local normalized = mod_path:gsub("[/\\]", ".") + + return { + __is_lazy_command = true, + module = normalized + } +end + + +return { + -- 1. 单文件子命令 (commands/use.lua) + ["use"] = { + description = "Switch the active version of a tool", + handler = command("use") + }, + + -- 2. 单文件子命令 (commands/list.lua) + ["list"] = { + description = "List available and currently activated versions", + handler = command("list") + }, + + -- 3. 复杂多文件子命令 (加载 commands/log/init.lua) + ["log"] = { + description = "View or configure mirror runtime logs", + handler = command("log") + }, + + -- 4. 支持别名快捷映射 (例如 mr ls 等价于 mr list) + ["ls"] = { + description = "Alias for list", + handler = command("list") + } +} \ No newline at end of file diff --git a/commands/log/init.lua b/commands/log/init.lua new file mode 100644 index 0000000..45fcd1d --- /dev/null +++ b/commands/log/init.lua @@ -0,0 +1,24 @@ +-- commands/log/init.lua +local argparse = require("argparse") +local utils = require("log.utils") -- 支持相对目录级 require + +return function(raw_args) + local parser = argparse("mr log", "Manage and view mirror logs.") + parser:option("-l --level", "Set log level"):choices({"trace", "debug", "info", "warn", "error"}) + parser:option("-t --tail", "Show last N lines"):convert(tonumber) + + local ok, args = pcall(function() return parser:parse(raw_args) end) + if not ok then + error("__ARGPARSE_HELP__") + end + + if args.level then + print(utils.format_level(args.level)) + mr.fs.write(__MIRROR_DIR__ .. "/mirror-log.ini", "level = \"" .. args.level .. "\"\n") + print("✔ Updated log level.") + elseif args.tail then + print(string.format("Tailing last %d lines...", args.tail)) + else + print("Log module ready. Run 'mr log --help' for details.") + end +end \ No newline at end of file diff --git a/commands/log/utils.lua b/commands/log/utils.lua new file mode 100644 index 0000000..4b383e0 --- /dev/null +++ b/commands/log/utils.lua @@ -0,0 +1,8 @@ +-- commands/log/utils.lua +local M = {} + +function M.format_level(level) + return string.format("==> [LOG LEVEL: %s] <==", string.upper(level)) +end + +return M \ No newline at end of file diff --git a/mirror-cli/Cargo.toml b/mirror-cli/Cargo.toml new file mode 100644 index 0000000..958bd4e --- /dev/null +++ b/mirror-cli/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "mirror-cli" +version = { workspace = true } +edition = { workspace = true } + +# 显式指定生成的二进制文件名 +[[bin]] +name = "mr" +path = "src/main.rs" + +[dependencies] +mirror-core = { path = "../mirror-core" } +clap = { version = "4.6.6", features = ["cargo", "color", "derive","string"] } +clap_derive = { version = "4.6.4" } +mlua = { workspace = true } +anyhow = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } \ No newline at end of file diff --git a/mirror-cli/src/app.rs b/mirror-cli/src/app.rs new file mode 100644 index 0000000..364178c --- /dev/null +++ b/mirror-cli/src/app.rs @@ -0,0 +1,35 @@ +use crate::cli; +use crate::commands::{builtin, dynamic::MrCommand}; +use anyhow::{Context, Result, bail}; +use mirror_core::{Layout, LuaRuntime}; + +pub fn run() -> Result<()> { + let exe = std::env::current_exe().context("获取代理程序路径失败")?; + let layout = Layout::from(&exe)?; + let registry_file = layout.base_dir.join("commands").join("commands.lua"); + + let runtime = LuaRuntime::new(&layout)?; + let commands: MrCommand = runtime + .eval_script(®istry_file) + .with_context(|| format!("加载命令配置失败: {}", registry_file.display()))?; + + let app = cli::build(commands.clone()); + let matches = app.get_matches(); + + match matches.subcommand() { + // 分支 A: 原生 Rust 实现的命令 + Some(("log", sub_m)) => builtin::log_handle(&layout, sub_m), + + // 分支 B: 动态 Lua 注册的命令 + Some((cmd_name, sub_m)) => { + let sub_cmd = commands + .get(cmd_name) + .with_context(|| format!("未找到子命令 '{cmd_name}'"))?; + let raw_args = cli::extract_raw_args(sub_m); + runtime.require_and_run(&sub_cmd.module, raw_args) + } + + // 分支 C: 未输入子命令(通常已被 Clap 的 arg_required_else_help 拦截) + None => Ok(()), + } +} diff --git a/mirror-cli/src/cli.rs b/mirror-cli/src/cli.rs new file mode 100644 index 0000000..60ad989 --- /dev/null +++ b/mirror-cli/src/cli.rs @@ -0,0 +1,62 @@ +use crate::commands::dynamic::MrCommand; +use clap::{Arg, ArgAction, Command}; +use mirror_core::LogLevel; +use std::ffi::OsString; +// 引入强类型枚举 +pub fn build(dynamic_cmds: MrCommand) -> Command { + let mut app = Command::new("mr") + .about("Mirror CLI Manager") + .version("0.1.0") + .arg_required_else_help(true) + .subcommand(log_subcommand()); + + for (name, sub_cmd) in dynamic_cmds.commands { + if name == "log" { + continue; + } + // let a =name.as_str() + // 开启 "string" feature 后,String 可以直接作为 Command 的入参 + app = app.subcommand( + Command::new(name) + .about(sub_cmd.description) + .allow_external_subcommands(true), + ); + } + app +} +/// 构建基础 CLI 骨架(内置 Rust 原生命令与参数规则) +fn log_subcommand() -> Command { + Command::new("log") + .about("查看或修改 mirror.ini 日志配置") + .arg( + Arg::new("tail") + .short('t') + .long("tail") + .value_name("LINES") + .help("显示最近的 N 行日志") + .value_parser(clap::value_parser!(usize)), + ) + .arg( + Arg::new("level") + .short('l') + .long("level") + .value_name("LEVEL") + .help("设置运行时日志级别") + .value_parser(clap::value_parser!(LogLevel)), + ) + .arg( + Arg::new("follow") + .short('f') + .long("follow") + .help("持续跟踪日志输出 (tail -f)") + .action(ArgAction::SetTrue), + ) +} + +pub fn extract_raw_args(matches: &clap::ArgMatches) -> Vec { + matches + .get_many::("") + .unwrap_or_default() + .map(|s| s.to_string_lossy().into_owned()) + .collect() +} diff --git a/mirror-cli/src/commands/builtin.rs b/mirror-cli/src/commands/builtin.rs new file mode 100644 index 0000000..46e25ff --- /dev/null +++ b/mirror-cli/src/commands/builtin.rs @@ -0,0 +1,35 @@ +use anyhow::{Context, Result}; +use mirror_core::Layout; +use std::fs; + +// Rust 原生处理 log 命令逻辑 +pub(crate) fn log_handle(layout: &Layout, matches: &clap::ArgMatches) -> Result<()> { + let log_ini_path = layout.base_dir.join("mirror-log.ini"); + + // 1. 处理设置日志等级 + if let Some(level) = matches.get_one::("level") { + let content = format!("level = \"{}\"\nlog_dir = \"logs\"\n", level.as_str()); + fs::write(&log_ini_path, content) + .with_context(|| format!("写入日志配置文件失败: {}", log_ini_path.display()))?; + println!("日志级别已更新为: {level} ({})", log_ini_path.display()); + return Ok(()); + } + + // 2. 处理 tail 查看 + if let Some(&tail_lines) = matches.get_one::("tail") { + println!("正在检索最后 {tail_lines} 行日志..."); + // 原生 Rust 高性能按行倒序读取日志文件 + return Ok(()); + } + + // 3. 默认打印当前配置 + if log_ini_path.is_file() { + let current_ini = fs::read_to_string(&log_ini_path) + .with_context(|| format!("读取配置文件失败: {}", log_ini_path.display()))?; + println!("当前 mirror-log.ini 配置:\n{current_ini}"); + } else { + println!("未找到 mirror-log.ini,当前使用默认全局级别: info"); + } + + Ok(()) +} diff --git a/mirror-cli/src/commands/dynamic.rs b/mirror-cli/src/commands/dynamic.rs new file mode 100644 index 0000000..5d78c16 --- /dev/null +++ b/mirror-cli/src/commands/dynamic.rs @@ -0,0 +1,139 @@ +use crate::validators; +use mirror_core::error::validation_error; +use mlua::{FromLua, Lua, Value}; +use std::collections::HashMap; + +const ALLOWED_KEYS: &[&str] = &["handler", "description"]; + +#[derive(Debug, Default, Clone)] +pub struct MrCommand { + pub commands: HashMap, +} +impl MrCommand { + pub fn get(&self, name: &str) -> Option<&MrSubCommand> { + self.commands.get(name) + } +} + +impl FromLua for MrCommand { + fn from_lua(value: Value, lua: &Lua) -> mlua::Result { + let root_tbl = match value { + Value::Table(t) => t, + other => { + return Err(validation_error(format!( + "commands 注册表的顶层配置必须是 Table,实际检测到: {}", + other.type_name() + ))); + } + }; + + let mut commands = HashMap::new(); + + for pair in root_tbl.pairs::() { + let (cmd_name, cmd_entry) = pair?; + + let cmd_name = validators::validate_command_name(&cmd_name)?; + // 2. 校验 entry 是否是 Table + if !cmd_entry.is_table() { + return Err(validation_error(format!( + "子命令 '{cmd_name}' 的配置必须是 Table,实际检测到: {}", + cmd_entry.type_name() + ))); + } + // 3. 直接交由子命令解析,外层负责补充错误上下文 + let cmd_def = MrSubCommand::from_lua(cmd_entry, lua).map_err(|err| { + validation_error(format!("子命令 '{cmd_name}' 配置解析失败:\n{err}")) + })?; + + commands.insert(cmd_name, cmd_def); + } + Ok(Self { commands }) + } +} +#[derive(Debug, Default, Clone)] +pub struct MrSubCommand { + /// 命令处理函数:必须是 Lua Function + pub(crate) module: String, + /// 命令行说明:必须是普通文本字符串 + pub(crate) description: String, +} + +impl FromLua for MrSubCommand { + fn from_lua(value: Value, _lua: &Lua) -> mlua::Result { + let table = match value { + Value::Table(t) => t, + other => { + return Err(validation_error(format!( + "配置必须是 Table,实际检测到: {}", + other.type_name() + ))); + } + }; + // validators::LuaValidator::parse_command_entry(&tbl) + for pair in table.pairs::() { + let (key, _) = pair?; + let key_str = validators::validate_command_name(&key)?; + + if !ALLOWED_KEYS.contains(&key_str.as_str()) { + return Err(validation_error(format!( + "包含未知字段 '{key_str}'。\n\ + ==> 合法字段仅支持: {}\n\ + ==> 请检查是否存在拼写手误(例如把 handler 写成了 handle/handlr)", + ALLOWED_KEYS.join(", ") + ))); + } + } + let handler_val = table.get::("handler")?; + // let handler_func = match handler_val { + // Value::Function(f) => f, + // Value::Boolean(true) => { + // return Err(validation_error( + // "子命令 handler 返回了布尔值 true。\n\ + // ==>排查提示:脚本已成功加载,但未返回函数。\n\ + // ==>请在末尾加上 'return function(args) ... end'。", + // )); + // } + // Value::Nil => { + // return Err(validation_error( + // "子命令缺少必填字段 handler(或对应脚本未返回函数)", + // )); + // } + // other => { + // return Err(validation_error(format!( + // "'handler' 必须是函数 (function),实际类型是: {}", + // other.type_name() + // ))); + // } + // }; + let module = match handler_val { + Value::Table(t) if t.get::("__is_lazy_command").unwrap_or(false) => { + t.get::("module")? + } + Value::String(s) => s.to_str()?.to_string(), // 同时兼容直接写字符串的情况 + other => { + return Err(mlua::Error::runtime(format!( + "handler 必须通过 command(\"xxx\") 声明,实际为 {}", + other.type_name() + ))); + } + }; + + // 可选字段: args(缺失或 nil 时默认空列表;保留空字符串参数,与提权路径的 "" 语义一致) + let description_val = table.get::("description")?; + let description = match description_val { + Value::Nil => String::new(), + Value::String(s) => s.to_str()?.to_string(), + other => { + return Err(validation_error(format!( + "必须是字符串文本,实际类型是 {}", + other.type_name() + ))); + } + }; + // let handler = lua.create_registry_value(handler_func)?; + Ok(Self { + module, + description, + }) + } +} diff --git a/mirror-cli/src/commands/mod.rs b/mirror-cli/src/commands/mod.rs new file mode 100644 index 0000000..5632530 --- /dev/null +++ b/mirror-cli/src/commands/mod.rs @@ -0,0 +1,2 @@ +pub(crate) mod builtin; +pub(crate) mod dynamic; \ No newline at end of file diff --git a/mirror-cli/src/main.rs b/mirror-cli/src/main.rs new file mode 100644 index 0000000..238a58f --- /dev/null +++ b/mirror-cli/src/main.rs @@ -0,0 +1,14 @@ +mod app; +mod cli; +mod commands; +mod validators; + +use std::process::ExitCode; + +fn main() -> ExitCode { + if let Err(err) = app::run() { + eprintln!("\x1b[31m[mr error]\x1b[0m {:#}", err); + return ExitCode::FAILURE; // 返回退出码 1 + } + ExitCode::SUCCESS // 返回退出码 0 +} \ No newline at end of file diff --git a/mirror-cli/src/validators.rs b/mirror-cli/src/validators.rs new file mode 100644 index 0000000..f6110b7 --- /dev/null +++ b/mirror-cli/src/validators.rs @@ -0,0 +1,41 @@ +use mirror_core::error::validation_error; +use mlua::Value; + +pub fn validate_command_name(name: &Value) -> mlua::Result { + let name_str = match name { + Value::String(s) => s + .to_str() + .map_err(|_| validation_error("子命令名称必须是合法的 UTF-8 字符串"))? + .to_string(), + other => { + return Err(validation_error(format!( + "命令行键名类型错误:期望 string,实际是 {}", + other.type_name() + ))); + } + }; + let trimmed = name_str.trim(); + if trimmed.is_empty() { + return Err(validation_error("子命令名称不能为空字符串")); + } + // 2. 禁止包含空格与不可见控制符(否则 shell 与 clap 无法正确定位) + if name_str.contains(|c: char| c.is_whitespace()) { + return Err(validation_error(format!( + "子命令名称 [{name_str}] 非法:命令名不能包含空格或空白字符" + ))); + } + + if name_str.contains('=') { + return Err(validation_error(format!( + "子命令名称 [{}] 不能包含 '='", + name_str + ))); + } + if name_str.contains('\0') { + return Err(validation_error(format!( + "子命令名称 [{}] 不能包含 NUL 字符", + name_str + ))); + } + Ok(name_str) +} \ No newline at end of file diff --git a/mirror-core/Cargo.toml b/mirror-core/Cargo.toml new file mode 100644 index 0000000..7404268 --- /dev/null +++ b/mirror-core/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "mirror-core" +version = { workspace = true } +edition = { workspace = true } + +[dependencies] +anyhow = { workspace = true } +mlua = { workspace = true } +dunce = { workspace = true } +tinyjson = { workspace = true } +windows-sys = { workspace = true } +serde = { workspace = true } +# 日志 +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +tracing-appender = { workspace = true } diff --git a/src/error.rs b/mirror-core/src/error.rs similarity index 100% rename from src/error.rs rename to mirror-core/src/error.rs diff --git a/src/layout.rs b/mirror-core/src/layout.rs similarity index 100% rename from src/layout.rs rename to mirror-core/src/layout.rs diff --git a/src/lib.rs b/mirror-core/src/lib.rs similarity index 75% rename from src/lib.rs rename to mirror-core/src/lib.rs index 66a95d3..987af54 100644 --- a/src/lib.rs +++ b/mirror-core/src/lib.rs @@ -3,13 +3,12 @@ mod layout; mod logger; mod mirror; mod runtime; -pub mod sys; mod utils; -mod validators; +pub mod validators; pub use layout::Layout; pub use mirror::Mirror; pub use runtime::LuaRuntime; -pub use logger::{Logger, init_logging_from}; +pub use logger::{init_logging_from, Logger,LogLevel}; pub use utils::{lua_string_2_os_string, normalize_path_for_lua, parse_tokens}; diff --git a/src/logger.rs b/mirror-core/src/logger.rs similarity index 91% rename from src/logger.rs rename to mirror-core/src/logger.rs index 0a24ba2..3a04f70 100644 --- a/src/logger.rs +++ b/mirror-core/src/logger.rs @@ -7,6 +7,7 @@ use tracing_appender::non_blocking::{NonBlocking, WorkerGuard}; use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::util::SubscriberInitExt; use tracing_subscriber::{EnvFilter, fmt}; + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Deserialize, Default)] #[serde(rename_all = "lowercase")] pub enum LogLevel { @@ -18,6 +19,13 @@ pub enum LogLevel { Debug, Trace, } + +impl std::fmt::Display for LogLevel { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + impl LogLevel { /// 是否需要激活写盘排查模式(Info 及以上更详细的级别) pub fn is_verbose(&self) -> bool { @@ -36,7 +44,7 @@ impl LogLevel { } } impl FromStr for LogLevel { - type Err = (); + type Err = String; fn from_str(s: &str) -> Result { match s.to_ascii_lowercase().as_str() { @@ -46,7 +54,9 @@ impl FromStr for LogLevel { "info" => Ok(LogLevel::Info), "debug" => Ok(LogLevel::Debug), "trace" => Ok(LogLevel::Trace), - _ => Err(()), + _ => Err(format!( + "无效的日志级别 '{s}',可选值为: off, error, warn, info, debug, trace" + )), } } } @@ -101,7 +111,7 @@ impl Logger { pub fn init_logging_from(layout: &Layout) -> Option { // 1. 在 layout.base_dir 目录下寻找 log.toml // let config_path = layout.base_dir.join("mirror.toml"); - let config_path = layout.base_dir.join("mirror.ini"); + let config_path = layout.base_dir.join("../../mirror.ini"); // 读取配置文件(如不存在或解析失败,降级回退到默认设置) let config = if config_path.exists() { @@ -114,7 +124,7 @@ pub fn init_logging_from(layout: &Layout) -> Option { Logger::default() }; - // 1. 优先读取环境变量 SHIM_LOG,解析失败或未设置则退回到 config.level + // 1. 优先读取环境变量 MIRROR_LOG_LEVEL,解析失败或未设置则退回到 config.level let effective_level = std::env::var("MIRROR_LOG_LEVEL") .ok() .and_then(|val| LogLevel::from_str(&val).ok()) diff --git a/src/mirror.rs b/mirror-core/src/mirror.rs similarity index 98% rename from src/mirror.rs rename to mirror-core/src/mirror.rs index bc57114..d06fd4c 100644 --- a/src/mirror.rs +++ b/mirror-core/src/mirror.rs @@ -20,7 +20,7 @@ pub struct Mirror { impl Mirror { pub fn load(layout: &Layout) -> Result { // 策略 1: 尝试加载全局配置文件 mirror.lua - let global_config = layout.base_dir.join("mirror.lua"); + let global_config = layout.base_dir.join("../../mirror.lua"); if !global_config.is_file() { bail!("主配置文件不存在: {}", global_config.display()); diff --git a/src/runtime.rs b/mirror-core/src/runtime.rs similarity index 83% rename from src/runtime.rs rename to mirror-core/src/runtime.rs index 6f37f59..e9c4738 100644 --- a/src/runtime.rs +++ b/mirror-core/src/runtime.rs @@ -36,6 +36,7 @@ impl LuaRuntime { Self::register_get_env(&lua)?; // 3. 初始化并配置安全/容错的 require 机制 Self::setup_require(&lua, &base_dir)?; + Ok(Self { lua }) } @@ -61,8 +62,44 @@ impl LuaRuntime { .set_name(&chunk_name) .eval::() .map_err(syntax_error) - .with_context(|| format!("执行 Lua 配置文件失败: {}", path.display())) + .with_context(|| format!("执行 {} 配置文件失败: {}", chunk_name,path.display())) } + + /// 传入注册表的 handler key 与命令行原始参数,内部完成调用 + pub fn call_handler( + &self, + handler_key: &mlua::RegistryKey, + args: Vec, + ) -> Result<()> { + let handler: mlua::Function = self.lua.registry_value(handler_key)?; + let lua_args = self.lua.create_sequence_from(args)?; + + if let Err(err) = handler.call::<()>(lua_args) { + let msg = err.to_string(); + // 内部消化类似 argparse 的帮助退出信号 + if msg.contains("__ARGPARSE_HELP__") { + return Ok(()); + } + anyhow::bail!("{msg}"); + } + + Ok(()) + } + + pub fn require_and_run(&self, module_name: &str, raw_args: Vec) -> Result<()> { + let require: mlua::Function = self.lua.globals().get("require")?; + let handler_val: mlua::Value = require.call(module_name)?; + + let func = match handler_val { + mlua::Value::Function(f) => f, + other => anyhow::bail!("模块 '{module_name}' 必须返回一个函数,实际为 {}", other.type_name()), + }; + + let lua_args = self.lua.create_sequence_from(raw_args)?; + func.call::<()>(lua_args)?; + Ok(()) + } + } impl LuaRuntime { /// 注入全局锚点变量 @@ -118,16 +155,22 @@ impl LuaRuntime { let globals = lua.globals(); // 1. 安全加固并拓展 package 搜索路径 - if let Ok(package) = globals.get::("package") { - let _ = package.set("cpath", ""); - let _ = package.set("loadlib", Value::Nil); + let package = globals.get::
("package")?; - if let Ok(path) = package.get::("path") { - let new_path = format!("{};{}/?.lua;{}/?/init.lua;", path, base_dir, base_dir); - debug!("重新写入的 package 查找路径"); - let _ = package.set("path", new_path); - } - } + let _ = package.set("cpath", ""); + let _ = package.set("loadlib", Value::Nil); + + let commands_dir = format!("{}/commands", base_dir); + let vendor_dir = format!("{}/vendor", base_dir); + + let path = package.get::("path")?; + let new_path = format!( + "{};{}/?.lua;{}/?/init.lua;{}/?.lua;{}/?/init.lua;{}/?.lua;{}/?/init.lua;", + path, base_dir, base_dir, commands_dir, commands_dir, vendor_dir, vendor_dir + ); + + debug!("重新写入的 package 查找路径"); + package.set("path", new_path)?; // 2. 获取原生 require 并通过闭包直接持有(无需向全局表注入备份变量) let original_require: mlua::Function = globals diff --git a/src/utils.rs b/mirror-core/src/utils.rs similarity index 100% rename from src/utils.rs rename to mirror-core/src/utils.rs diff --git a/src/validators.rs b/mirror-core/src/validators.rs similarity index 100% rename from src/validators.rs rename to mirror-core/src/validators.rs diff --git a/mirror-shim/Cargo.toml b/mirror-shim/Cargo.toml new file mode 100644 index 0000000..383f14d --- /dev/null +++ b/mirror-shim/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "mirror-shim" +version = { workspace = true } +edition = { workspace = true } + +[dependencies] +mirror-core = { path = "../mirror-core" } + +anyhow = { workspace = true } + +windows-sys = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +tracing-appender = { workspace = true } + +[features] +default = [] +args = [] + + + +[profile.release] +opt-level = "z" +panic = "abort" \ No newline at end of file diff --git a/src/main.rs b/mirror-shim/src/main.rs similarity index 95% rename from src/main.rs rename to mirror-shim/src/main.rs index b25f371..50a5a5d 100644 --- a/src/main.rs +++ b/mirror-shim/src/main.rs @@ -1,9 +1,11 @@ -use mirror::{Layout, Mirror,init_logging_from}; -use mirror::sys::*; +use mirror_core::{init_logging_from, Layout, Mirror}; use std::ffi::OsString; use std::{env, process::exit}; use anyhow::Context; -use tracing::{debug, error, warn}; +use tracing::{debug, error}; +use crate::sys::*; + +pub mod sys; fn main() { let current_exe = match env::current_exe().context("获取代理程序路径失败"){ diff --git a/src/sys.rs b/mirror-shim/src/sys.rs similarity index 100% rename from src/sys.rs rename to mirror-shim/src/sys.rs diff --git a/src/sys/win.rs b/mirror-shim/src/sys/win.rs similarity index 100% rename from src/sys/win.rs rename to mirror-shim/src/sys/win.rs diff --git a/mirror.lua b/mirror.lua index 563be88..63f6a0c 100644 --- a/mirror.lua +++ b/mirror.lua @@ -10,6 +10,7 @@ return { -- 1. 标准相对路径 + 正斜杠拼接 (最推荐,绿色便携) --------------------------------------------------- ["numa"] = { + home="", target = base_dir .. "/tools/numa/numa.exe", -- 追加参数 args = { "--help" }, diff --git a/vendor/argparse.lua b/vendor/argparse.lua new file mode 100644 index 0000000..402f14a --- /dev/null +++ b/vendor/argparse.lua @@ -0,0 +1,1527 @@ +-- The MIT License (MIT) + +-- Copyright (c) 2013 - 2018 Peter Melnichenko + +-- Permission is hereby granted, free of charge, to any person obtaining a copy of +-- this software and associated documentation files (the "Software"), to deal in +-- the Software without restriction, including without limitation the rights to +-- use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +-- the Software, and to permit persons to whom the Software is furnished to do so, +-- subject to the following conditions: + +-- The above copyright notice and this permission notice shall be included in all +-- copies or substantial portions of the Software. + +-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +-- FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +-- COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +-- IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +-- CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +local function deep_update(t1, t2) + for k, v in pairs(t2) do + if type(v) == "table" then + v = deep_update({}, v) + end + + t1[k] = v + end + + return t1 +end + +-- A property is a tuple {name, callback}. +-- properties.args is number of properties that can be set as arguments +-- when calling an object. +local function class(prototype, properties, parent) + -- Class is the metatable of its instances. + local cl = {} + cl.__index = cl + + if parent then + cl.__prototype = deep_update(deep_update({}, parent.__prototype), prototype) + else + cl.__prototype = prototype + end + + if properties then + local names = {} + + -- Create setter methods and fill set of property names. + for _, property in ipairs(properties) do + local name, callback = property[1], property[2] + + cl[name] = function(self, value) + if not callback(self, value) then + self["_" .. name] = value + end + + return self + end + + names[name] = true + end + + function cl.__call(self, ...) + -- When calling an object, if the first argument is a table, + -- interpret keys as property names, else delegate arguments + -- to corresponding setters in order. + if type((...)) == "table" then + for name, value in pairs((...)) do + if names[name] then + self[name](self, value) + end + end + else + local nargs = select("#", ...) + + for i, property in ipairs(properties) do + if i > nargs or i > properties.args then + break + end + + local arg = select(i, ...) + + if arg ~= nil then + self[property[1]](self, arg) + end + end + end + + return self + end + end + + -- If indexing class fails, fallback to its parent. + local class_metatable = {} + class_metatable.__index = parent + + function class_metatable.__call(self, ...) + -- Calling a class returns its instance. + -- Arguments are delegated to the instance. + local object = deep_update({}, self.__prototype) + setmetatable(object, self) + return object(...) + end + + return setmetatable(cl, class_metatable) +end + +local function typecheck(name, types, value) + for _, type_ in ipairs(types) do + if type(value) == type_ then + return true + end + end + + error(("bad property '%s' (%s expected, got %s)"):format(name, table.concat(types, " or "), type(value))) +end + +local function typechecked(name, ...) + local types = {...} + return {name, function(_, value) typecheck(name, types, value) end} +end + +local multiname = {"name", function(self, value) + typecheck("name", {"string"}, value) + + for alias in value:gmatch("%S+") do + self._name = self._name or alias + table.insert(self._aliases, alias) + end + + -- Do not set _name as with other properties. + return true +end} + +local function parse_boundaries(str) + if tonumber(str) then + return tonumber(str), tonumber(str) + end + + if str == "*" then + return 0, math.huge + end + + if str == "+" then + return 1, math.huge + end + + if str == "?" then + return 0, 1 + end + + if str:match "^%d+%-%d+$" then + local min, max = str:match "^(%d+)%-(%d+)$" + return tonumber(min), tonumber(max) + end + + if str:match "^%d+%+$" then + local min = str:match "^(%d+)%+$" + return tonumber(min), math.huge + end +end + +local function boundaries(name) + return {name, function(self, value) + typecheck(name, {"number", "string"}, value) + + local min, max = parse_boundaries(value) + + if not min then + error(("bad property '%s'"):format(name)) + end + + self["_min" .. name], self["_max" .. name] = min, max + end} +end + +local actions = {} + +local option_action = {"action", function(_, value) + typecheck("action", {"function", "string"}, value) + + if type(value) == "string" and not actions[value] then + error(("unknown action '%s'"):format(value)) + end +end} + +local option_init = {"init", function(self) + self._has_init = true +end} + +local option_default = {"default", function(self, value) + if type(value) ~= "string" then + self._init = value + self._has_init = true + return true + end +end} + +local add_help = {"add_help", function(self, value) + typecheck("add_help", {"boolean", "string", "table"}, value) + + if self._has_help then + table.remove(self._options) + self._has_help = false + end + + if value then + local help = self:flag() + :description "Show this help message and exit." + :action(function() + print(self:get_help()) + os.exit(0) + end) + + if value ~= true then + help = help(value) + end + + if not help._name then + help "-h" "--help" + end + + self._has_help = true + end +end} + +local Parser = class({ + _arguments = {}, + _options = {}, + _commands = {}, + _mutexes = {}, + _groups = {}, + _require_command = true, + _handle_options = true +}, { + args = 3, + typechecked("name", "string"), + typechecked("description", "string"), + typechecked("epilog", "string"), + typechecked("usage", "string"), + typechecked("help", "string"), + typechecked("require_command", "boolean"), + typechecked("handle_options", "boolean"), + typechecked("action", "function"), + typechecked("command_target", "string"), + typechecked("help_vertical_space", "number"), + typechecked("usage_margin", "number"), + typechecked("usage_max_width", "number"), + typechecked("help_usage_margin", "number"), + typechecked("help_description_margin", "number"), + typechecked("help_max_width", "number"), + add_help +}) + +local Command = class({ + _aliases = {} +}, { + args = 3, + multiname, + typechecked("description", "string"), + typechecked("epilog", "string"), + typechecked("target", "string"), + typechecked("usage", "string"), + typechecked("help", "string"), + typechecked("require_command", "boolean"), + typechecked("handle_options", "boolean"), + typechecked("action", "function"), + typechecked("command_target", "string"), + typechecked("help_vertical_space", "number"), + typechecked("usage_margin", "number"), + typechecked("usage_max_width", "number"), + typechecked("help_usage_margin", "number"), + typechecked("help_description_margin", "number"), + typechecked("help_max_width", "number"), + typechecked("hidden", "boolean"), + add_help +}, Parser) + +local Argument = class({ + _minargs = 1, + _maxargs = 1, + _mincount = 1, + _maxcount = 1, + _defmode = "unused", + _show_default = true +}, { + args = 5, + typechecked("name", "string"), + typechecked("description", "string"), + option_default, + typechecked("convert", "function", "table"), + boundaries("args"), + typechecked("target", "string"), + typechecked("defmode", "string"), + typechecked("show_default", "boolean"), + typechecked("argname", "string", "table"), + typechecked("hidden", "boolean"), + option_action, + option_init +}) + +local Option = class({ + _aliases = {}, + _mincount = 0, + _overwrite = true +}, { + args = 6, + multiname, + typechecked("description", "string"), + option_default, + typechecked("convert", "function", "table"), + boundaries("args"), + boundaries("count"), + typechecked("target", "string"), + typechecked("defmode", "string"), + typechecked("show_default", "boolean"), + typechecked("overwrite", "boolean"), + typechecked("argname", "string", "table"), + typechecked("hidden", "boolean"), + option_action, + option_init +}, Argument) + +function Parser:_inherit_property(name, default) + local element = self + + while true do + local value = element["_" .. name] + + if value ~= nil then + return value + end + + if not element._parent then + return default + end + + element = element._parent + end +end + +function Argument:_get_argument_list() + local buf = {} + local i = 1 + + while i <= math.min(self._minargs, 3) do + local argname = self:_get_argname(i) + + if self._default and self._defmode:find "a" then + argname = "[" .. argname .. "]" + end + + table.insert(buf, argname) + i = i+1 + end + + while i <= math.min(self._maxargs, 3) do + table.insert(buf, "[" .. self:_get_argname(i) .. "]") + i = i+1 + + if self._maxargs == math.huge then + break + end + end + + if i < self._maxargs then + table.insert(buf, "...") + end + + return buf +end + +function Argument:_get_usage() + local usage = table.concat(self:_get_argument_list(), " ") + + if self._default and self._defmode:find "u" then + if self._maxargs > 1 or (self._minargs == 1 and not self._defmode:find "a") then + usage = "[" .. usage .. "]" + end + end + + return usage +end + +function actions.store_true(result, target) + result[target] = true +end + +function actions.store_false(result, target) + result[target] = false +end + +function actions.store(result, target, argument) + result[target] = argument +end + +function actions.count(result, target, _, overwrite) + if not overwrite then + result[target] = result[target] + 1 + end +end + +function actions.append(result, target, argument, overwrite) + result[target] = result[target] or {} + table.insert(result[target], argument) + + if overwrite then + table.remove(result[target], 1) + end +end + +function actions.concat(result, target, arguments, overwrite) + if overwrite then + error("'concat' action can't handle too many invocations") + end + + result[target] = result[target] or {} + + for _, argument in ipairs(arguments) do + table.insert(result[target], argument) + end +end + +function Argument:_get_action() + local action, init + + if self._maxcount == 1 then + if self._maxargs == 0 then + action, init = "store_true", nil + else + action, init = "store", nil + end + else + if self._maxargs == 0 then + action, init = "count", 0 + else + action, init = "append", {} + end + end + + if self._action then + action = self._action + end + + if self._has_init then + init = self._init + end + + if type(action) == "string" then + action = actions[action] + end + + return action, init +end + +-- Returns placeholder for `narg`-th argument. +function Argument:_get_argname(narg) + local argname = self._argname or self:_get_default_argname() + + if type(argname) == "table" then + return argname[narg] + else + return argname + end +end + +function Argument:_get_default_argname() + return "<" .. self._name .. ">" +end + +function Option:_get_default_argname() + return "<" .. self:_get_default_target() .. ">" +end + +-- Returns labels to be shown in the help message. +function Argument:_get_label_lines() + return {self._name} +end + +function Option:_get_label_lines() + local argument_list = self:_get_argument_list() + + if #argument_list == 0 then + -- Don't put aliases for simple flags like `-h` on different lines. + return {table.concat(self._aliases, ", ")} + end + + local longest_alias_length = -1 + + for _, alias in ipairs(self._aliases) do + longest_alias_length = math.max(longest_alias_length, #alias) + end + + local argument_list_repr = table.concat(argument_list, " ") + local lines = {} + + for i, alias in ipairs(self._aliases) do + local line = (" "):rep(longest_alias_length - #alias) .. alias .. " " .. argument_list_repr + + if i ~= #self._aliases then + line = line .. "," + end + + table.insert(lines, line) + end + + return lines +end + +function Command:_get_label_lines() + return {table.concat(self._aliases, ", ")} +end + +function Argument:_get_description() + if self._default and self._show_default then + if self._description then + return ("%s (default: %s)"):format(self._description, self._default) + else + return ("default: %s"):format(self._default) + end + else + return self._description or "" + end +end + +function Command:_get_description() + return self._description or "" +end + +function Option:_get_usage() + local usage = self:_get_argument_list() + table.insert(usage, 1, self._name) + usage = table.concat(usage, " ") + + if self._mincount == 0 or self._default then + usage = "[" .. usage .. "]" + end + + return usage +end + +function Argument:_get_default_target() + return self._name +end + +function Option:_get_default_target() + local res + + for _, alias in ipairs(self._aliases) do + if alias:sub(1, 1) == alias:sub(2, 2) then + res = alias:sub(3) + break + end + end + + res = res or self._name:sub(2) + return (res:gsub("-", "_")) +end + +function Option:_is_vararg() + return self._maxargs ~= self._minargs +end + +function Parser:_get_fullname() + local parent = self._parent + local buf = {self._name} + + while parent do + table.insert(buf, 1, parent._name) + parent = parent._parent + end + + return table.concat(buf, " ") +end + +function Parser:_update_charset(charset) + charset = charset or {} + + for _, command in ipairs(self._commands) do + command:_update_charset(charset) + end + + for _, option in ipairs(self._options) do + for _, alias in ipairs(option._aliases) do + charset[alias:sub(1, 1)] = true + end + end + + return charset +end + +function Parser:argument(...) + local argument = Argument(...) + table.insert(self._arguments, argument) + return argument +end + +function Parser:option(...) + local option = Option(...) + + if self._has_help then + table.insert(self._options, #self._options, option) + else + table.insert(self._options, option) + end + + return option +end + +function Parser:flag(...) + return self:option():args(0)(...) +end + +function Parser:command(...) + local command = Command():add_help(true)(...) + command._parent = self + table.insert(self._commands, command) + return command +end + +function Parser:mutex(...) + local elements = {...} + + for i, element in ipairs(elements) do + local mt = getmetatable(element) + assert(mt == Option or mt == Argument, ("bad argument #%d to 'mutex' (Option or Argument expected)"):format(i)) + end + + table.insert(self._mutexes, elements) + return self +end + +function Parser:group(name, ...) + assert(type(name) == "string", ("bad argument #1 to 'group' (string expected, got %s)"):format(type(name))) + + local group = {name = name, ...} + + for i, element in ipairs(group) do + local mt = getmetatable(element) + assert(mt == Option or mt == Argument or mt == Command, + ("bad argument #%d to 'group' (Option or Argument or Command expected)"):format(i + 1)) + end + + table.insert(self._groups, group) + return self +end + +local usage_welcome = "Usage: " + +function Parser:get_usage() + if self._usage then + return self._usage + end + + local usage_margin = self:_inherit_property("usage_margin", #usage_welcome) + local max_usage_width = self:_inherit_property("usage_max_width", 70) + local lines = {usage_welcome .. self:_get_fullname()} + + local function add(s) + if #lines[#lines]+1+#s <= max_usage_width then + lines[#lines] = lines[#lines] .. " " .. s + else + lines[#lines+1] = (" "):rep(usage_margin) .. s + end + end + + -- Normally options are before positional arguments in usage messages. + -- However, vararg options should be after, because they can't be reliable used + -- before a positional argument. + -- Mutexes come into play, too, and are shown as soon as possible. + -- Overall, output usages in the following order: + -- 1. Mutexes that don't have positional arguments or vararg options. + -- 2. Options that are not in any mutexes and are not vararg. + -- 3. Positional arguments - on their own or as a part of a mutex. + -- 4. Remaining mutexes. + -- 5. Remaining options. + + local elements_in_mutexes = {} + local added_elements = {} + local added_mutexes = {} + local argument_to_mutexes = {} + + local function add_mutex(mutex, main_argument) + if added_mutexes[mutex] then + return + end + + added_mutexes[mutex] = true + local buf = {} + + for _, element in ipairs(mutex) do + if not element._hidden and not added_elements[element] then + if getmetatable(element) == Option or element == main_argument then + table.insert(buf, element:_get_usage()) + added_elements[element] = true + end + end + end + + if #buf == 1 then + add(buf[1]) + elseif #buf > 1 then + add("(" .. table.concat(buf, " | ") .. ")") + end + end + + local function add_element(element) + if not element._hidden and not added_elements[element] then + add(element:_get_usage()) + added_elements[element] = true + end + end + + for _, mutex in ipairs(self._mutexes) do + local is_vararg = false + local has_argument = false + + for _, element in ipairs(mutex) do + if getmetatable(element) == Option then + if element:_is_vararg() then + is_vararg = true + end + else + has_argument = true + argument_to_mutexes[element] = argument_to_mutexes[element] or {} + table.insert(argument_to_mutexes[element], mutex) + end + + elements_in_mutexes[element] = true + end + + if not is_vararg and not has_argument then + add_mutex(mutex) + end + end + + for _, option in ipairs(self._options) do + if not elements_in_mutexes[option] and not option:_is_vararg() then + add_element(option) + end + end + + -- Add usages for positional arguments, together with one mutex containing them, if they are in a mutex. + for _, argument in ipairs(self._arguments) do + -- Pick a mutex as a part of which to show this argument, take the first one that's still available. + local mutex + + if elements_in_mutexes[argument] then + for _, argument_mutex in ipairs(argument_to_mutexes[argument]) do + if not added_mutexes[argument_mutex] then + mutex = argument_mutex + end + end + end + + if mutex then + add_mutex(mutex, argument) + else + add_element(argument) + end + end + + for _, mutex in ipairs(self._mutexes) do + add_mutex(mutex) + end + + for _, option in ipairs(self._options) do + add_element(option) + end + + if #self._commands > 0 then + if self._require_command then + add("") + else + add("[]") + end + + add("...") + end + + return table.concat(lines, "\n") +end + +local function split_lines(s) + if s == "" then + return {} + end + + local lines = {} + + if s:sub(-1) ~= "\n" then + s = s .. "\n" + end + + for line in s:gmatch("([^\n]*)\n") do + table.insert(lines, line) + end + + return lines +end + +local function autowrap_line(line, max_length) + -- Algorithm for splitting lines is simple and greedy. + local result_lines = {} + + -- Preserve original indentation of the line, put this at the beginning of each result line. + -- If the first word looks like a list marker ('*', '+', or '-'), add spaces so that starts + -- of the second and the following lines vertically align with the start of the second word. + local indentation = line:match("^ *") + + if line:find("^ *[%*%+%-]") then + indentation = indentation .. " " .. line:match("^ *[%*%+%-]( *)") + end + + -- Parts of the last line being assembled. + local line_parts = {} + + -- Length of the current line. + local line_length = 0 + + -- Index of the next character to consider. + local index = 1 + + while true do + local word_start, word_finish, word = line:find("([^ ]+)", index) + + if not word_start then + -- Ignore trailing spaces, if any. + break + end + + local preceding_spaces = line:sub(index, word_start - 1) + index = word_finish + 1 + + if (#line_parts == 0) or (line_length + #preceding_spaces + #word <= max_length) then + -- Either this is the very first word or it fits as an addition to the current line, add it. + table.insert(line_parts, preceding_spaces) -- For the very first word this adds the indentation. + table.insert(line_parts, word) + line_length = line_length + #preceding_spaces + #word + else + -- Does not fit, finish current line and put the word into a new one. + table.insert(result_lines, table.concat(line_parts)) + line_parts = {indentation, word} + line_length = #indentation + #word + end + end + + if #line_parts > 0 then + table.insert(result_lines, table.concat(line_parts)) + end + + if #result_lines == 0 then + -- Preserve empty lines. + result_lines[1] = "" + end + + return result_lines +end + +-- Automatically wraps lines within given array, +-- attempting to limit line length to `max_length`. +-- Existing line splits are preserved. +local function autowrap(lines, max_length) + local result_lines = {} + + for _, line in ipairs(lines) do + local autowrapped_lines = autowrap_line(line, max_length) + + for _, autowrapped_line in ipairs(autowrapped_lines) do + table.insert(result_lines, autowrapped_line) + end + end + + return result_lines +end + +function Parser:_get_element_help(element) + local label_lines = element:_get_label_lines() + local description_lines = split_lines(element:_get_description()) + + local result_lines = {} + + -- All label lines should have the same length (except the last one, it has no comma). + -- If too long, start description after all the label lines. + -- Otherwise, combine label and description lines. + + local usage_margin_len = self:_inherit_property("help_usage_margin", 3) + local usage_margin = (" "):rep(usage_margin_len) + local description_margin_len = self:_inherit_property("help_description_margin", 25) + local description_margin = (" "):rep(description_margin_len) + + local help_max_width = self:_inherit_property("help_max_width") + + if help_max_width then + local description_max_width = math.max(help_max_width - description_margin_len, 10) + description_lines = autowrap(description_lines, description_max_width) + end + + if #label_lines[1] >= (description_margin_len - usage_margin_len) then + for _, label_line in ipairs(label_lines) do + table.insert(result_lines, usage_margin .. label_line) + end + + for _, description_line in ipairs(description_lines) do + table.insert(result_lines, description_margin .. description_line) + end + else + for i = 1, math.max(#label_lines, #description_lines) do + local label_line = label_lines[i] + local description_line = description_lines[i] + + local line = "" + + if label_line then + line = usage_margin .. label_line + end + + if description_line and description_line ~= "" then + line = line .. (" "):rep(description_margin_len - #line) .. description_line + end + + table.insert(result_lines, line) + end + end + + return table.concat(result_lines, "\n") +end + +local function get_group_types(group) + local types = {} + + for _, element in ipairs(group) do + types[getmetatable(element)] = true + end + + return types +end + +function Parser:_add_group_help(blocks, added_elements, label, elements) + local buf = {label} + + for _, element in ipairs(elements) do + if not element._hidden and not added_elements[element] then + added_elements[element] = true + table.insert(buf, self:_get_element_help(element)) + end + end + + if #buf > 1 then + table.insert(blocks, table.concat(buf, ("\n"):rep(self:_inherit_property("help_vertical_space", 0) + 1))) + end +end + +function Parser:get_help() + if self._help then + return self._help + end + + local blocks = {self:get_usage()} + + local help_max_width = self:_inherit_property("help_max_width") + + if self._description then + local description = self._description + + if help_max_width then + description = table.concat(autowrap(split_lines(description), help_max_width), "\n") + end + + table.insert(blocks, description) + end + + -- 1. Put groups containing arguments first, then other arguments. + -- 2. Put remaining groups containing options, then other options. + -- 3. Put remaining groups containing commands, then other commands. + -- Assume that an element can't be in several groups. + local groups_by_type = { + [Argument] = {}, + [Option] = {}, + [Command] = {} + } + + for _, group in ipairs(self._groups) do + local group_types = get_group_types(group) + + for _, mt in ipairs({Argument, Option, Command}) do + if group_types[mt] then + table.insert(groups_by_type[mt], group) + break + end + end + end + + local default_groups = { + {name = "Arguments", type = Argument, elements = self._arguments}, + {name = "Options", type = Option, elements = self._options}, + {name = "Commands", type = Command, elements = self._commands} + } + + local added_elements = {} + + for _, default_group in ipairs(default_groups) do + local type_groups = groups_by_type[default_group.type] + + for _, group in ipairs(type_groups) do + self:_add_group_help(blocks, added_elements, group.name .. ":", group) + end + + local default_label = default_group.name .. ":" + + if #type_groups > 0 then + default_label = "Other " .. default_label:gsub("^.", string.lower) + end + + self:_add_group_help(blocks, added_elements, default_label, default_group.elements) + end + + if self._epilog then + local epilog = self._epilog + + if help_max_width then + epilog = table.concat(autowrap(split_lines(epilog), help_max_width), "\n") + end + + table.insert(blocks, epilog) + end + + return table.concat(blocks, "\n\n") +end + +local function get_tip(context, wrong_name) + local context_pool = {} + local possible_name + local possible_names = {} + + for name in pairs(context) do + if type(name) == "string" then + for i = 1, #name do + possible_name = name:sub(1, i - 1) .. name:sub(i + 1) + + if not context_pool[possible_name] then + context_pool[possible_name] = {} + end + + table.insert(context_pool[possible_name], name) + end + end + end + + for i = 1, #wrong_name + 1 do + possible_name = wrong_name:sub(1, i - 1) .. wrong_name:sub(i + 1) + + if context[possible_name] then + possible_names[possible_name] = true + elseif context_pool[possible_name] then + for _, name in ipairs(context_pool[possible_name]) do + possible_names[name] = true + end + end + end + + local first = next(possible_names) + + if first then + if next(possible_names, first) then + local possible_names_arr = {} + + for name in pairs(possible_names) do + table.insert(possible_names_arr, "'" .. name .. "'") + end + + table.sort(possible_names_arr) + return "\nDid you mean one of these: " .. table.concat(possible_names_arr, " ") .. "?" + else + return "\nDid you mean '" .. first .. "'?" + end + else + return "" + end +end + +local ElementState = class({ + invocations = 0 +}) + +function ElementState:__call(state, element) + self.state = state + self.result = state.result + self.element = element + self.target = element._target or element:_get_default_target() + self.action, self.result[self.target] = element:_get_action() + return self +end + +function ElementState:error(fmt, ...) + self.state:error(fmt, ...) +end + +function ElementState:convert(argument, index) + local converter = self.element._convert + + if converter then + local ok, err + + if type(converter) == "function" then + ok, err = converter(argument) + elseif type(converter[index]) == "function" then + ok, err = converter[index](argument) + else + ok = converter[argument] + end + + if ok == nil then + self:error(err and "%s" or "malformed argument '%s'", err or argument) + end + + argument = ok + end + + return argument +end + +function ElementState:default(mode) + return self.element._defmode:find(mode) and self.element._default +end + +local function bound(noun, min, max, is_max) + local res = "" + + if min ~= max then + res = "at " .. (is_max and "most" or "least") .. " " + end + + local number = is_max and max or min + return res .. tostring(number) .. " " .. noun .. (number == 1 and "" or "s") +end + +function ElementState:set_name(alias) + self.name = ("%s '%s'"):format(alias and "option" or "argument", alias or self.element._name) +end + +function ElementState:invoke() + self.open = true + self.overwrite = false + + if self.invocations >= self.element._maxcount then + if self.element._overwrite then + self.overwrite = true + else + local num_times_repr = bound("time", self.element._mincount, self.element._maxcount, true) + self:error("%s must be used %s", self.name, num_times_repr) + end + else + self.invocations = self.invocations + 1 + end + + self.args = {} + + if self.element._maxargs <= 0 then + self:close() + end + + return self.open +end + +function ElementState:pass(argument) + argument = self:convert(argument, #self.args + 1) + table.insert(self.args, argument) + + if #self.args >= self.element._maxargs then + self:close() + end + + return self.open +end + +function ElementState:complete_invocation() + while #self.args < self.element._minargs do + self:pass(self.element._default) + end +end + +function ElementState:close() + if self.open then + self.open = false + + if #self.args < self.element._minargs then + if self:default("a") then + self:complete_invocation() + else + if #self.args == 0 then + if getmetatable(self.element) == Argument then + self:error("missing %s", self.name) + elseif self.element._maxargs == 1 then + self:error("%s requires an argument", self.name) + end + end + + self:error("%s requires %s", self.name, bound("argument", self.element._minargs, self.element._maxargs)) + end + end + + local args + + if self.element._maxargs == 0 then + args = self.args[1] + elseif self.element._maxargs == 1 then + if self.element._minargs == 0 and self.element._mincount ~= self.element._maxcount then + args = self.args + else + args = self.args[1] + end + else + args = self.args + end + + self.action(self.result, self.target, args, self.overwrite) + end +end + +local ParseState = class({ + result = {}, + options = {}, + arguments = {}, + argument_i = 1, + element_to_mutexes = {}, + mutex_to_element_state = {}, + command_actions = {} +}) + +function ParseState:__call(parser, error_handler) + self.parser = parser + self.error_handler = error_handler + self.charset = parser:_update_charset() + self:switch(parser) + return self +end + +function ParseState:error(fmt, ...) + self.error_handler(self.parser, fmt:format(...)) +end + +function ParseState:switch(parser) + self.parser = parser + + if parser._action then + table.insert(self.command_actions, {action = parser._action, name = parser._name}) + end + + for _, option in ipairs(parser._options) do + option = ElementState(self, option) + table.insert(self.options, option) + + for _, alias in ipairs(option.element._aliases) do + self.options[alias] = option + end + end + + for _, mutex in ipairs(parser._mutexes) do + for _, element in ipairs(mutex) do + if not self.element_to_mutexes[element] then + self.element_to_mutexes[element] = {} + end + + table.insert(self.element_to_mutexes[element], mutex) + end + end + + for _, argument in ipairs(parser._arguments) do + argument = ElementState(self, argument) + table.insert(self.arguments, argument) + argument:set_name() + argument:invoke() + end + + self.handle_options = parser._handle_options + self.argument = self.arguments[self.argument_i] + self.commands = parser._commands + + for _, command in ipairs(self.commands) do + for _, alias in ipairs(command._aliases) do + self.commands[alias] = command + end + end +end + +function ParseState:get_option(name) + local option = self.options[name] + + if not option then + self:error("unknown option '%s'%s", name, get_tip(self.options, name)) + else + return option + end +end + +function ParseState:get_command(name) + local command = self.commands[name] + + if not command then + if #self.commands > 0 then + self:error("unknown command '%s'%s", name, get_tip(self.commands, name)) + else + self:error("too many arguments") + end + else + return command + end +end + +function ParseState:check_mutexes(element_state) + if self.element_to_mutexes[element_state.element] then + for _, mutex in ipairs(self.element_to_mutexes[element_state.element]) do + local used_element_state = self.mutex_to_element_state[mutex] + + if used_element_state and used_element_state ~= element_state then + self:error("%s can not be used together with %s", element_state.name, used_element_state.name) + else + self.mutex_to_element_state[mutex] = element_state + end + end + end +end + +function ParseState:invoke(option, name) + self:close() + option:set_name(name) + self:check_mutexes(option, name) + + if option:invoke() then + self.option = option + end +end + +function ParseState:pass(arg) + if self.option then + if not self.option:pass(arg) then + self.option = nil + end + elseif self.argument then + self:check_mutexes(self.argument) + + if not self.argument:pass(arg) then + self.argument_i = self.argument_i + 1 + self.argument = self.arguments[self.argument_i] + end + else + local command = self:get_command(arg) + self.result[command._target or command._name] = true + + if self.parser._command_target then + self.result[self.parser._command_target] = command._name + end + + self:switch(command) + end +end + +function ParseState:close() + if self.option then + self.option:close() + self.option = nil + end +end + +function ParseState:finalize() + self:close() + + for i = self.argument_i, #self.arguments do + local argument = self.arguments[i] + if #argument.args == 0 and argument:default("u") then + argument:complete_invocation() + else + argument:close() + end + end + + if self.parser._require_command and #self.commands > 0 then + self:error("a command is required") + end + + for _, option in ipairs(self.options) do + option.name = option.name or ("option '%s'"):format(option.element._name) + + if option.invocations == 0 then + if option:default("u") then + option:invoke() + option:complete_invocation() + option:close() + end + end + + local mincount = option.element._mincount + + if option.invocations < mincount then + if option:default("a") then + while option.invocations < mincount do + option:invoke() + option:close() + end + elseif option.invocations == 0 then + self:error("missing %s", option.name) + else + self:error("%s must be used %s", option.name, bound("time", mincount, option.element._maxcount)) + end + end + end + + for i = #self.command_actions, 1, -1 do + self.command_actions[i].action(self.result, self.command_actions[i].name) + end +end + +function ParseState:parse(args) + for _, arg in ipairs(args) do + local plain = true + + if self.handle_options then + local first = arg:sub(1, 1) + + if self.charset[first] then + if #arg > 1 then + plain = false + + if arg:sub(2, 2) == first then + if #arg == 2 then + if self.options[arg] then + local option = self:get_option(arg) + self:invoke(option, arg) + else + self:close() + end + + self.handle_options = false + else + local equals = arg:find "=" + if equals then + local name = arg:sub(1, equals - 1) + local option = self:get_option(name) + + if option.element._maxargs <= 0 then + self:error("option '%s' does not take arguments", name) + end + + self:invoke(option, name) + self:pass(arg:sub(equals + 1)) + else + local option = self:get_option(arg) + self:invoke(option, arg) + end + end + else + for i = 2, #arg do + local name = first .. arg:sub(i, i) + local option = self:get_option(name) + self:invoke(option, name) + + if i ~= #arg and option.element._maxargs > 0 then + self:pass(arg:sub(i + 1)) + break + end + end + end + end + end + end + + if plain then + self:pass(arg) + end + end + + self:finalize() + return self.result +end + +function Parser:error(msg) + io.stderr:write(("%s\n\nError: %s\n"):format(self:get_usage(), msg)) + os.exit(1) +end + +-- Compatibility with strict.lua and other checkers: +local default_cmdline = rawget(_G, "arg") or {} + +function Parser:_parse(args, error_handler) + return ParseState(self, error_handler):parse(args or default_cmdline) +end + +function Parser:parse(args) + return self:_parse(args, self.error) +end + +local function xpcall_error_handler(err) + return tostring(err) .. "\noriginal " .. debug.traceback("", 2):sub(2) +end + +function Parser:pparse(args) + local parse_error + + local ok, result = xpcall(function() + return self:_parse(args, function(_, err) + parse_error = err + error(err, 0) + end) + end, xpcall_error_handler) + + if ok then + return true, result + elseif not parse_error then + error(result, 0) + else + return false, parse_error + end +end + +local argparse = {} + +argparse.version = "0.6.0" + +setmetatable(argparse, {__call = function(_, ...) + return Parser(default_cmdline[0]):add_help(true)(...) +end}) + +return argparse