diff --git a/barg.js b/barg.js index 59b483c..bd8bb35 100644 --- a/barg.js +++ b/barg.js @@ -11,296 +11,297 @@ function join(...rest) { return rest.filter((v) => !!v).join("."); } -function take_one(list) { +function takeOne(list) { return list.shift(); } -function take_all(list) { - const all = [...list]; +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 raw_tokens = input.trim().split(/\s+/).slice(1); // skip command - const tokens = []; + const args = input.trim().split(/\s+/); + const root = args.shift(); + const tokens = [`root::${root}`]; - while (raw_tokens.length) { - const raw_token = take_one(raw_tokens); + while (args.length) { + const token = takeOne(args); - if (raw_token === "--") { - tokens.push(`rest::${take_all(raw_tokens).join(" ")}`); + // Rest + if (token === "--") { + tokens.push(`rest::${takeAll(args).join(" ")}`); break; } - let key; - let value; - - if (raw_token.startsWith("--")) { - [key, value] = raw_token.slice(2).split("="); + // Named Argument + Value: Option or Flag (long variant) + if (token.startsWith("--")) { + const [key, value] = token.slice(2).split("="); tokens.push(`narg::${key}`); - } else if (raw_token.startsWith("-")) { - [key, value] = raw_token.slice(1).split("="); - tokens.push(...key.split("").map((k) => `narg::${k}`)); - } else { - tokens.push(`unk::${raw_token}`); + if (value) tokens.push(`value::${value}`); + continue; } - if (value !== undefined) { - tokens.push(`value::${value}`); + // 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 parse_input(spec, input) { +function parseInput(spec, input) { const tokens = tokenize(input); + const s = {}; + const a = {}; - const schema = {}; - const alias_map = {}; + let _id = 100; + let level = -1; + let pos = []; + let cmds = []; - let position = [0]; - let level = 0; - let commands = []; - - function ref(...rest) { - return commands.slice(0, level).concat(rest).join("."); - } - - function get_entry_ref(key) { - return alias_map[ref(key)]; - } - - function get_entry_item(...keys) { - return schema[join(...keys)]; - } - - function generate_schema() { + function generateSchema() { for (const entry of spec) { const elements = entry.split(";"); const type = elements.shift(); - switch (type) { - case "command": { + map(type, { + command: () => { + const id = String(_id++); const aliases = elements.shift().split(","); - const name = aliases[0]; const help = elements.shift(); // Schema - schema[ref(name, "type")] = "command"; - schema[ref(name, "help")] = help; + s[join(id, "entryType")] = "command"; + s[join(id, "name")] = aliases[0]; + s[join(id, "help")] = help; + s[join(id, "args")] = ""; // Alias - for (const alias of aliases) { - alias_map[ref(`unk::${alias}`)] = ref(name); + if (cmds.length === 0) { + a[`cmd::root`] = id; + } else { + aliases.forEach((alias) => (a[join(cmds.at(-1), `cmd::${alias}`)] = id)); } // Control - commands.push(name); level += 1; - position.push(0); - break; - } - case "end": { + cmds.push(id); + pos.push(0); + }, + end: () => { level -= 1; - commands.pop(); - position.pop(); - break; - } - case "argument": { - let aliases = elements.shift().split(","); - let name = aliases[0]; + cmds.pop(); + pos.pop(); + }, + argument: () => { + const id = String(_id++); + const aliases = elements.shift().split(","); + const name = aliases[0]; - const entry = { name, dest: name, required: false, kind: "positional" }; + // Attributes + const entry = { name, dest: name, required: false, type: "positional" }; for (const element of elements) { - const [attribute, attr_value] = element.split(":"); - - if (attribute === "required") entry.required = true; - else if (attribute === "repeatable") entry.repeatable = true; - else if (attribute === "help") entry.help = attr_value; - else if (attribute === "dest") entry.dest = attr_value; - else if (attribute === "type") entry.kind = attr_value; - else if (attribute === "default") entry.value = attr_value; - } - - // Alias - if (entry.kind === "positional") { - name = String(position[level]++); - aliases = [`pos::${name}`]; - } else if (entry.kind === "rest") { - aliases = ["__rest"]; - } - - for (const alias of aliases) { - if (entry.kind === "option" || entry.kind === "flag") { - alias_map[ref(`narg::${alias}`)] = ref(name); - } else { - alias_map[ref(alias)] = ref(name); - } + const [key, value = true] = element.split(":"); + entry[key === "default" ? "value" : key] = value; } // Schema - schema[ref(name, "type")] = "argument"; - for (const [key, value] of Object.entries(entry)) { - schema[ref(name, key)] = value; - } + s[join(id, "entryType")] = "argument"; + Object.entries(entry).forEach(([key, value]) => { + s[join(id, key)] = value; + }); - // Register arg to command - if (schema[ref("args")]) { - schema[ref("args")] += `,${name}`; - } else { - schema[ref("args")] = `${name}`; - } + // Register to command + const cmdArgs = join(cmds.at(-1), "args"); + s[cmdArgs] += s[cmdArgs] ? `,${id}` : id; - break; - } - default: - throw new Error(`Invalid entry type: '${type}'`); - } + // 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}"`); + }, + })(); } - commands = []; level = 0; - position = [0]; + pos = []; + cmds = []; } - function next_token_value() { - const token = take_one(tokens); - const [token_type, token_key] = token.split("::"); - if (token_type !== "unk") { - throw new Error(`Expected option value, but got: ${token_type}. Token: '${token_key}'`); - } - return token_key; - } - - generate_schema(); - log({ schema, alias_map }); + generateSchema(); + // log({ s, a }); while (tokens.length > 0) { - const token = take_one(tokens); - const [token_type, token_key] = token.split("::"); + 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 = position.length - 1; level >= 0; level--) { - let entry_ref = get_entry_ref(token); + 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 for rest argument - if (token_type === "rest") { - entry_ref = get_entry_ref("__rest"); - } + // 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]++; + } - if (token_type === "narg") { - entry_ref = get_entry_ref(token); - } - - if (!entry_ref && level === position.length - 1 && token_type === "unk") { - // Positional arguments are valid only for the latest command - entry_ref = get_entry_ref(`pos::${position[level]}`); - if (entry_ref) { - position[level] += 1; - } - } + return id; + }, + })(); // Try with parent command - if (!entry_ref) { - continue; - } + if (!entryId) continue; - const entry_type = get_entry_item(entry_ref, "type"); - if (entry_type === "command") { - commands.push(token_key); - position.push(0); - } else if (entry_type === "argument") { - const kind = get_entry_item(entry_ref, "kind"); - - let value; - if (kind === "flag") value = true; - else if (kind === "option") value = next_token_value(); - else if (kind === "positional") value = token_key; - else if (kind === "rest") value = token_key; - else throw new Error(`Invalid attribute type: '${kind}'`); - - schema[join(entry_ref, "value")] = value; - } + // 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(`> Skipping invalid argument: (${token_type}) '${token_key}'`); + console.log(`Invalid argument: "${token}" (${tokenTag}). Skipping...`); } } - // Check required + // Set values and check required + const commands = []; const values = {}; - let id = ""; - for (const command of commands) { - id = join(id, command); - let command_args = schema[join(id, "args")]; - if (!command_args) { - continue; - } + for (const cmdId of cmds) { + let cmdName = s[join(cmdId, "name")]; + let cmdArgs = s[join(cmdId, "args")]; + if (!cmdArgs) continue; - for (const arg of command_args.split(",")) { - const name = schema[join(id, arg, "name")]; - const dest = schema[join(id, arg, "dest")]; - const value = schema[join(id, arg, "value")]; - const required = schema[join(id, arg, "required")]; + 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 === undefined && required) { - throw new Error(`Argument '${name}' is required in command '${command}'`); + if (value == null && required) { + throw new Error(`Argument "${name}" is required in command "${cmdName}"`); } - if (values[dest] !== undefined) { - values[`${command}_${dest}`] = value; + if (values[dest] != null) { + values[`${cmdName}_${dest}`] = value; } else { values[dest] = value; } } + commands.push(cmdName); } log({ commands, values }); return values; } -function parse_input_wrap(spec, input) { +function parseInputWrap(spec, input) { try { - parse_input(spec, input); + 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:from_name;help:Name of the base 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:image_name;help:Base image", - "argument;include,I;type:option;repeatable;dest:include_paths;help:Include paths", + "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:name_opt;type:option;help:Building container name", + "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:cmd_rest;help:Command and args to run inside the container", + "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 build --image debian mybox container --dev --name webapp externalContainer -- echo hi", + "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", @@ -310,5 +311,5 @@ const INPUTS = [ INPUTS.forEach((input) => { console.log("\n\n------------------------\n"); console.log("> ", input); - parse_input_wrap(SPEC, input); + parseInputWrap(SPEC, input); });