diff --git a/.changeset/violet-taxis-try.md b/.changeset/violet-taxis-try.md new file mode 100644 index 0000000..b6f090d --- /dev/null +++ b/.changeset/violet-taxis-try.md @@ -0,0 +1,5 @@ +--- +"ultraflag": minor +--- + +Optimize performance by using a single loop for parsing diff --git a/README.md b/README.md index cf3b39b..9a71fb7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # `ultraflag` -A <1kB library for parsing CLI flags. Inspired by Deno's `std` [`flags`](https://github.com/denoland/deno_std/blob/main/flags/mod.ts) module. +A 730B library for parsing CLI flags. Inspired by Deno's `std` [`flags`](https://github.com/denoland/deno_std/blob/main/flags/mod.ts) module. ### Features @@ -37,7 +37,8 @@ const args = parse(argv, { ## Benchmarks ``` -ultraflag x 801,993 ops/sec ±0.40% (95 runs sampled) -minimist x 318,623 ops/sec ±0.49% (95 runs sampled) -yargs-parser x 23,560 ops/sec ±3.77% (91 runs sampled) +mri x 1,285,159 ops/sec ±0.29% (90 runs sampled) +ultraflag x 986,699 ops/sec ±0.38% (91 runs sampled) +minimist x 250,866 ops/sec ±0.59% (92 runs sampled) +yargs-parser x 18,153 ops/sec ±4.30% (85 runs sampled) ``` diff --git a/package.json b/package.json index d436786..6bd0273 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "globby": "^13.1.2", "gzip-size": "^7.0.0", "minimist": "^1.2.7", + "mri": "^1.2.0", "npm-run-all": "^4.1.5", "prettier": "^2.5.1", "pretty-bytes": "^6.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1d0e3d6..a3fd04b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,6 +9,7 @@ specifiers: globby: ^13.1.2 gzip-size: ^7.0.0 minimist: ^1.2.7 + mri: ^1.2.0 npm-run-all: ^4.1.5 prettier: ^2.5.1 pretty-bytes: ^6.0.0 @@ -25,6 +26,7 @@ devDependencies: globby: 13.1.2 gzip-size: 7.0.0 minimist: 1.2.7 + mri: 1.2.0 npm-run-all: 4.1.5 prettier: 2.7.1 pretty-bytes: 6.0.0 @@ -1478,6 +1480,11 @@ packages: engines: {node: '>= 8.0.0'} dev: true + /mri/1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + dev: true + /ms/2.1.2: resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} dev: true diff --git a/scripts/bench.js b/scripts/bench.js index 7293479..f12ca07 100644 --- a/scripts/bench.js +++ b/scripts/bench.js @@ -2,24 +2,15 @@ import benchmark from "benchmark"; import { parse as ultraflag } from "../dist/index.js"; import minimist from 'minimist'; import yargs from 'yargs-parser'; +import mri from 'mri'; -// @ts-ignore -const suite = new benchmark.Suite(); +const bench = new benchmark.Suite(); +const args = ['--a=1', '-b', '--bool', '--no-boop', '--multi=foo', '--multi=baz', '-xyz']; -const args = `--a=1 --b=2 -c 3 -xyz -c 4`.split(' '); - -suite - .add("ultraflag", () => { - ultraflag(args); - }) - .add("minimist", () => { - minimist(args); - }) - .add("yargs-parser", () => { - yargs(args) - }) - .on("cycle", (event) => { - console.log(String(event.target)); - }); - -suite.run(); +bench + .add('ultraflag ', () => ultraflag(args)) + .add('mri ', () => mri(args)) + .add('minimist ', () => minimist(args)) + .add('yargs-parser ', () => yargs(args)) + .on('cycle', e => console.log(String(e.target))) + .run(); diff --git a/src/index.ts b/src/index.ts index 1e9cd31..a5a7ac9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,17 +6,12 @@ import type { BooleanType, StringType, Collectable, - Aliases, } from "./types.js"; export { ParseOptions, Args } from "./types.js"; -const FLAG_RE = /(?:--?([^\s=]+))(?:\s+|=|$)("[^"]+"|'[^']+'|[^-\s]+)?|\S+/gm; const BOOL_RE = /^(true|false)$/; -const NUMBER_RE = /^(\.?\d)/; const QUOTED_RE = /^('|").*\1$/; -const NEGATED_RE = /^no-/; -const SINGLE_RE = /^-[^-]/; const set = (obj: NestedMapping, key: string, value: any, type?: string) => { if (key.includes(".")) { @@ -29,38 +24,44 @@ const set = (obj: NestedMapping, key: string, value: any, type?: string) => { } key = parts[parts.length - 1]; } - if (type === 'array' && obj[key] !== undefined) { + if (type === "array" && obj[key] !== undefined) { if (Array.isArray(obj[key])) { (obj[key] as any[]).push(value); } else { obj[key] = [obj[key], value]; } } else { - obj[key] = type === 'array' ? [value] : value; + obj[key] = type === "array" ? [value] : value; } }; -const type = (key: string, opts: Record<'boolean' | 'string' | 'array', string[]>): 'boolean' | 'string' | 'array' | undefined => { - for (const [t, keys] of Object.entries(opts)) { - if (keys.includes(key)) return t as keyof typeof opts; - } +const type = ( + key: string, + opts: Record<"boolean" | "string" | "array", string[]> +): "boolean" | "string" | "array" | undefined => { + if (opts.array && opts.array.length > 0 && opts.array.includes(key)) + return "array"; + if (opts.string && opts.string.length > 0 && opts.string.includes(key)) + return "string"; + if (opts.boolean && opts.boolean.length > 0 && opts.boolean.includes(key)) + return "boolean"; return; -} +}; -const defaultValue = (type?: 'boolean' | 'string' | 'array') => { - if (type === 'string') return ''; - if (type === 'array') return []; +const defaultValue = (type?: "boolean" | "string" | "array") => { + if (type === "string") return ""; + if (type === "array") return []; return true; -} +}; -const coerce = (value: string, type?: 'string' | 'boolean' | "array") => { - if (type === 'string') return value; - if (type === 'boolean') return !!value; +const coerce = (value?: string, type?: "string" | "boolean" | "array") => { + if (type === "string") return value; + if (type === "boolean") return !!value; if (!value) return value; - if (BOOL_RE.test(value)) return value === "true"; - if (NUMBER_RE.test(value)) return Number(value); - if (QUOTED_RE.test(value)) return value.slice(1, -1); + if (value.length > 3 && BOOL_RE.test(value)) return value === "true"; + if (value.length > 2 && QUOTED_RE.test(value)) return value.slice(1, -1); + if (value[0] === '.' && /\d/.test(value[1]) || /\d/.test(value[0])) return Number(value); return value; }; @@ -82,48 +83,70 @@ export function parse< TAliasNames extends string = string >( argv: string[], - { default: defaults, alias: aliases = {}, ...types }: ParseOptions< - TBooleans, - TStrings, - TCollectable, - TDefaults, - TAliases - > = {} + { + default: defaults, + alias: aliases, + ...types + }: ParseOptions = {} ): Args { if (argv.length === 0) return {} as Args; - const str = argv.join(' '); - - FLAG_RE.lastIndex = 0; - let m; const obj = { ...defaults, _: [] } as unknown as Args; - while ((m = FLAG_RE.exec(str))) { - let [value, key, arg] = m; - let isAliased = false; - if (!key && !arg) { - (obj as any)._.push(coerce(value)); - continue; - } - if (aliases.hasOwnProperty(key)) { - key = aliases[key as keyof typeof aliases] as string; - isAliased = true; - } - const t = type(key, types as any); - if (!isAliased && SINGLE_RE.test(value)) { - // Special case! `-a.a1` should be treated as { a: '.a1' } - if (key.includes(".")) { - set(obj, key.split(".")[0], "." + key.split(".").slice(1).join(".")); - FLAG_RE.lastIndex -= arg?.length ?? 0; + const args = []; + for (let i = 0; i < argv.length; i++) { + const curr = argv[i]; + const next = argv[i + 1]; + + let t: 'string' | 'boolean' | 'array' | undefined; + let key = ''; + let value: string | undefined; + + if (curr.length > 1 && curr[0] === "-") { + if (curr[1] !== "-" && curr.length > 2 && !curr.includes('=')) { + if (curr.includes('.')) { + key = curr.slice(1, 2); + value = curr.slice(2); + } else { + const keys = curr.slice(1, -1); + for (let key of keys) { + if (aliases && (aliases as Record)[key] !== undefined) { + key = aliases[key as keyof typeof aliases] as string; + } + set(obj, key, defaultValue(t), t) + } + key = curr.slice(-1) + if (next && next[0] !== '-') { + value = next; + i++; + } + } + } else if (!curr.includes("=") && next && next[0] !== "-") { + key = curr.replace(/^-{1,2}/, ''); + value = next; + t = type(key, types as any); + i++; } else { - for (const k of key.slice(0, -1)) { - set(obj, k, true); + const eq = curr.indexOf('='); + if (eq === -1) { + key = curr.replace(/^-{1,2}/, ''); + } else { + key = curr.slice(0, eq).replace(/^-{1,2}/, ''); + value = curr.slice(eq + 1); } - set(obj, key[key.length - 1], coerce(arg, t) ?? true); + t = type(key, types as any); } - } else if ((!t || t === 'boolean') && NEGATED_RE.test(key)) { - set(obj, key.slice(3), false); - } else { - set(obj, key, coerce(arg, t) ?? defaultValue(t), t); + + if ((!t || t === "boolean") && key.length > 3 && key.startsWith('no-')) { + set(obj, key.slice(3), false) + } else { + if (aliases && (aliases as Record)[key] !== undefined) { + key = aliases[key as keyof typeof aliases] as string; + } + set(obj, key, coerce(value, t) ?? defaultValue(t), t) + } + } else if (curr) { + (obj as any)._.push(coerce(curr)); + continue; } }