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 }