prime8dev

prime8dev
git clone https://git.prime8.dev/prime8dev.git
Log | Files | Refs | README | LICENSE

shell-lang.js (7429B)


      1 // A small POSIX-ish command language for the web shell: quoting, escapes,
      2 // $VAR / ${VAR} / $? expansion, ~, globs, pipes, redirects and ; && ||.
      3 
      4 import { lookup, resolvePath, sortedChildren } from "./shell-fs.js";
      5 
      6 const OPS = ["2>&1", "2>>", "&&", "||", ">>", "2>", "|", ";", ">", "<", "&"];
      7 const BREAK = /[\s;|&<>]/;
      8 
      9 function escGlob(s) {
     10   return s.replace(/[\\*?[\]]/g, "\\$&");
     11 }
     12 
     13 // readVar leaves a \0name\0 marker instead of the value: variables expand
     14 // when each command runs, so `FOO=bar; echo $FOO` sees the new value.
     15 function readVar(line, i) {
     16   const nx = line[i + 1] ?? "";
     17   if (nx === "?" || nx === "$") return { v: `\0${nx}\0`, i: i + 2 };
     18   if (nx === "{") {
     19     const j = line.indexOf("}", i + 2);
     20     if (j < 0) return { v: "$", i: i + 1 };
     21     return { v: `\0${line.slice(i + 2, j)}\0`, i: j + 1 };
     22   }
     23   const m = /^[A-Za-z_][A-Za-z0-9_]*/.exec(line.slice(i + 1));
     24   if (!m) return { v: "$", i: i + 1 };
     25   return { v: `\0${m[0]}\0`, i: i + 1 + m[0].length };
     26 }
     27 
     28 function varValue(name, env) {
     29   if (name === "?") return String(env["?"] ?? 0);
     30   if (name === "$") return String(env.$$ ?? 4242);
     31   return env[name] ?? "";
     32 }
     33 
     34 // resolveWord substitutes variable markers using the current environment.
     35 export function resolveWord(w, env) {
     36   if (!w.v.includes("\0")) return w;
     37   const sub = (str, esc) => str.replace(/\0([^\0]*)\0/g, (_, raw) => {
     38       const val = varValue(raw.replace(/\\(.)/g, "$1"), env); // pat markers may be glob-escaped
     39       return esc ? escGlob(val) : val;
     40     });
     41   return { ...w, v: sub(w.v, false), pat: sub(w.pat, true) };
     42 }
     43 
     44 // tokenize returns {toks} or {error}. A word token is {w: {v, pat, glob, quoted}}:
     45 // v is the literal value, pat the same with quoted glob characters escaped.
     46 export function tokenize(line, env) {
     47   line = line.replace(/\0/g, "");
     48   const toks = [];
     49   const n = line.length;
     50   let i = 0;
     51   while (i < n) {
     52     const c = line[i];
     53     if (c === " " || c === "\t") {
     54       i++;
     55       continue;
     56     }
     57     if (c === "#") break;
     58     const op = OPS.find((o) => line.startsWith(o, i));
     59     if (op) {
     60       toks.push({ op });
     61       i += op.length;
     62       continue;
     63     }
     64     let v = "";
     65     let pat = "";
     66     let glob = false;
     67     let quoted = false;
     68     if (c === "~" && (i + 1 === n || line[i + 1] === "/" || BREAK.test(line[i + 1]))) {
     69       v += env.HOME;
     70       pat += escGlob(env.HOME);
     71       i++;
     72     }
     73     while (i < n) {
     74       const ch = line[i];
     75       if (BREAK.test(ch)) break;
     76       if (ch === "\\") {
     77         const nx = line[i + 1] ?? "";
     78         v += nx;
     79         pat += escGlob(nx);
     80         i += 2;
     81         quoted = true;
     82         continue;
     83       }
     84       if (ch === "'") {
     85         const j = line.indexOf("'", i + 1);
     86         if (j < 0) return { error: "unexpected EOF while looking for matching `''" };
     87         const s = line.slice(i + 1, j);
     88         v += s;
     89         pat += escGlob(s);
     90         i = j + 1;
     91         quoted = true;
     92         continue;
     93       }
     94       if (ch === '"') {
     95         i++;
     96         let s = "";
     97         while (i < n && line[i] !== '"') {
     98           if (line[i] === "\\" && /[$"\\`]/.test(line[i + 1] ?? "")) {
     99             s += line[i + 1];
    100             i += 2;
    101             continue;
    102           }
    103           if (line[i] === "$") {
    104             const r = readVar(line, i);
    105             s += r.v;
    106             i = r.i;
    107             continue;
    108           }
    109           s += line[i++];
    110         }
    111         if (i >= n) return { error: "unexpected EOF while looking for matching `\"'" };
    112         i++;
    113         v += s;
    114         pat += escGlob(s);
    115         quoted = true;
    116         continue;
    117       }
    118       if (ch === "$") {
    119         const r = readVar(line, i);
    120         v += r.v;
    121         pat += escGlob(r.v);
    122         i = r.i;
    123         continue;
    124       }
    125       if (ch === "*" || ch === "?" || ch === "[") glob = true;
    126       v += ch;
    127       pat += ch;
    128       i++;
    129     }
    130     toks.push({ w: { v, pat, glob, quoted } });
    131   }
    132   return { toks };
    133 }
    134 
    135 const REDIRS = new Set([">", ">>", "<", "2>", "2>>"]);
    136 
    137 // parse turns tokens into [{conn, pipeline: [{words, redirs}]}], conn being
    138 // the operator that joins an item to the one before it.
    139 export function parse(toks) {
    140   const list = [];
    141   let pipeline = [];
    142   let cmd = { words: [], redirs: [] };
    143   let conn = null;
    144   let dangling = false;
    145   const empty = () => cmd.words.length === 0 && cmd.redirs.length === 0;
    146   const fail = (tok) => ({ error: `syntax error near unexpected token \`${tok}'` });
    147 
    148   for (let k = 0; k < toks.length; k++) {
    149     const t = toks[k];
    150     if (t.w) {
    151       cmd.words.push(t.w);
    152       dangling = false;
    153       continue;
    154     }
    155     const op = t.op;
    156     if (REDIRS.has(op)) {
    157       const nx = toks[k + 1];
    158       if (!nx || !nx.w) return fail(nx ? nx.op : "newline");
    159       cmd.redirs.push({ op, target: nx.w });
    160       k++;
    161       dangling = false;
    162       continue;
    163     }
    164     if (op === "2>&1") {
    165       cmd.redirs.push({ op });
    166       continue;
    167     }
    168     if (op === "|") {
    169       if (empty()) return fail("|");
    170       pipeline.push(cmd);
    171       cmd = { words: [], redirs: [] };
    172       dangling = true;
    173       continue;
    174     }
    175     // ; & && ||
    176     if (empty()) return fail(op);
    177     pipeline.push(cmd);
    178     list.push({ conn, pipeline });
    179     pipeline = [];
    180     cmd = { words: [], redirs: [] };
    181     conn = op === "&" ? ";" : op;
    182     dangling = op === "&&" || op === "||";
    183   }
    184   if (!empty()) pipeline.push(cmd);
    185   if (dangling) return { error: "syntax error: unexpected end of file" };
    186   if (pipeline.length) list.push({ conn, pipeline });
    187   return { list };
    188 }
    189 
    190 function hasMeta(seg) {
    191   for (let i = 0; i < seg.length; i++) {
    192     if (seg[i] === "\\") i++;
    193     else if ("*?[".includes(seg[i])) return true;
    194   }
    195   return false;
    196 }
    197 
    198 function unescape(seg) {
    199   return seg.replace(/\\(.)/g, "$1");
    200 }
    201 
    202 function escRe(c) {
    203   return c.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
    204 }
    205 
    206 export function globRegex(pat, flags = "") {
    207   let re = "";
    208   for (let i = 0; i < pat.length; i++) {
    209     const c = pat[i];
    210     if (c === "\\") re += escRe(pat[++i] ?? "");
    211     else if (c === "*") re += "[^/]*";
    212     else if (c === "?") re += "[^/]";
    213     else if (c === "[") {
    214       const j = pat.indexOf("]", i + 2);
    215       if (j < 0) {
    216         re += "\\[";
    217         continue;
    218       }
    219       let cls = pat.slice(i + 1, j);
    220       if (cls[0] === "!") cls = "^" + cls.slice(1);
    221       re += "[" + cls.replace(/\\/g, "\\\\") + "]";
    222       i = j;
    223     } else re += escRe(c);
    224   }
    225   return new RegExp("^" + re + "$", flags);
    226 }
    227 
    228 // expandWord applies glob expansion against the fs; no match keeps the word.
    229 export function expandWord(w, root, home, cwd) {
    230   if (!w.glob) return [w.v];
    231   const absolute = w.pat.startsWith("/");
    232   const segs = w.pat.split("/").filter((s) => s !== "");
    233   let bases = [absolute ? "/" : ""];
    234   const join = (b, name) => (b === "" ? name : b === "/" ? "/" + name : b + "/" + name);
    235   for (const seg of segs) {
    236     const next = [];
    237     for (const b of bases) {
    238       if (!hasMeta(seg)) {
    239         next.push(join(b, unescape(seg)));
    240         continue;
    241       }
    242       const dir = lookup(root, resolvePath(home, cwd, b || "."));
    243       if (!dir || !dir.dir) continue;
    244       const re = globRegex(seg);
    245       for (const c of sortedChildren(dir)) {
    246         if (c.name.startsWith(".") && !seg.startsWith(".")) continue;
    247         if (re.test(c.name)) next.push(join(b, c.name));
    248       }
    249     }
    250     bases = next;
    251   }
    252   const found = bases.filter((p) => lookup(root, resolvePath(home, cwd, p)));
    253   return found.length ? found : [w.v];
    254 }