From 2a17ca30cb57d75c42670df90bc5f273adecbc91 Mon Sep 17 00:00:00 2001 From: Mplan Date: Fri, 12 Jun 2026 14:14:31 +0800 Subject: [PATCH] feat: add CLI random number generator supporting 6 distributions --- .gitignore | 39 ++++++++++ README.md | 112 ++++++++++++++++++++++++++++ index.ts | 2 + package.json | 19 +++++ src/args.ts | 202 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/dist.ts | 88 ++++++++++++++++++++++ src/help.ts | 35 +++++++++ src/main.ts | 29 ++++++++ src/stdin.ts | 23 ++++++ src/types.ts | 55 ++++++++++++++ tsconfig.json | 30 ++++++++ 11 files changed, 634 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100755 index.ts create mode 100644 package.json create mode 100644 src/args.ts create mode 100644 src/dist.ts create mode 100644 src/help.ts create mode 100644 src/main.ts create mode 100644 src/stdin.ts create mode 100644 src/types.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..74e6b49 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store + +# lock + +bun.lock +package-lock.json \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..a67b545 --- /dev/null +++ b/README.md @@ -0,0 +1,112 @@ +# rand + +一个快速、可组合的命令行随机数生成器,支持 6 种概率分布,原生管道语法。 + +## 安装 + +```bash +# 源码运行 +bun install +bun run index.ts + +# 构建独立二进制 +bun run build # → dist/rand +sudo cp dist/rand /usr/local/bin/ +``` + +## 快速开始 + +```bash +rand # 0–100 的随机整数 +rand 50 # 0–50 +rand 10 20 # 10–20 +rand -c 5 # 生成 5 个数 +rand -f2 # 保留 2 位小数 +``` + +## 选项 + +| 选项 | 说明 | +|------|------| +| `-c, --count ` | 生成个数,默认 1 | +| `-f[N]` | 小数位数:`-f` 默认 2 位,`-f1` = 1 位,`-f3` = 3 位 | +| `--float [N]` | `-f` 的长形式 | +| `-d, --dist ` | 概率分布,默认 `uniform` | +| `-h, --help` | 帮助信息 | +| `--` | 之后所有参数视为位置参数(用于负数) | + +## 概率分布 + +| 分布 | 命令 | 参数 | 默认值 | +|------|------|------|--------| +| uniform | `rand` | `[min] [max]` | 0, 100 | +| normal | `rand -d normal` | `[μ] [σ]` | 0, 1 | +| binomial | `rand -d binomial` | `[n] [p]` | 10, 0.5 | +| poisson | `rand -d poisson` | `[λ]` | 1 | +| exponential | `rand -d exponential` | `[λ]` | 1 | +| hypergeometric | `rand -d hypergeometric` | `[N] [K] [n]` | 100, 50, 10 | + +### 采样算法 + +| 分布 | 算法 | +|------|------| +| normal | Box-Muller 变换 | +| binomial | Bernoulli 试验求和 | +| poisson | Knuth 算法 | +| exponential | 逆 CDF 变换 | +| hypergeometric | 不放回抽样模拟 | + +## 管道 + +stdout 输出纯数据,stderr 输出诊断信息,天然支持管道组合: + +```bash +rand | xargs echo # 管道输出 +echo 50 | rand # stdin 提供上限 +echo "5 2" | rand -d normal -f1 # stdin 覆盖分布参数 +rand -c 100 | sort -n | head -5 # 生成 100 个,取最小的 5 个 +``` + +## 示例 + +```bash +# 正态分布:均值 100,标准差 15 +rand -d normal 100 15 -f1 + +# 二项分布:20 次试验,成功概率 0.3,生成 5 个样本 +rand -d binomial 20 0.3 -c 5 + +# 泊松分布:λ=3 +rand -d poisson 3 + +# 指数分布:λ=0.5,保留 1 位小数 +rand -d exponential 0.5 -f1 + +# 超几何分布:总体 100,成功 30,抽取 5 次 +rand -d hypergeometric 100 30 5 + +# 负数范围(用 -- 分隔标志和参数) +rand -- -10 -5 +rand -d normal -- 0 -1 # stddev 必须 > 0,会报错 +``` + +## 项目结构 + +``` +index.ts # 入口 +src/ + types.ts # 类型定义与默认值 + help.ts # 帮助文本 + args.ts # 参数解析与校验 + stdin.ts # 管道输入读取 + dist.ts # 分布采样器与调度 + main.ts # 编排逻辑 +``` + +## 构建 + +```bash +bun run build # → dist/rand(独立二进制,约 74MB) +``` + +基于 [Bun](https://bun.com) 构建,零外部依赖。 diff --git a/index.ts b/index.ts new file mode 100755 index 0000000..cdf34e8 --- /dev/null +++ b/index.ts @@ -0,0 +1,2 @@ +#!/usr/bin/env bun +import "./src/main.ts"; diff --git a/package.json b/package.json new file mode 100644 index 0000000..9de6c49 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "rand", + "version": "1.0.0", + "module": "index.ts", + "type": "module", + "bin": { + "rand": "./index.ts" + }, + "scripts": { + "build": "bun build ./index.ts --compile --outfile dist/rand" + }, + "private": true, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + } +} diff --git a/src/args.ts b/src/args.ts new file mode 100644 index 0000000..5e3adcd --- /dev/null +++ b/src/args.ts @@ -0,0 +1,202 @@ +import { type Options, type Dist, DISTS, defaultOptions } from "./types"; +import { showHelp } from "./help"; + +export function showError(msg: string): never { + console.error(`rand: ${msg}\nTry 'rand --help' for usage.`); + process.exit(1); +} + +export function parseArgs(raw: string[]): { options: Options; help: boolean } { + const positional: string[] = []; + let count = 1; + let decimals = 0; + let dist: Dist = "uniform"; + let help = false; + + let i = 0; + while (i < raw.length) { + const arg = raw[i]!; + + if (arg === "-h" || arg === "--help") { + help = true; + i++; + continue; + } + + // -f (plain) → default 2 decimal places + if (arg === "-f") { + decimals = 2; + i++; + continue; + } + + // -fN combined form: -f1, -f2, -f3, … + if (/^-f\d+$/.test(arg)) { + decimals = parseInt(arg.slice(2), 10); + i++; + continue; + } + + // --float [N] + if (arg === "--float") { + i++; + if (i < raw.length && /^\d+$/.test(raw[i]!)) { + decimals = parseInt(raw[i]!, 10); + i++; + } else { + decimals = 2; + } + continue; + } + + // -d, --dist + if (arg === "-d" || arg === "--dist") { + i++; + if (i >= raw.length) showError("--dist requires a distribution name"); + const name = raw[i]!; + if (!(DISTS as string[]).includes(name)) { + showError(`unknown distribution: ${name}\n valid: ${DISTS.join(", ")}`); + } + dist = name as Dist; + i++; + continue; + } + + // -c, --count + if (arg === "-c" || arg === "--count") { + i++; + if (i >= raw.length) showError("--count requires a value"); + const n = parseInt(raw[i]!, 10); + if (isNaN(n) || n < 1) showError(`invalid count: ${raw[i]}`); + count = n; + i++; + continue; + } + + // -- : everything after is positional + if (arg === "--") { + i++; + while (i < raw.length) { + positional.push(raw[i]!); + i++; + } + break; + } + + // Negative number as positional (e.g. -1, -0.5) + if (/^-\d+(\.\d+)?$/.test(arg)) { + positional.push(arg); + i++; + continue; + } + + // Catch unknown flags + if (arg.startsWith("-")) { + showError(`unknown option: ${arg}`); + } + + positional.push(arg); + i++; + } + + const opts = defaultOptions(dist); + opts.count = count; + opts.decimals = decimals; + applyPositionals(opts, positional); + + return { options: opts, help }; +} + +export function applyPositionals(opts: Options, args: string[]): void { + const nums = args.map(parseNum); + + switch (opts.dist) { + case "uniform": { + if (nums.length === 0) return; + if (nums.length === 1) { + opts.max = nums[0]!; + } else if (nums.length === 2) { + opts.min = nums[0]!; + opts.max = nums[1]!; + } else { + showError(`uniform expects 0–2 args, got ${nums.length}: ${args.join(" ")}`); + } + if (opts.min > opts.max) showError(`min (${opts.min}) > max (${opts.max})`); + break; + } + case "normal": { + if (nums.length === 0) return; + if (nums.length === 1) { + opts.mean = nums[0]!; + } else if (nums.length === 2) { + opts.mean = nums[0]!; + opts.stddev = nums[1]!; + } else { + showError(`normal expects 0–2 args, got ${nums.length}: ${args.join(" ")}`); + } + if (opts.stddev <= 0) showError(`stddev must be > 0, got ${opts.stddev}`); + break; + } + case "binomial": { + if (nums.length === 0) return; + if (nums.length === 1) { + opts.trials = nums[0]!; + } else if (nums.length === 2) { + opts.trials = nums[0]!; + opts.prob = nums[1]!; + } else { + showError(`binomial expects 0–2 args, got ${nums.length}: ${args.join(" ")}`); + } + if (opts.trials < 0 || !Number.isInteger(opts.trials)) { + showError(`trials must be a non-negative integer, got ${opts.trials}`); + } + if (opts.prob < 0 || opts.prob > 1) showError(`prob must be 0–1, got ${opts.prob}`); + break; + } + case "poisson": { + if (nums.length === 0) return; + if (nums.length === 1) { + opts.lambda = nums[0]!; + } else { + showError(`poisson expects 0–1 arg, got ${nums.length}: ${args.join(" ")}`); + } + if (opts.lambda <= 0) showError(`lambda must be > 0, got ${opts.lambda}`); + break; + } + case "exponential": { + if (nums.length === 0) return; + if (nums.length === 1) { + opts.lambda = nums[0]!; + } else { + showError(`exponential expects 0–1 arg, got ${nums.length}: ${args.join(" ")}`); + } + if (opts.lambda <= 0) showError(`lambda must be > 0, got ${opts.lambda}`); + break; + } + case "hypergeometric": { + if (nums.length === 0) return; + if (nums.length >= 1) opts.popSize = nums[0]!; + if (nums.length >= 2) opts.successes = nums[1]!; + if (nums.length >= 3) opts.draws = nums[2]!; + if (nums.length > 3) { + showError(`hypergeometric expects 0–3 args, got ${nums.length}: ${args.join(" ")}`); + } + if (opts.popSize < 0 || !Number.isInteger(opts.popSize)) { + showError(`population size N must be a non-negative integer, got ${opts.popSize}`); + } + if (opts.successes < 0 || opts.successes > opts.popSize || !Number.isInteger(opts.successes)) { + showError(`successes K must be 0–N, got ${opts.successes} (N=${opts.popSize})`); + } + if (opts.draws < 0 || opts.draws > opts.popSize || !Number.isInteger(opts.draws)) { + showError(`draws n must be 0–N, got ${opts.draws} (N=${opts.popSize})`); + } + break; + } + } +} + +function parseNum(s: string): number { + const n = Number(s); + if (isNaN(n)) showError(`not a number: ${s}`); + return n; +} diff --git a/src/dist.ts b/src/dist.ts new file mode 100644 index 0000000..b8311b1 --- /dev/null +++ b/src/dist.ts @@ -0,0 +1,88 @@ +import { type Options } from "./types"; + +// --------------------------------------------------------------------------- +// Individual distribution samplers +// --------------------------------------------------------------------------- + +/** Box-Muller transform. Each call consumes 2 uniform randoms. */ +function normalRandom(mean: number, stddev: number): number { + let u1 = Math.random(); + while (u1 === 0) u1 = Math.random(); // avoid log(0) + const u2 = Math.random(); + return mean + stddev * Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2); +} + +/** Sum of Bernoulli trials. */ +function binomialRandom(n: number, p: number): number { + let s = 0; + for (let i = 0; i < n; i++) { + if (Math.random() < p) s++; + } + return s; +} + +/** Knuth's algorithm. */ +function poissonRandom(lambda: number): number { + const L = Math.exp(-lambda); + let k = 0; + let p = 1; + do { + k++; + p *= Math.random(); + } while (p > L); + return k - 1; +} + +/** Inverse CDF. */ +function exponentialRandom(lambda: number): number { + return -Math.log(Math.random()) / lambda; +} + +/** Urn model — simulate drawing without replacement. */ +function hypergeometricRandom(N: number, K: number, n: number): number { + let s = 0; + let remainingK = K; + let remainingTotal = N; + const draws = Math.min(n, N); + for (let i = 0; i < draws; i++) { + if (Math.random() < remainingK / remainingTotal) { + s++; + remainingK--; + } + remainingTotal--; + } + return s; +} + +// --------------------------------------------------------------------------- +// Dispatcher +// --------------------------------------------------------------------------- + +export function generate(opts: Options): number[] { + const results: number[] = []; + for (let i = 0; i < opts.count; i++) { + switch (opts.dist) { + case "uniform": + results.push(Math.random() * (opts.max - opts.min) + opts.min); + break; + case "normal": + results.push(normalRandom(opts.mean, opts.stddev)); + break; + case "binomial": + results.push(binomialRandom(opts.trials, opts.prob)); + break; + case "poisson": + results.push(poissonRandom(opts.lambda)); + break; + case "exponential": + results.push(exponentialRandom(opts.lambda)); + break; + case "hypergeometric": + results.push( + hypergeometricRandom(opts.popSize, opts.successes, opts.draws), + ); + break; + } + } + return results; +} diff --git a/src/help.ts b/src/help.ts new file mode 100644 index 0000000..ae82ec1 --- /dev/null +++ b/src/help.ts @@ -0,0 +1,35 @@ +export const HELP = `rand — Generate random numbers + +Usage: + rand [options] [args] + +Options: + -c, --count Number of random numbers to output (default: 1) + -f[N] Decimal places: -f = -f2 (default: 2) + --float [N] Long form of -f + -d, --dist Distribution (default: uniform) + -h, --help Show this help + +Distributions and their positional args: + uniform rand [min] [max] (default: 0 100) + normal rand -d normal [mean] [stddev] (default: 0 1) + binomial rand -d binomial [n] [p] (default: 10 0.5) + poisson rand -d poisson [lambda] (default: 1) + exponential rand -d exponential [lambda] (default: 1) + hypergeometric rand -d hypergeometric [N] [K] [n] + (default: 100 50 10) + +Examples: + rand # uniform 0–100 + rand -d normal # normal μ=0, σ=1 + rand -d normal 100 15 -f1 # normal μ=100, σ=15, 1 decimal + rand -d binomial 20 0.3 -c 5 # 5 binomial(n=20, p=0.3) + rand -d poisson 3 # poisson λ=3 + rand -d exponential 0.5 # exponential λ=0.5 + echo "5 2" | rand -d normal # stdin overrides positional args + rand | xargs echo # pipe output`; + +export function showHelp(): never { + console.log(HELP); + process.exit(0); +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..1e8a7ff --- /dev/null +++ b/src/main.ts @@ -0,0 +1,29 @@ +import { parseArgs, applyPositionals } from "./args"; +import { showHelp } from "./help"; +import { readStdin, parseStdinNumbers } from "./stdin"; +import { generate } from "./dist"; + +export async function main(): Promise { + const argv = Bun.argv.slice(2); + const { options, help } = parseArgs(argv); + if (help) showHelp(); + + // If stdin has data, parse numbers and apply as positional overrides + if (!process.stdin.isTTY) { + const input = await readStdin(); + const nums = parseStdinNumbers(input); + if (nums.length > 0) { + applyPositionals(options, nums.map(String)); + } + } + + const results = generate(options); + for (const r of results) { + console.log(options.decimals > 0 ? r.toFixed(options.decimals) : Math.floor(r)); + } +} + +main().catch((err: Error) => { + console.error(`rand: ${err.message}`); + process.exit(1); +}); diff --git a/src/stdin.ts b/src/stdin.ts new file mode 100644 index 0000000..8c48881 --- /dev/null +++ b/src/stdin.ts @@ -0,0 +1,23 @@ +export async function readStdin(): Promise { + const decoder = new TextDecoder(); + let buf = ""; + for await (const chunk of Bun.stdin.stream()) { + buf += decoder.decode(chunk, { stream: true }); + } + buf += decoder.decode(); // flush + return buf; +} + +/** Parse space-separated numbers from stdin. Returns number[] (may be empty). */ +export function parseStdinNumbers(input: string): number[] { + const trimmed = input.trim(); + if (!trimmed) return []; + const parts = trimmed.split(/\s+/); + const nums: number[] = []; + for (const p of parts) { + const n = Number(p); + if (isNaN(n)) return []; // unrecognizable → ignore stdin + nums.push(n); + } + return nums; +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..cd6b915 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,55 @@ +export type Dist = + | "uniform" + | "normal" + | "binomial" + | "poisson" + | "exponential" + | "hypergeometric"; + +export const DISTS: Dist[] = [ + "uniform", + "normal", + "binomial", + "poisson", + "exponential", + "hypergeometric", +]; + +export interface Options { + dist: Dist; + count: number; + decimals: number; + // uniform + min: number; + max: number; + // normal + mean: number; + stddev: number; + // binomial + trials: number; + prob: number; + // poisson + lambda: number; + // hypergeometric + popSize: number; + successes: number; + draws: number; +} + +export function defaultOptions(dist: Dist): Options { + return { + dist, + count: 1, + decimals: 0, + min: 0, + max: 100, + mean: 0, + stddev: 1, + trials: 10, + prob: 0.5, + lambda: 1, + popSize: 100, + successes: 50, + draws: 10, + }; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..b2e7497 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + "types": ["bun"], + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +}