/** * TODO: * - Add logic for repeatable attribute */ function log(text) { console.log(JSON.stringify(text, null, 2)); } function join(...rest) { return rest.filter((v) => !!v).join("."); } function takeOne(list) { return list.shift(); } function takeAll(list) { const all = list.slice(); list.length = 0; return all; } function map(key, cases, options = { error: true }) { const DEFAULT = "_"; const hasKey = key != null && cases.hasOwnProperty(key); const hasDefault = cases.hasOwnProperty(DEFAULT); if (!hasKey && !hasDefault) { if (options.error) { throw new Error(`Invalid map key "${key}". Valid keys: ${Object.keys(cases).join(", ")}`); } return null; } return cases[hasKey ? key : DEFAULT]; } function tokenize(input) { const args = input.trim().split(/\s+/); const root = args.shift(); const tokens = [`root::${root}`]; while (args.length) { const token = takeOne(args); // Rest if (token === "--") { tokens.push(`rest::${takeAll(args).join(" ")}`); break; } // Named Argument + Value: Option or Flag (long variant) if (token.startsWith("--")) { const [key, value] = token.slice(2).split("="); tokens.push(`narg::${key}`); if (value) tokens.push(`value::${value}`); continue; } // Named Argument + Value: Option or Flag (short variant) if (token.startsWith("-")) { const [flags, value] = token.slice(1).split("="); tokens.push(...flags.split("").map((f) => `narg::${f}`)); if (value) tokens.push(`value::${value}`); continue; } // Unknown: Command, Positional or Value tokens.push(`unk::${token}`); } return tokens; } function parseInput(spec, input) { const tokens = tokenize(input); const s = {}; const a = {}; let _id = 100; let level = -1; let pos = []; let cmds = []; function generateSchema() { for (const entry of spec) { const elements = entry.split(";"); const type = elements.shift(); map(type, { command: () => { const id = String(_id++); const aliases = elements.shift().split(","); const help = elements.shift(); // Schema s[join(id, "entryType")] = "command"; s[join(id, "name")] = aliases[0]; s[join(id, "help")] = help; s[join(id, "args")] = ""; // Alias if (cmds.length === 0) { a[`cmd::root`] = id; } else { aliases.forEach((alias) => (a[join(cmds.at(-1), `cmd::${alias}`)] = id)); } // Control level += 1; cmds.push(id); pos.push(0); }, end: () => { level -= 1; cmds.pop(); pos.pop(); }, argument: () => { const id = String(_id++); const aliases = elements.shift().split(","); const name = aliases[0]; // Attributes const entry = { name, dest: name, required: false, type: "positional" }; for (const element of elements) { const [key, value = true] = element.split(":"); entry[key === "default" ? "value" : key] = value; } // Schema s[join(id, "entryType")] = "argument"; Object.entries(entry).forEach(([key, value]) => { s[join(id, key)] = value; }); // Register to command const cmdArgs = join(cmds.at(-1), "args"); s[cmdArgs] += s[cmdArgs] ? `,${id}` : id; // Alias map(entry.type, { positional: () => [`pos::${pos[level]++}`], rest: () => ["rest"], option: () => aliases.map((a) => `narg::${a}`), flag: () => aliases.map((a) => `narg::${a}`), })().forEach((alias) => (a[join(cmds.at(-1), alias)] = id)); }, _: () => { throw new Error(`Invalid entry type: "${type}"`); }, })(); } level = 0; pos = []; cmds = []; } generateSchema(); // log({ s, a }); while (tokens.length > 0) { const taggedToken = takeOne(tokens); const [tokenTag, token] = taggedToken.split("::"); // Bubble up arguments -> Loop over all command levels starting by the latest let found = false; for (level = Math.max(0, cmds.length - 1); level >= 0; level--) { const entryId = map(tokenTag, { root: () => a["cmd::root"], rest: () => a[join(cmds[level], "rest")], narg: () => a[join(cmds[level], taggedToken)], unk: () => { // check: Command let id = a[join(cmds[level], `cmd::${token}`)]; // check: Positionl. Valid only on last command level if (!id && level === pos.length - 1) { id = a[join(cmds[level], `pos::${pos[level]}`)]; if (id) pos[level]++; } return id; }, })(); // Try with parent command if (!entryId) continue; // Add const entryType = s[join(entryId, "entryType")]; map(entryType, { command: () => { cmds.push(entryId); pos.push(0); if (tokenTag === "root") { s[join(entryId, "name")] = token; } }, argument: () => { const attrType = s[join(entryId, "type")]; const value = map(attrType, { rest: () => token, positional: () => token, flag: () => true, option: () => { const nTaggedToken = tokens[0]; if (!nTaggedToken) { throw new Error(`No value provided for option argument: "${token}"`); } const [nTokenTag, nToken] = nTaggedToken.split("::"); if (nTokenTag !== "value" && nTokenTag !== "unk") { throw new Error( `Expected option argument, but got: "${nToken}" (${nTokenTag}). Skipping...`, ); } tokens.shift(); return nToken; }, })(); s[join(entryId, "value")] = value; }, })(); found = true; break; } if (!found) { console.log(`Invalid argument: "${token}" (${tokenTag}). Skipping...`); } } // Set values and check required const commands = []; const values = {}; for (const cmdId of cmds) { let cmdName = s[join(cmdId, "name")]; let cmdArgs = s[join(cmdId, "args")]; if (!cmdArgs) continue; for (const argId of cmdArgs.split(",")) { const name = s[join(argId, "name")]; const dest = s[join(argId, "dest")]; const value = s[join(argId, "value")]; const required = s[join(argId, "required")]; if (value == null && required) { throw new Error(`Argument "${name}" is required in command "${cmdName}"`); } if (values[dest] != null) { values[`${cmdName}_${dest}`] = value; } else { values[dest] = value; } } commands.push(cmdName); } log({ commands, values }); return values; } function parseInputWrap(spec, input) { try { parseInput(spec, input); } catch (error) { console.error(`Error: ${error.message}`); } } const SPEC = [ "command;barg;Barg - Bash Argument Parser", "argument;global;type:flag", "command;build;Build a dev container", "argument;from;type:option;dest:fromName;help:Name of the base container", "argument;name;type:positional;required;help:Name of the container", "argument;nametwo;help:Name of the container", "argument;image,i;type:option;required;dest:imageName;help:Base image", "argument;include,I;type:option;repeatable;dest:includePaths;help:Include paths", "argument;verbose,v;type:flag;repeatable;default:0;help:Increase verbosity (-v, -vv, ...)", "argument;quiet,q;type:flag;default:false;help:Suppress all output", "argument;cmd;type:rest;help:Command and args to run inside the container", "argument;name;dest:nameOpt;type:option;help:Building container name", "command;container;Container building", "argument;dev;type:flag;help:Is dev or not", "argument;name;required;help:Building container name", "argument;cmd;type:rest;dest:cmdRest;help:Command and args to run inside the container", "end", "end", "command;stop;Stop a dev container", "argument;name;type:positional;required;help:Name of the container", "argument;kill,k;type:flag;default:false;help:Force kill the container", "end", "end", ]; const INPUTS = [ "dev build -i myimage buildtwo --from base-dev --image node:18 --include src --include test -v -v -q mycontainer -- npm run start", "dev build buildname container -i myimage --dev --name webapp externalContainer", "dev --global build --image debian mybox container --dev --name webapp externalContainer -- echo hi", "dev stop mycontainer --kill", "dev stop mycontainer --kill -- not specified one", "dev build --from alpine --image node:20 mybox -- echo hi", "dev build --aaaaa --from alpine --image node:20 --bbbbb orb mybox ccccc -- echo hi", ]; INPUTS.forEach((input) => { console.log("\n\n------------------------\n"); console.log("> ", input); parseInputWrap(SPEC, input); });