buninu 0.4.17 → 0.4.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -283,25 +283,23 @@ export function tokenize(source, options = {}) {
283
283
  while (i < source.length && source[i] !== "\n") i++;
284
284
  continue;
285
285
  }
286
-
287
286
  const two = source.slice(i, i + 2);
288
- const fdDup = source.slice(i, i + 4);
287
+ const fdDup = /^(\d+>&\d+)/.exec(source.slice(i))?.[1] ?? "";
289
288
  const operatorAhead =
290
289
  ["|", ";", "<", ">"].includes(ch) ||
291
290
  two === "&&" ||
292
291
  two === "||" ||
293
292
  two === "2>" ||
294
- fdDup === "1>&2" ||
295
- fdDup === "2>&1";
293
+ Boolean(fdDup);
296
294
  if (current && operatorAhead) {
297
295
  finishWord();
298
296
  continue;
299
297
  }
300
298
  // A descriptor duplication begins with the shorter `2>` redirect token,
301
299
  // so it must win before ordinary file redirects are considered.
302
- if (!current && (fdDup === "1>&2" || fdDup === "2>&1")) {
300
+ if (!current && fdDup) {
303
301
  tokens.push({ type: "op", value: fdDup, offset: i });
304
- i += 4;
302
+ i += fdDup.length;
305
303
  atWordBoundary = true;
306
304
  continue;
307
305
  }
@@ -364,7 +362,7 @@ export function tokenize(source, options = {}) {
364
362
  }
365
363
  if (source[i] === "\\" && i + 1 < source.length) {
366
364
  const next = source[i + 1];
367
- if (next === "$" || next === '"' || next === "\\") {
365
+ if (next === "$" || next === "`" || next === '"' || next === "\\") {
368
366
  text += `\u0000${next}`;
369
367
  i += 2;
370
368
  continue;
@@ -419,8 +417,7 @@ export function tokenize(source, options = {}) {
419
417
  source.slice(i, i + 2) === "&&" ||
420
418
  source.slice(i, i + 2) === "||" ||
421
419
  source.slice(i, i + 2) === "2>" ||
422
- source.slice(i, i + 4) === "1>&2" ||
423
- source.slice(i, i + 4) === "2>&1";
420
+ /^\d+>&\d+/.test(source.slice(i));
424
421
  if (isSpace(c) || c === "\n" || c === "'" || c === '"' || c === "\\" || maybeOp)
425
422
  break;
426
423
  text += c;
@@ -448,6 +445,7 @@ function emptyCommand() {
448
445
  }
449
446
 
450
447
  export function parse(source) {
448
+ source = normalizeSource(source);
451
449
  const tokens = tokenize(source);
452
450
  const jobs = [];
453
451
  let i = 0;
@@ -466,7 +464,7 @@ export function parse(source) {
466
464
  const token = tokens[i];
467
465
  if (token.type === "op" && ["|", ";", "&&", "||"].includes(token.value))
468
466
  break;
469
- if (token.type === "op" && ["1>&2", "2>&1"].includes(token.value)) {
467
+ if (token.type === "op" && /^\d+>&\d+$/.test(token.value)) {
470
468
  command.redirects.push({ op: token.value, target: null });
471
469
  i++;
472
470
  continue;
@@ -513,8 +511,8 @@ export function parse(source) {
513
511
  throw new ShellSyntaxError("! requires a command", firstWord.offset);
514
512
  negate = true;
515
513
  }
516
- jobs.push({ connector, pipeline, negate });
517
514
  const separator = tokens[i]?.value;
515
+ jobs.push({ connector, pipeline, negate, background: false });
518
516
  if (!separator) break;
519
517
  if (![";", "&&", "||"].includes(separator))
520
518
  throw new ShellSyntaxError(`unexpected operator ${separator}`, tokens[i].offset);
@@ -523,6 +521,20 @@ export function parse(source) {
523
521
  skipSemicolons();
524
522
  if (i >= tokens.length) break;
525
523
  }
524
+ const lastJob = jobs.at(-1);
525
+ const lastCommand = lastJob?.pipeline.at(-1);
526
+ const lastWord = lastCommand?.words.at(-1);
527
+ const lastToken = tokens.findLast((token) =>
528
+ token.type !== "op" || token.value !== ";" || source[token.offset] !== "\n");
529
+ if (lastWord === lastToken &&
530
+ lastWord?.fragments.length === 1 &&
531
+ lastWord.fragments[0].quote === "none" &&
532
+ lastWord.fragments[0].text === "&") {
533
+ lastCommand.words.pop();
534
+ if (lastCommand.words.length === 0 && lastCommand.redirects.length === 0)
535
+ throw new ShellSyntaxError("expected a command", lastWord.offset);
536
+ lastJob.background = true;
537
+ }
526
538
  return jobs;
527
539
  }
528
540
 
@@ -604,6 +616,66 @@ function globPatternRegex(pattern, anchoredStart, anchoredEnd, greedy) {
604
616
  return new RegExp(`${anchoredStart ? "^" : ""}${source}${anchoredEnd ? "$" : ""}`);
605
617
  }
606
618
 
619
+ async function casePatternRegex(pattern, state) {
620
+ let marked = "";
621
+ const literal = (value) => {
622
+ for (const ch of value) marked += `\u0000${ch}`;
623
+ };
624
+ for (let i = 0; i < pattern.length;) {
625
+ if (pattern[i] === "\\") {
626
+ if (i + 1 < pattern.length) literal(pattern[i + 1]);
627
+ i += 2;
628
+ continue;
629
+ }
630
+ if (pattern[i] === "'") {
631
+ const end = pattern.indexOf("'", i + 1);
632
+ if (end < 0) throw new ShellSyntaxError("unterminated single quote in case pattern");
633
+ literal(pattern.slice(i + 1, end));
634
+ i = end + 1;
635
+ continue;
636
+ }
637
+ if (pattern[i] === '"') {
638
+ let end = i + 1;
639
+ while (end < pattern.length && pattern[end] !== '"') {
640
+ if (pattern[end] === "\\") end++;
641
+ end++;
642
+ }
643
+ if (end >= pattern.length) throw new ShellSyntaxError("unterminated double quote in case pattern");
644
+ literal(stripExpansionMarkers(await expandText(pattern.slice(i + 1, end), state)));
645
+ i = end + 1;
646
+ continue;
647
+ }
648
+ let end = i;
649
+ while (end < pattern.length && !["\\", "'", '"'].includes(pattern[end])) end++;
650
+ marked += stripExpansionMarkers(await expandText(pattern.slice(i, end), state));
651
+ i = end;
652
+ }
653
+ let source = "";
654
+ for (let i = 0; i < marked.length; i++) {
655
+ if (marked[i] === "\u0000") {
656
+ source += (marked[++i] ?? "").replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
657
+ } else if (marked[i] === "*") source += ".*";
658
+ else if (marked[i] === "?") source += ".";
659
+ else if (marked[i] === "[") {
660
+ let end = i + 1;
661
+ if (marked[end] === "!" || marked[end] === "^") end++;
662
+ if (marked[end] === "]") end++;
663
+ while (end < marked.length && marked[end] !== "]") end++;
664
+ if (end >= marked.length) source += "\\[";
665
+ else {
666
+ let body = marked.slice(i + 1, end);
667
+ const negate = body[0] === "!" || body[0] === "^";
668
+ if (negate) body = body.slice(1);
669
+ body = body.replaceAll("\\", "\\\\");
670
+ source += `[${negate ? "^" : ""}${body}]`;
671
+ i = end;
672
+ }
673
+ }
674
+ else source += marked[i].replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
675
+ }
676
+ return new RegExp(`^${source}$`);
677
+ }
678
+
607
679
  async function parameterExpansion(body, state) {
608
680
  if (/^[!%][A-Za-z_][A-Za-z0-9_]*$/.test(body)) {
609
681
  const value = parameterValue(body.slice(1), state);
@@ -1223,33 +1295,37 @@ const builtins = {
1223
1295
  const format = argv[1];
1224
1296
  let ai = 2;
1225
1297
  let output = "";
1226
- for (let i = 0; i < format.length; i++) {
1227
- if (format[i] === "\\" && i + 1 < format.length) {
1228
- const parsed = parsePrintEscapes(format.slice(i, i + 2));
1229
- output += parsed.text;
1230
- i++;
1231
- continue;
1232
- }
1233
- if (format[i] !== "%") {
1234
- output += format[i];
1235
- continue;
1236
- }
1237
- if (format[i + 1] === "%") {
1238
- output += "%";
1239
- i++;
1240
- continue;
1298
+ let previous;
1299
+ do {
1300
+ previous = ai;
1301
+ for (let i = 0; i < format.length; i++) {
1302
+ if (format[i] === "\\" && i + 1 < format.length) {
1303
+ const parsed = parsePrintEscapes(format.slice(i, i + 2));
1304
+ output += parsed.text;
1305
+ i++;
1306
+ continue;
1307
+ }
1308
+ if (format[i] !== "%") {
1309
+ output += format[i];
1310
+ continue;
1311
+ }
1312
+ if (format[i + 1] === "%") {
1313
+ output += "%";
1314
+ i++;
1315
+ continue;
1316
+ }
1317
+ const match = /^%([0-9]*)([sdi])/.exec(format.slice(i));
1318
+ if (!match) return result(2, "", `bunmsh: printf: unsupported format near ${format.slice(i)}\n`);
1319
+ const width = Number(match[1] || 0);
1320
+ const kind = match[2];
1321
+ const value = argv[ai++] ?? (kind === "s" ? "" : "0");
1322
+ let rendered = kind === "s" ? value : String(Number.parseInt(value, 10) || 0);
1323
+ if (width > rendered.length)
1324
+ rendered = (match[1]?.startsWith("0") ? "0" : " ").repeat(width - rendered.length) + rendered;
1325
+ output += rendered;
1326
+ i += match[0].length - 1;
1241
1327
  }
1242
- const match = /^%([0-9]*)([sdi])/.exec(format.slice(i));
1243
- if (!match) return result(2, "", `bunmsh: printf: unsupported format near ${format.slice(i)}\n`);
1244
- const width = Number(match[1] || 0);
1245
- const kind = match[2];
1246
- const value = argv[ai++] ?? (kind === "s" ? "" : "0");
1247
- let rendered = kind === "s" ? value : String(Number.parseInt(value, 10) || 0);
1248
- if (width > rendered.length)
1249
- rendered = (match[1]?.startsWith("0") ? "0" : " ").repeat(width - rendered.length) + rendered;
1250
- output += rendered;
1251
- i += match[0].length - 1;
1252
- }
1328
+ } while (ai > previous && ai < argv.length);
1253
1329
  return result(0, output);
1254
1330
  },
1255
1331
  read: async (argv, state, input) => {
@@ -1821,11 +1897,19 @@ async function runHeadFallback(argv, state, input) {
1821
1897
  return result(1, "", "bunmsh: head: invalid line count\n");
1822
1898
  const operands = argv.slice(i);
1823
1899
  if (operands.length) {
1824
- try {
1825
- const data = await readFallbackFiles(operands, state, input);
1826
- if (bytesMode) return result(0, data.slice(0, count));
1827
- return result(0, decoder.decode(data).split(/(?<=\n)/).slice(0, count).join(""));
1828
- } catch (error) { return result(1, "", `bunmsh: head: ${error.message}\n`); }
1900
+ const output = [];
1901
+ let status = 0, stderr = "";
1902
+ for (const operand of operands) {
1903
+ if (operands.length > 1)
1904
+ output.push(`${output.length ? "\n" : ""}==> ${operand} <==\n`);
1905
+ try {
1906
+ const data = await readFallbackFiles([operand], state, input);
1907
+ output.push(bytesMode
1908
+ ? data.slice(0, count)
1909
+ : decoder.decode(data).split(/(?<=\n)/).slice(0, count).join(""));
1910
+ } catch (error) { status = 1; stderr += `bunmsh: head: ${operand}: ${error.message}\n`; }
1911
+ }
1912
+ return result(status, concatBytes(output), stderr);
1829
1913
  }
1830
1914
  const reader = fallbackInput(input).getReader();
1831
1915
  const output = [];
@@ -1873,19 +1957,32 @@ async function runTextFilter(argv, state, input, kind) {
1873
1957
  reverse ||= option.includes("r"); numeric ||= option.includes("n"); unique ||= option.includes("u");
1874
1958
  }
1875
1959
  }
1876
- const text = decoder.decode(await readFallbackFiles(args, state, input));
1877
- let lines = text.split("\n");
1878
- const trailing = lines.at(-1) === "";
1879
- if (trailing) lines.pop();
1880
1960
  if (kind === "tail") {
1881
- lines = tailFromStart
1882
- ? lines.slice(Math.max(0, tailCount - 1))
1883
- : lines.slice(-tailCount);
1884
- } else {
1885
- lines.sort((a, b) => numeric ? Number(a) - Number(b) : a.localeCompare(b));
1886
- if (unique) lines = lines.filter((line, index) => index === 0 || line !== lines[index - 1]);
1887
- if (reverse) lines.reverse();
1961
+ const operands = args.length ? args : ["-"];
1962
+ const output = [];
1963
+ let status = 0, stderr = "";
1964
+ for (const operand of operands) {
1965
+ try {
1966
+ const text = decoder.decode(await readFallbackFiles([operand], state, input));
1967
+ const lines = text.split("\n");
1968
+ const trailing = lines.at(-1) === "";
1969
+ if (trailing) lines.pop();
1970
+ const selected = tailFromStart
1971
+ ? lines.slice(Math.max(0, tailCount - 1))
1972
+ : tailCount === 0 ? [] : lines.slice(-tailCount);
1973
+ if (operands.length > 1 && (tailFromStart || tailCount !== 0))
1974
+ output.push(`${output.length ? "\n" : ""}==> ${operand === "-" ? "standard input" : operand} <==\n`);
1975
+ if (selected.length) output.push(`${selected.join("\n")}${trailing ? "\n" : ""}`);
1976
+ } catch (error) { status = 1; stderr += `bunmsh: tail: ${operand}: ${error.message}\n`; }
1977
+ }
1978
+ return result(status, output.join(""), stderr);
1888
1979
  }
1980
+ const text = decoder.decode(await readFallbackFiles(args, state, input));
1981
+ let lines = text.split("\n");
1982
+ if (lines.at(-1) === "") lines.pop();
1983
+ lines.sort((a, b) => numeric ? Number(a) - Number(b) : a.localeCompare(b));
1984
+ if (unique) lines = lines.filter((line, index) => index === 0 || line !== lines[index - 1]);
1985
+ if (reverse) lines.reverse();
1889
1986
  return result(0, lines.length ? `${lines.join("\n")}\n` : "");
1890
1987
  } catch (error) { return result(1, "", `bunmsh: ${kind}: ${error.message}\n`); }
1891
1988
  }
@@ -1894,15 +1991,27 @@ async function runWcFallback(argv, state, input) {
1894
1991
  let flags = "", i = 1;
1895
1992
  while (/^-[lwc]+$/.test(argv[i] ?? "")) flags += argv[i++].slice(1);
1896
1993
  if (!flags) flags = "lwc";
1897
- try {
1898
- const data = await readFallbackFiles(argv.slice(i), state, input);
1994
+ const operands = argv.slice(i);
1995
+ const files = operands.length ? operands : ["-"];
1996
+ const rows = [];
1997
+ let status = 0, stderr = "";
1998
+ let totalBytes = 0;
1999
+ for (const operand of files) try {
2000
+ const data = await readFallbackFiles([operand], state, input);
1899
2001
  const text = decoder.decode(data);
1900
2002
  const values = [];
1901
2003
  if (flags.includes("l")) values.push((text.match(/\n/g) ?? []).length);
1902
2004
  if (flags.includes("w")) values.push(text.trim() ? text.trim().split(/\s+/).length : 0);
1903
2005
  if (flags.includes("c")) values.push(data.byteLength);
1904
- return result(0, `${values.join(" ")}\n`);
1905
- } catch (error) { return result(1, "", `bunmsh: wc: ${error.message}\n`); }
2006
+ totalBytes += data.byteLength;
2007
+ rows.push({ values, operand });
2008
+ } catch (error) { status = 1; stderr += `bunmsh: wc: ${operand}: ${error.message}\n`; }
2009
+ if (operands.length > 1 && rows.length)
2010
+ rows.push({ values: rows.reduce((total, row) => total.map((n, index) => n + row.values[index]),
2011
+ rows[0].values.map(() => 0)), operand: "total" });
2012
+ const width = operands.length > 1 ? String(totalBytes).length : 0;
2013
+ return result(status, rows.map(({ values, operand }) =>
2014
+ `${values.map((value) => String(value).padStart(width)).join(" ")}${operands.length ? ` ${operand}` : ""}\n`).join(""), stderr);
1906
2015
  }
1907
2016
 
1908
2017
  function expandTrSet(value) {
@@ -2007,11 +2116,14 @@ async function runTeeFallback(argv, state, input) {
2007
2116
 
2008
2117
  async function runHashFallback(argv, state, input, algorithm) {
2009
2118
  const operands = argv.slice(1);
2010
- try {
2011
- const data = await readFallbackFiles(operands, state, input);
2119
+ const output = [];
2120
+ let status = 0, stderr = "";
2121
+ for (const operand of operands.length ? operands : ["-"]) try {
2122
+ const data = await readFallbackFiles([operand], state, input);
2012
2123
  const digest = createHash(algorithm).update(data).digest("hex");
2013
- return result(0, `${digest}${operands.length === 1 && operands[0] !== "-" ? ` ${operands[0]}` : ""}\n`);
2014
- } catch (error) { return result(1, "", `bunmsh: ${algorithm}sum: ${error.message}\n`); }
2124
+ output.push(`${digest} ${operand}\n`);
2125
+ } catch (error) { status = 1; stderr += `bunmsh: ${algorithm}sum: ${operand}: ${error.message}\n`; }
2126
+ return result(status, output.join(""), stderr);
2015
2127
  }
2016
2128
 
2017
2129
  function runMktempFallback(argv, state) {
@@ -2173,6 +2285,8 @@ async function runGrepFallback(argv, state, input, context = {}) {
2173
2285
  };
2174
2286
 
2175
2287
  const operands = argv.slice(i);
2288
+ const implicitCwd = !operands.length && flags.includes("r");
2289
+ if (implicitCwd) operands.push(".");
2176
2290
  if (operands.length) {
2177
2291
  try {
2178
2292
  const files = grepFiles(operands, state, flags.includes("r"));
@@ -2181,7 +2295,8 @@ async function runGrepFallback(argv, state, input, context = {}) {
2181
2295
  const lines = (await Bun.file(file.path).text()).split("\n");
2182
2296
  if (lines.at(-1) === "") lines.pop();
2183
2297
  for (let line = 0; line < lines.length; line++)
2184
- if (processLine(lines[line], line + 1, file.shown, showLabel)) return result(0);
2298
+ if (processLine(lines[line], line + 1,
2299
+ implicitCwd ? file.shown.replace(/^\.\//, "") : file.shown, showLabel)) return result(0);
2185
2300
  }
2186
2301
  } catch (error) { return result(2, "", `bunmsh: grep: ${error.message}\n`); }
2187
2302
  } else {
@@ -2341,20 +2456,26 @@ function cutCharacterRanges(spec) {
2341
2456
  return ranges;
2342
2457
  }
2343
2458
 
2344
- async function runCutFallback(argv, _state, input) {
2459
+ async function runCutFallback(argv, state, input) {
2345
2460
  let spec, i = 1;
2346
2461
  if (argv[i] === "-c") spec = argv[++i], i++;
2347
2462
  else if (argv[i]?.startsWith("-c")) spec = argv[i++].slice(2);
2348
- if (!spec || i !== argv.length) return result(1, "", "bunmsh: cut: usage: cut -c LIST\n");
2463
+ if (!spec) return result(1, "", "bunmsh: cut: usage: cut -c LIST [FILE ...]\n");
2349
2464
  try {
2350
2465
  const ranges = cutCharacterRanges(spec);
2351
- const text = decoder.decode(await readFallbackInput(input));
2352
- const trailing = text.endsWith("\n");
2353
- const lines = text.split("\n");
2354
- if (trailing) lines.pop();
2355
- const output = lines.map((line) => [...line].filter((_ch, index) =>
2356
- ranges.some(([start, end]) => index + 1 >= start && index + 1 <= end)).join("")).join("\n");
2357
- return result(0, output + (trailing ? "\n" : ""));
2466
+ const output = [];
2467
+ let status = 0, stderr = "";
2468
+ const operands = argv.slice(i);
2469
+ for (const operand of operands.length ? operands : ["-"]) try {
2470
+ const text = decoder.decode(await readFallbackFiles([operand], state, input));
2471
+ const trailing = text.endsWith("\n");
2472
+ const lines = text.split("\n");
2473
+ if (trailing) lines.pop();
2474
+ output.push(lines.map((line) => [...line].filter((_ch, index) =>
2475
+ ranges.some(([start, end]) => index + 1 >= start && index + 1 <= end)).join("")).join("\n") +
2476
+ (trailing ? "\n" : ""));
2477
+ } catch (error) { status = 1; stderr += `bunmsh: cut: ${operand}: ${error.message}\n`; }
2478
+ return result(status, output.join(""), stderr);
2358
2479
  } catch (error) { return result(1, "", `bunmsh: cut: ${error.message}\n`); }
2359
2480
  }
2360
2481
 
@@ -2366,24 +2487,27 @@ function runLnFallback(argv, state) {
2366
2487
  noTargetDirectory ||= argv[i].includes("T");
2367
2488
  i++;
2368
2489
  }
2369
- if (argv.length - i !== 2) return result(1, "", "bunmsh: ln: usage: ln [-sf] target link_name\n");
2370
- const target = nativePath(argv[i]);
2371
- let link = isAbsolute(nativePath(argv[i + 1]))
2372
- ? nativePath(argv[i + 1])
2373
- : resolvePath(nativePath(state.cwd), nativePath(argv[i + 1]));
2374
- try {
2375
- if (!noTargetDirectory) {
2376
- try {
2377
- if (statSync(link).isDirectory()) link = resolvePath(link, pathBasename(target));
2378
- } catch {}
2379
- }
2380
- if (force) try { unlinkSync(link); } catch {}
2381
- if (symbolic) symlinkSync(target, link); else {
2382
- const source = isAbsolute(target) ? target : resolvePath(nativePath(state.cwd), target);
2383
- linkSync(source, link);
2384
- }
2385
- return result();
2386
- } catch (error) { return result(1, "", `bunmsh: ln: ${error.message}\n`); }
2490
+ if (argv.length - i < 2) return result(1, "", "bunmsh: ln: usage: ln [-sf] target... link_name\n");
2491
+ const sources = argv.slice(i, -1);
2492
+ const destination = nativePath(argv.at(-1));
2493
+ const targetPath = isAbsolute(destination) ? destination : resolvePath(nativePath(state.cwd), destination);
2494
+ let directory = false;
2495
+ if (!noTargetDirectory) try { directory = statSync(targetPath).isDirectory(); } catch {}
2496
+ if (sources.length > 1 && !directory)
2497
+ return result(1, "", "bunmsh: ln: target is not a directory\n");
2498
+ let status = 0, stderr = "";
2499
+ for (const operand of sources) {
2500
+ const target = nativePath(operand);
2501
+ const link = directory ? resolvePath(targetPath, pathBasename(target)) : targetPath;
2502
+ try {
2503
+ if (force) try { unlinkSync(link); } catch {}
2504
+ if (symbolic) symlinkSync(target, link); else {
2505
+ const source = isAbsolute(target) ? target : resolvePath(nativePath(state.cwd), target);
2506
+ linkSync(source, link);
2507
+ }
2508
+ } catch (error) { status = 1; stderr += `bunmsh: ln: ${operand}: ${error.message}\n`; }
2509
+ }
2510
+ return result(status, "", stderr);
2387
2511
  }
2388
2512
 
2389
2513
  function runChmodFallback(argv, state) {
@@ -2859,6 +2983,9 @@ function compoundLogicalSource(source) {
2859
2983
  breaks.add(token.offset);
2860
2984
  if (["elif", "else", "fi", "done", "esac"].includes(word))
2861
2985
  breaks.add(token.offset);
2986
+ if (["fi", "done", "esac"].includes(word) && tokens[index + 1]?.value === ";" &&
2987
+ tokens[index + 2] && tokens[index + 2].value !== ";")
2988
+ breaks.add(tokens[index + 2].offset);
2862
2989
  if (["then", "do", "else"].includes(word) && tokens[index + 1])
2863
2990
  breaks.add(tokens[index + 1].offset);
2864
2991
  commandStart = ["if", "while", "until", "elif", "then", "do", "else"].includes(word);
@@ -2870,14 +2997,12 @@ function compoundLogicalSource(source) {
2870
2997
  return logical;
2871
2998
  }
2872
2999
 
2873
- // A leaf command line inside a compound body (if/while/for/case) is scanned
2874
- // one physical line at a time, but a here-document's body lives on the lines
2875
- // that follow it. Grow the line by re-tokenizing with more lines appended
2876
- // until the heredoc(s) it opens are fully resolved, so the folded lines never
2877
- // get misread as separate commands or as clauses of the surrounding compound.
2878
- function growHeredocLine(lines, index) {
3000
+ // A leaf command inside a compound body may continue across physical lines
3001
+ // inside quotes, substitutions, or a here-document. Keep those lines together
3002
+ // until mksh-style lexical tokenization says the command is complete, so
3003
+ // keywords inside a multiline word are never mistaken for compound syntax.
3004
+ function growLogicalCommand(lines, index) {
2879
3005
  const line = lines[index];
2880
- if (!line.includes("<<")) return { source: line, next: index + 1 };
2881
3006
  let end = index;
2882
3007
  let candidate = line;
2883
3008
  while (true) {
@@ -2886,7 +3011,7 @@ function growHeredocLine(lines, index) {
2886
3011
  return { source: candidate, next: end + 1 };
2887
3012
  } catch (error) {
2888
3013
  if (!(error instanceof ShellSyntaxError) ||
2889
- !error.message.startsWith("unterminated here-document"))
3014
+ !/^(?:unterminated (?:here-document|single quote|double quote|command substitution|substitution))/.test(error.message))
2890
3015
  throw error;
2891
3016
  end++;
2892
3017
  if (end >= lines.length) throw error;
@@ -2895,7 +3020,199 @@ function growHeredocLine(lines, index) {
2895
3020
  }
2896
3021
  }
2897
3022
 
2898
- function parseCompoundScript(source) {
3023
+ function makeCompoundPostfix(compound, suffix, source, nextLine) {
3024
+ const match = /^\s*(\|\||&&|\||;)\s*(.*)$/.exec(suffix ?? "");
3025
+ if (!match) return { node: compound, nextLine };
3026
+ let command = match[2].trim();
3027
+ while (!command && nextLine < source.length) {
3028
+ command = source[nextLine++].trim();
3029
+ if (!command || command.startsWith("#")) command = "";
3030
+ }
3031
+ if (!command)
3032
+ throw new ShellSyntaxError(`compound command requires a command after ${match[1]}`);
3033
+ return {
3034
+ node: {
3035
+ type: "compoundPostfix",
3036
+ compound,
3037
+ operator: match[1],
3038
+ command,
3039
+ },
3040
+ nextLine,
3041
+ };
3042
+ }
3043
+
3044
+ function findShellKeyword(source, keyword) {
3045
+ if (source.trimStart().startsWith("#")) return -1;
3046
+ let quote = null;
3047
+ for (let i = 0; i < source.length;) {
3048
+ const ch = source[i];
3049
+ if (quote === "'") {
3050
+ if (ch === "'") quote = null;
3051
+ i++;
3052
+ continue;
3053
+ }
3054
+ if (quote === '"') {
3055
+ if (ch === "\\" && /[\\"$`\n]/.test(source[i + 1] ?? "")) { i += 2; continue; }
3056
+ if (ch === '"') quote = null;
3057
+ i++;
3058
+ continue;
3059
+ }
3060
+ if (ch === "\\") { i += 2; continue; }
3061
+ if (ch === "'" || ch === '"') { quote = ch; i++; continue; }
3062
+ if (ch === "`" || (ch === "$" && source[i + 1] === "(")) {
3063
+ try { i = substitutionEnd(source, i); }
3064
+ catch { return -1; } // This physical line may continue the substitution.
3065
+ continue;
3066
+ }
3067
+ if (ch === "$" && source[i + 1] === "{") {
3068
+ try { i = parameterEnd(source, i); }
3069
+ catch { return -1; }
3070
+ continue;
3071
+ }
3072
+ if (/[A-Za-z_]/.test(ch)) {
3073
+ const start = i++;
3074
+ while (/[A-Za-z0-9_]/.test(source[i] ?? "")) i++;
3075
+ if (source.slice(start, i) === keyword) {
3076
+ let previous = start - 1;
3077
+ while (previous >= 0 && /\s/.test(source[previous])) previous--;
3078
+ if (keyword !== "esac" || previous < 0 || source[previous] === ";" || source[previous] === "\n")
3079
+ return start;
3080
+ }
3081
+ continue;
3082
+ }
3083
+ i++;
3084
+ }
3085
+ return -1;
3086
+ }
3087
+
3088
+ function splitCasePatterns(source) {
3089
+ const patterns = [];
3090
+ let start = 0;
3091
+ let quote = null;
3092
+ for (let i = 0; i < source.length; i++) {
3093
+ const ch = source[i];
3094
+ if (ch === "\\") { i++; continue; }
3095
+ if (quote) {
3096
+ if (ch === quote) quote = null;
3097
+ continue;
3098
+ }
3099
+ if (ch === "'" || ch === '"') { quote = ch; continue; }
3100
+ if (ch === "$" && (source[i + 1] === "(" || source[i + 1] === "{")) {
3101
+ i = (source[i + 1] === "(" ? substitutionEnd(source, i) : parameterEnd(source, i)) - 1;
3102
+ continue;
3103
+ }
3104
+ if (ch === "`") { i = substitutionEnd(source, i) - 1; continue; }
3105
+ if (ch === "|") { patterns.push(source.slice(start, i).trim()); start = i + 1; }
3106
+ }
3107
+ patterns.push(source.slice(start).trim());
3108
+ return patterns;
3109
+ }
3110
+
3111
+ // Scan case clauses in the same lexical spirit as mksh's CASEPAT/KEYWORD
3112
+ // modes. In particular, clause boundaries are shell tokens, not line
3113
+ // boundaries: Autoconf commonly writes an entire clause on one line.
3114
+ function parseCaseArms(source) {
3115
+ const arms = [];
3116
+ let i = 0;
3117
+ const skipSpace = () => {
3118
+ while (/\s/.test(source[i] ?? "")) i++;
3119
+ };
3120
+ while (i < source.length) {
3121
+ skipSpace();
3122
+ if (i >= source.length) break;
3123
+ if (source[i] === "#") {
3124
+ while (i < source.length && source[i] !== "\n") i++;
3125
+ continue;
3126
+ }
3127
+ if (source[i] === "(") { i++; skipSpace(); }
3128
+ const patternStart = i;
3129
+ let quote = null;
3130
+ let extensionDepth = 0;
3131
+ for (; i < source.length; i++) {
3132
+ const ch = source[i];
3133
+ if (ch === "\\") { i++; continue; }
3134
+ if (quote) {
3135
+ if (ch === quote) quote = null;
3136
+ continue;
3137
+ }
3138
+ if (ch === "'" || ch === '"') { quote = ch; continue; }
3139
+ if (ch === "$" && (source[i + 1] === "(" || source[i + 1] === "{")) {
3140
+ i = (source[i + 1] === "(" ? substitutionEnd(source, i) : parameterEnd(source, i)) - 1;
3141
+ continue;
3142
+ }
3143
+ if (ch === "`") { i = substitutionEnd(source, i) - 1; continue; }
3144
+ if (ch === "(" && /[?*+@!]/.test(source[i - 1] ?? "")) { extensionDepth++; continue; }
3145
+ if (ch === ")" && extensionDepth) { extensionDepth--; continue; }
3146
+ if (ch === ")") break;
3147
+ }
3148
+ if (i >= source.length)
3149
+ throw new ShellSyntaxError(`case pattern requires ): ${source.slice(patternStart, Math.min(source.length, patternStart + 120))}`);
3150
+ const patterns = splitCasePatterns(source.slice(patternStart, i));
3151
+ if (!patterns.length || patterns.some((pattern) => !pattern))
3152
+ throw new ShellSyntaxError("case requires a pattern");
3153
+ i++;
3154
+
3155
+ const bodyStart = i;
3156
+ quote = null;
3157
+ let nestedCases = 0;
3158
+ let word = "";
3159
+ let terminator = null;
3160
+ const finishWord = () => {
3161
+ if (word === "case") nestedCases++;
3162
+ else if (word === "esac" && nestedCases) nestedCases--;
3163
+ word = "";
3164
+ };
3165
+ for (; i < source.length; i++) {
3166
+ const ch = source[i];
3167
+ if (ch === "#" && !word && (i === bodyStart || /[\s;|&]/.test(source[i - 1] ?? ""))) {
3168
+ while (i < source.length && source[i] !== "\n") i++;
3169
+ continue;
3170
+ }
3171
+ if (ch === "\\") { word = ""; i++; continue; }
3172
+ if (quote) {
3173
+ if (ch === quote) quote = null;
3174
+ continue;
3175
+ }
3176
+ if (ch === "'" || ch === '"') { finishWord(); quote = ch; continue; }
3177
+ if (ch === "$" && (source[i + 1] === "(" || source[i + 1] === "{")) {
3178
+ finishWord();
3179
+ i = (source[i + 1] === "(" ? substitutionEnd(source, i) : parameterEnd(source, i)) - 1;
3180
+ continue;
3181
+ }
3182
+ if (ch === "`") {
3183
+ finishWord();
3184
+ try { i = substitutionEnd(source, i) - 1; }
3185
+ catch (error) { error.message += ` near ${JSON.stringify(source.slice(Math.max(0, i - 100), i + 180))}`; throw error; }
3186
+ continue;
3187
+ }
3188
+ if (/[A-Za-z_]/.test(ch)) { word += ch; continue; }
3189
+ finishWord();
3190
+ if (!nestedCases && ch === ";" && [";", "&", "|"].includes(source[i + 1])) {
3191
+ terminator = source.slice(i, i + 2);
3192
+ break;
3193
+ }
3194
+ }
3195
+ // POSIX allows the final clause to omit `;;` immediately before esac.
3196
+ if (!terminator) {
3197
+ if (source.slice(bodyStart, i).trim() || arms.length) terminator = ";;";
3198
+ else throw new ShellSyntaxError("unterminated case clause");
3199
+ }
3200
+ const bodySource = source.slice(bodyStart, i).trim();
3201
+ let body;
3202
+ try {
3203
+ body = parseCompoundScript(bodySource);
3204
+ } catch (error) {
3205
+ if (error instanceof ShellSyntaxError)
3206
+ error.message += ` in case arm ${patterns.join("|")}`;
3207
+ throw error;
3208
+ }
3209
+ arms.push({ patterns, body, terminator });
3210
+ if (i < source.length) i += 2;
3211
+ }
3212
+ return arms;
3213
+ }
3214
+
3215
+ export function parseCompoundScript(source) {
2899
3216
  const lines = compoundLogicalSource(source.replace(/\\\r?\n/g, "")).split(/\r?\n/);
2900
3217
  const parseSequence = (start, stops = []) => {
2901
3218
  const nodes = [];
@@ -2923,102 +3240,163 @@ function parseCompoundScript(source) {
2923
3240
  }
2924
3241
 
2925
3242
  if (/^if(?:\s|$)/.test(trimmed)) {
3243
+ const compoundStart = i;
2926
3244
  const branches = [];
2927
3245
  let header = trimmed;
2928
- while (!/;\s*then\s*$/.test(header) && ++i < lines.length)
3246
+ while (!/(?:;\s*|\s+)then(?:\s+.*)?$/.test(header) && ++i < lines.length)
2929
3247
  header += ` ${lines[i].trim()}`;
2930
- if (!/;\s*then\s*$/.test(header)) throw new ShellSyntaxError("if requires then");
2931
- let condition = header.replace(/^if\s*/, "").replace(/;\s*then\s*$/, "");
3248
+ let then = /(?:;\s*|\s+)then(?:\s+(.*))?$/.exec(header);
3249
+ if (!then) throw new ShellSyntaxError("if requires then");
3250
+ let condition = header.slice(0, then.index).replace(/^if\s*/, "").trim();
3251
+ let thenCommand = then[1]?.trim() ?? "";
2932
3252
  i++;
2933
3253
  while (true) {
2934
- const body = parseSequence(i, [/^elif(?:\s|$)/, /^else\s*;?$/, /^fi\s*;?$/]);
2935
- branches.push({ condition, body: body.nodes });
3254
+ const body = parseSequence(i, [/^elif(?:\s|$)/, /^else\s*;?$/, /^fi\b/]);
3255
+ branches.push({ condition, body: body.nodes, thenCommand });
2936
3256
  i = body.index;
2937
3257
  const stop = lines[i]?.trim() ?? "";
2938
3258
  if (/^elif(?:\s|$)/.test(stop)) {
2939
3259
  header = stop;
2940
- while (!/;\s*then\s*$/.test(header) && ++i < lines.length)
3260
+ while (!/(?:;\s*|\s+)then(?:\s+.*)?$/.test(header) && ++i < lines.length)
2941
3261
  header += ` ${lines[i].trim()}`;
2942
- if (!/;\s*then\s*$/.test(header)) throw new ShellSyntaxError("elif requires then");
2943
- condition = header.replace(/^elif\s*/, "").replace(/;\s*then\s*$/, "");
3262
+ then = /(?:;\s*|\s+)then(?:\s+(.*))?$/.exec(header);
3263
+ if (!then) throw new ShellSyntaxError("elif requires then");
3264
+ condition = header.slice(0, then.index).replace(/^elif\s*/, "").trim();
3265
+ thenCommand = then[1]?.trim() ?? "";
2944
3266
  i++;
2945
3267
  continue;
2946
3268
  }
2947
3269
  let otherwise = [];
2948
3270
  if (/^else\s*;?$/.test(stop)) {
2949
- const body = parseSequence(i + 1, [/^fi\s*;?$/]);
3271
+ const body = parseSequence(i + 1, [/^fi\b/]);
2950
3272
  otherwise = body.nodes;
2951
3273
  i = body.index;
2952
3274
  }
2953
- if (!/^fi\s*;?$/.test(lines[i]?.trim() ?? ""))
3275
+ if (!/^fi\b/.test(lines[i]?.trim() ?? ""))
2954
3276
  throw new ShellSyntaxError("unterminated if");
2955
- nodes.push({ type: "if", branches, otherwise });
2956
- i++;
3277
+ const compound = { type: "if", branches, otherwise };
3278
+ const close = /^fi\b(.*)$/.exec(lines[i].trim());
3279
+ const result = makeCompoundPostfix(
3280
+ { ...compound, source: [...lines.slice(compoundStart, i), "fi"].join("\n") },
3281
+ close?.[1] ?? "",
3282
+ lines,
3283
+ i + 1,
3284
+ );
3285
+ nodes.push(result.node);
3286
+ i = result.nextLine;
2957
3287
  break;
2958
3288
  }
2959
3289
  continue;
2960
3290
  }
2961
3291
  if (/^(?:while|until)(?:\s|$)/.test(trimmed)) {
3292
+ const compoundStart = i;
2962
3293
  const kind = trimmed.startsWith("until") ? "until" : "while";
2963
3294
  let header = trimmed;
2964
- while (!/;\s*do\s*$/.test(header) && ++i < lines.length)
3295
+ while (!/(?:;\s*|\s+)do\s*$/.test(header) && ++i < lines.length)
2965
3296
  header += ` ${lines[i].trim()}`;
2966
- if (!/;\s*do\s*$/.test(header)) throw new ShellSyntaxError(`${kind} requires do`);
2967
- const condition = header.replace(new RegExp(`^${kind}\\s*`), "").replace(/;\s*do\s*$/, "");
2968
- const body = parseSequence(i + 1, [/^done(?:\s*<\s*.+)?\s*;?$/]);
2969
- const done = /^done(?:\s*<\s*(.+?))?\s*;?$/.exec(lines[body.index]?.trim() ?? "");
3297
+ if (!/(?:;\s*|\s+)do\s*$/.test(header)) throw new ShellSyntaxError(`${kind} requires do`);
3298
+ const condition = header.replace(new RegExp(`^${kind}\\s*`), "").replace(/(?:;\s*|\s+)do\s*$/, "");
3299
+ const body = parseSequence(i + 1, [/^done\b/]);
3300
+ const done = /^done(?:\s*<\s*(.+?))?\s*;?\s*((?:\|\||&&|\||;).*)?$/.exec(lines[body.index]?.trim() ?? "");
2970
3301
  if (!done)
2971
3302
  throw new ShellSyntaxError(`unterminated ${kind}`);
2972
- nodes.push({ type: "loop", kind, condition, body: body.nodes, input: done[1] ?? null });
2973
- i = body.index + 1;
3303
+ const compound = {
3304
+ type: "loop", kind, condition, body: body.nodes, input: done[1] ?? null,
3305
+ source: [...lines.slice(compoundStart, body.index), `done${done[1] ? ` < ${done[1]}` : ""}`].join("\n"),
3306
+ };
3307
+ const result = makeCompoundPostfix(compound, done[2] ?? "", lines, body.index + 1);
3308
+ nodes.push(result.node);
3309
+ i = result.nextLine;
2974
3310
  continue;
2975
3311
  }
2976
3312
  if (/^for(?:\s|$)/.test(trimmed)) {
3313
+ const compoundStart = i;
2977
3314
  let header = trimmed;
2978
- while (!/;\s*do\s*$/.test(header) && ++i < lines.length)
3315
+ let bodyStart = -1;
3316
+ while (i < lines.length) {
3317
+ if (/;\s*do\s*$/.test(header)) {
3318
+ header = header.replace(/;\s*do\s*$/, "");
3319
+ bodyStart = i + 1;
3320
+ break;
3321
+ }
3322
+ if (lines[i + 1]?.trim() === "do") {
3323
+ bodyStart = i + 2;
3324
+ i++;
3325
+ break;
3326
+ }
3327
+ if (++i >= lines.length) break;
2979
3328
  header += ` ${lines[i].trim()}`;
2980
- const match = /^for\s+([A-Za-z_][A-Za-z0-9_]*)(?:\s+in\s+(.*?))?\s*;\s*do\s*$/.exec(header);
3329
+ }
3330
+ const match = /^for\s+([A-Za-z_][A-Za-z0-9_]*)(?:\s+in(?:\s+(.*?))?)?\s*;?$/.exec(header);
2981
3331
  if (!match) throw new ShellSyntaxError("invalid for command");
2982
- const body = parseSequence(i + 1, [/^done\s*;?$/]);
2983
- if (!/^done\s*;?$/.test(lines[body.index]?.trim() ?? ""))
2984
- throw new ShellSyntaxError("unterminated for");
2985
- nodes.push({ type: "for", name: match[1], words: match[2] ?? null, body: body.nodes });
2986
- i = body.index + 1;
3332
+ if (bodyStart < 0) throw new ShellSyntaxError("for requires do");
3333
+ const body = parseSequence(bodyStart, [/^done\b/]);
3334
+ if (!/^done\b/.test(lines[body.index]?.trim() ?? ""))
3335
+ throw new ShellSyntaxError(`unterminated for: ${lines[compoundStart]?.trim()} (stopped at ${lines[body.index]?.trim()})`);
3336
+ const compound = {
3337
+ type: "for", name: match[1], words: match[2] ?? null, body: body.nodes,
3338
+ source: [...lines.slice(compoundStart, body.index), "done"].join("\n"),
3339
+ };
3340
+ const close = /^done\b(.*)$/.exec(lines[body.index].trim());
3341
+ const result = makeCompoundPostfix(compound, close?.[1] ?? "", lines, body.index + 1);
3342
+ nodes.push(result.node);
3343
+ i = result.nextLine;
2987
3344
  continue;
2988
3345
  }
2989
3346
  if (/^case(?:\s|$)/.test(trimmed)) {
3347
+ const compoundStart = i;
2990
3348
  let header = trimmed;
2991
- while (!/\s+in\s*$/.test(header) && ++i < lines.length)
3349
+ while (!/\s+in(?:\s|$)/.test(header) && ++i < lines.length)
2992
3350
  header += ` ${lines[i].trim()}`;
2993
- const match = /^case\s+(.+?)\s+in\s*$/.exec(header);
3351
+ const match = /^case\s+(.+?)\s+in(?:\s+(.*))?$/.exec(header);
2994
3352
  if (!match) throw new ShellSyntaxError("case requires in");
2995
- const arms = [];
3353
+ const inlineEsac = findShellKeyword(match[2] ?? "", "esac");
3354
+ if (inlineEsac >= 0) {
3355
+ const inlineArms = match[2].slice(0, inlineEsac);
3356
+ const suffix = match[2].slice(inlineEsac + "esac".length);
3357
+ const compound = {
3358
+ type: "case",
3359
+ word: match[1],
3360
+ arms: parseCaseArms(inlineArms),
3361
+ source: `case ${match[1]} in\n${inlineArms}\nesac`,
3362
+ };
3363
+ const result = makeCompoundPostfix(compound, suffix, lines, i + 1);
3364
+ nodes.push(result.node);
3365
+ i = result.nextLine;
3366
+ continue;
3367
+ }
3368
+ const bodyLines = match[2] ? [match[2]] : [];
2996
3369
  i++;
2997
- while (i < lines.length && !/^esac\s*;?$/.test(lines[i].trim())) {
2998
- if (!lines[i].trim() || lines[i].trim().startsWith("#")) { i++; continue; }
2999
- const arm = /^(.+?)\)\s*$/.exec(lines[i].trim());
3000
- if (!arm) throw new ShellSyntaxError("case pattern requires )");
3001
- const bodyLines = [];
3002
- i++;
3003
- while (i < lines.length && !/^esac\s*;?$/.test(lines[i].trim())) {
3004
- const current = lines[i];
3005
- const terminator = current.indexOf(";;");
3006
- if (terminator >= 0) {
3007
- if (current.slice(0, terminator).trim()) bodyLines.push(current.slice(0, terminator));
3008
- i++;
3009
- break;
3010
- }
3011
- bodyLines.push(current);
3012
- i++;
3370
+ let nestedCases = 0;
3371
+ while (i < lines.length) {
3372
+ const current = lines[i].trim();
3373
+ const nestedCase = findShellKeyword(current, "case");
3374
+ if (nestedCase >= 0 && /\s/.test(current[nestedCase + "case".length] ?? ""))
3375
+ nestedCases++;
3376
+ // In an enclosing case arm, mksh tokenises `esac;;` as two
3377
+ // independent tokens: ESAC followed by the enclosing arm's BREAK.
3378
+ // Count that as the end of the nested case while retaining the
3379
+ // whole line for parseCaseArms to assign the `;;` to its parent.
3380
+ const closingCase = findShellKeyword(current, "esac");
3381
+ if (closingCase >= 0) {
3382
+ if (!nestedCases) break;
3383
+ nestedCases--;
3013
3384
  }
3014
- arms.push({ patterns: arm[1].split("|").map((pattern) => pattern.trim()), body: parseCompoundScript(bodyLines.join("\n")) });
3385
+ bodyLines.push(lines[i++]);
3015
3386
  }
3016
- if (!/^esac\s*;?$/.test(lines[i]?.trim() ?? "")) throw new ShellSyntaxError("unterminated case");
3017
- nodes.push({ type: "case", word: match[1], arms });
3018
- i++;
3387
+ if (!/^esac\b/.test(lines[i]?.trim() ?? ""))
3388
+ throw new ShellSyntaxError(`unterminated case: ${trimmed} (at ${lines[i]?.trim() ?? "EOF"})`);
3389
+ const compound = {
3390
+ type: "case", word: match[1], arms: parseCaseArms(bodyLines.join("\n")),
3391
+ source: [...lines.slice(compoundStart, i), "esac"].join("\n"),
3392
+ };
3393
+ const close = /^esac\b(.*)$/.exec(lines[i].trim());
3394
+ const result = makeCompoundPostfix(compound, close?.[1] ?? "", lines, i + 1);
3395
+ nodes.push(result.node);
3396
+ i = result.nextLine;
3019
3397
  continue;
3020
3398
  }
3021
- const grown = growHeredocLine(lines, i);
3399
+ const grown = growLogicalCommand(lines, i);
3022
3400
  nodes.push({ type: "command", source: grown.source });
3023
3401
  i = grown.next;
3024
3402
  }
@@ -3069,6 +3447,61 @@ async function executeNodeList(nodes, state, options = {}) {
3069
3447
  };
3070
3448
  for (const node of nodes) {
3071
3449
  if (state.exitRequested) break;
3450
+ if (node.type === "compoundPostfix") {
3451
+ if (node.operator === "|") {
3452
+ const link = new TransformStream();
3453
+ const leftPromise = runExternal(
3454
+ pipelineCompoundArgv(node.compound.source),
3455
+ pipelineBuiltinState(state),
3456
+ options.input ?? null,
3457
+ false,
3458
+ true,
3459
+ link.writable,
3460
+ { pipelineKillSignal: "SIGTERM" },
3461
+ );
3462
+ const rightState = {
3463
+ ...state,
3464
+ env: { ...state.env },
3465
+ aliases: { ...state.aliases },
3466
+ functions: { ...state.functions },
3467
+ readonly: new Set(state.readonly),
3468
+ directoryHistory: [...state.directoryHistory],
3469
+ tabs: [...state.tabs],
3470
+ exitRequested: false,
3471
+ exitStatus: 0,
3472
+ };
3473
+ const right = await runExternal(
3474
+ pipelineCompoundArgv(node.command),
3475
+ pipelineBuiltinState(rightState),
3476
+ link.readable,
3477
+ true,
3478
+ true,
3479
+ );
3480
+ const left = await leftPromise;
3481
+ execution = result(right.status, right.stdout, concatBytes([left.stderr, right.stderr]));
3482
+ } else {
3483
+ execution = await executeNodeList([node.compound], state, { capture });
3484
+ append(execution);
3485
+ const shouldRun = node.operator === "&&"
3486
+ ? execution.status === 0
3487
+ : node.operator === "||"
3488
+ ? execution.status !== 0
3489
+ : true;
3490
+ if (shouldRun) {
3491
+ execution = await execute(node.command, state, { capture });
3492
+ append(execution);
3493
+ }
3494
+ state.lastStatus = execution.status;
3495
+ continue;
3496
+ }
3497
+ state.lastStatus = execution.status;
3498
+ if (capture) append(execution);
3499
+ else {
3500
+ if (execution.stdout.byteLength) stdout.push(execution.stdout);
3501
+ if (execution.stderr.byteLength) stderr.push(execution.stderr);
3502
+ }
3503
+ continue;
3504
+ }
3072
3505
  if (node.type === "function") {
3073
3506
  state.functions[node.name] = node.body;
3074
3507
  continue;
@@ -3152,29 +3585,39 @@ async function executeNodeList(nodes, state, options = {}) {
3152
3585
  const parsed = parse(`__case ${node.word}`)[0]?.pipeline[0];
3153
3586
  const value = (await expandWord(parsed.words[1], state, { single: true }))[0] ?? "";
3154
3587
  execution = result();
3588
+ let fallThrough = false;
3155
3589
  for (const arm of node.arms) {
3156
- let matches = false;
3157
- for (let pattern of arm.patterns) {
3158
- pattern = pattern.replace(/^(['"])(.*)\1$/, "$2");
3159
- pattern = stripExpansionMarkers(await expandText(pattern, state));
3160
- if (globPatternRegex(pattern, true, true, true).test(value)) { matches = true; break; }
3590
+ let matches = fallThrough;
3591
+ if (!matches) for (const pattern of arm.patterns) {
3592
+ if ((await casePatternRegex(pattern, state)).test(value)) { matches = true; break; }
3161
3593
  }
3162
3594
  if (!matches) continue;
3163
3595
  execution = await executeNodeList(arm.body, state, { capture });
3164
3596
  append(execution);
3597
+ if (arm.terminator === ";&") { fallThrough = true; continue; }
3598
+ if (arm.terminator === ";|") { fallThrough = false; continue; }
3165
3599
  break;
3166
3600
  }
3167
3601
  state.lastStatus = execution.status;
3168
3602
  continue;
3169
3603
  }
3170
3604
  let selected = null;
3605
+ let selectedThen = "";
3171
3606
  for (const branch of node.branches) {
3172
3607
  let condition = branch.condition.trim();
3173
3608
  let negate = false;
3174
3609
  while (/^!\s+/.test(condition)) { negate = !negate; condition = condition.replace(/^!\s+/, ""); }
3175
3610
  const checked = await execute(condition, state, { capture, simple: true });
3176
3611
  append(checked);
3177
- if ((checked.status === 0) !== negate) { selected = branch.body; break; }
3612
+ if ((checked.status === 0) !== negate) {
3613
+ selected = branch.body;
3614
+ selectedThen = branch.thenCommand ?? "";
3615
+ break;
3616
+ }
3617
+ }
3618
+ if (selectedThen) {
3619
+ const initial = await execute(selectedThen, state, { capture });
3620
+ append(initial);
3178
3621
  }
3179
3622
  execution = selected
3180
3623
  ? await executeNodeList(selected, state, { capture })
@@ -3210,15 +3653,22 @@ async function runExternal(
3210
3653
  cmd: spawnArgv,
3211
3654
  cwd: nativePath(state.cwd),
3212
3655
  env: state.env,
3213
- stdin: input === null ? "inherit" : input,
3214
- stdout: captureStdout || stdoutSink ? "pipe" : "inherit",
3215
- stderr: captureStderr || runtimeOptions.stderrSink ? "pipe" : "inherit",
3656
+ stdin: runtimeOptions.background ? "ignore" : input === null ? "inherit" : input,
3657
+ stdout: runtimeOptions.background ? "inherit" : captureStdout || stdoutSink ? "pipe" : "inherit",
3658
+ stderr: runtimeOptions.background ? "inherit" : captureStderr || runtimeOptions.stderrSink ? "pipe" : "inherit",
3216
3659
  onExit(proc, exitCode, signalCode, error) {
3217
3660
  runtimeOptions.onExit?.(proc, exitCode, signalCode, error);
3218
3661
  },
3219
3662
  };
3220
3663
  try {
3221
3664
  const proc = Bun.spawn(options);
3665
+ if (runtimeOptions.background) {
3666
+ if (!Array.isArray(Bun.sha.procs)) Bun.sha.procs = [];
3667
+ Bun.sha.procs.push(proc);
3668
+ proc.unref();
3669
+ const index = Bun.sha.procs.length - 1;
3670
+ return result(0, "", `[${index + 1}] ${proc.pid} Bun.sha.procs[${index}]\n`);
3671
+ }
3222
3672
  const pipelineKillSignal = runtimeOptions.pipelineKillSignal ?? "SIGPIPE";
3223
3673
  // A downstream pipeline stage that closes early (for example `head -n 3`)
3224
3674
  // leaves nothing reading the other end of `stdoutSink`/`stderrSink` (a
@@ -3286,6 +3736,85 @@ function pipelineBuiltinArgv(argv) {
3286
3736
  : [...BUN_RUNTIME_COMMAND, BUNMSH_ENTRY, "-cc", ...argv];
3287
3737
  }
3288
3738
 
3739
+ function pipelineCompoundArgv(source) {
3740
+ return IS_COMPILED
3741
+ ? [...EXECUTABLE_COMMAND, "-c", source]
3742
+ : [...BUN_RUNTIME_COMMAND, BUNMSH_ENTRY, "-c", source];
3743
+ }
3744
+
3745
+ function leadingSubshell(source) {
3746
+ const leading = source.length - source.trimStart().length;
3747
+ if (source[leading] !== "(") return null;
3748
+ let depth = 0, quote = null;
3749
+ for (let i = leading; i < source.length; i++) {
3750
+ const ch = source[i];
3751
+ if (quote === "'") { if (ch === "'") quote = null; continue; }
3752
+ if (quote === '"') {
3753
+ if (ch === "\\" && /[\\"$`\n]/.test(source[i + 1] ?? "")) { i++; continue; }
3754
+ if (ch === '"') quote = null;
3755
+ continue;
3756
+ }
3757
+ if (ch === "\\") { i++; continue; }
3758
+ if (ch === "'" || ch === '"') { quote = ch; continue; }
3759
+ if (ch === "`") {
3760
+ try { i = substitutionEnd(source, i) - 1; } catch { return null; }
3761
+ continue;
3762
+ }
3763
+ if (ch === "$" && source[i + 1] === "(") {
3764
+ try { i = substitutionEnd(source, i) - 1; } catch { return null; }
3765
+ continue;
3766
+ }
3767
+ if (ch === "(") depth++;
3768
+ else if (ch === ")" && --depth === 0)
3769
+ return { body: source.slice(leading + 1, i), suffix: source.slice(i + 1) };
3770
+ }
3771
+ return null;
3772
+ }
3773
+
3774
+ function rewriteSubshells(source, state) {
3775
+ let output = "", quote = null;
3776
+ for (let i = 0; i < source.length;) {
3777
+ const ch = source[i];
3778
+ if (quote === "'") {
3779
+ output += ch;
3780
+ if (ch === "'") quote = null;
3781
+ i++;
3782
+ continue;
3783
+ }
3784
+ if (quote === '"') {
3785
+ output += ch;
3786
+ if (ch === "\\" && /[\\"$`\n]/.test(source[i + 1] ?? "")) output += source[++i];
3787
+ else if (ch === '"') quote = null;
3788
+ i++;
3789
+ continue;
3790
+ }
3791
+ if (ch === "\\") { output += source.slice(i, i + 2); i += 2; continue; }
3792
+ if (ch === "'" || ch === '"') { quote = ch; output += ch; i++; continue; }
3793
+ if (ch === "`") {
3794
+ try { const end = substitutionEnd(source, i); output += source.slice(i, end); i = end; continue; }
3795
+ catch { output += ch; i++; continue; }
3796
+ }
3797
+ if (ch === "$" && source[i + 1] === "(") {
3798
+ try { const end = substitutionEnd(source, i); output += source.slice(i, end); i = end; continue; }
3799
+ catch { output += ch; i++; continue; }
3800
+ }
3801
+ const boundary = i === 0 || /[\s;|&]/.test(source[i - 1]);
3802
+ if (ch === "(" && boundary) {
3803
+ const group = leadingSubshell(source.slice(i));
3804
+ if (group) {
3805
+ const argv = pipelineCompoundArgv(group.body);
3806
+ const serialized = pipelineBuiltinState(state).env[PIPELINE_STATE_ENV];
3807
+ output += `BUNMSH_INTERNAL_PIPELINE_STATE=${quoteShellWord(serialized)} ${argv.map(quoteShellWord).join(" ")}`;
3808
+ i += group.body.length + 2;
3809
+ continue;
3810
+ }
3811
+ }
3812
+ output += ch;
3813
+ i++;
3814
+ }
3815
+ return output;
3816
+ }
3817
+
3289
3818
  function pipelineBuiltinState(state) {
3290
3819
  return {
3291
3820
  ...state,
@@ -3379,6 +3908,15 @@ async function runCommandArgv(
3379
3908
  if(argv[0] === "&")
3380
3909
  commandArgv = argv.slice(1) ;
3381
3910
  }
3911
+
3912
+ if (options.background && (
3913
+ options.pipelineStage ||
3914
+ ["command", "builtin", "__builtin", "time", "yes"].includes(commandArgv[0]) ||
3915
+ Object.hasOwn(state.functions, commandArgv[0]) ||
3916
+ Object.hasOwn(builtins, commandArgv[0]) ||
3917
+ (!commandArgv[0].includes("/") && Object.hasOwn(fallbackBuiltins, commandArgv[0]) &&
3918
+ !findExecutable(commandArgv[0], state))
3919
+ )) return result(2, "", "bunmsh: &: only a single external command is supported\n");
3382
3920
 
3383
3921
  let commandState = state;
3384
3922
  if (options.pipelineStage && runsInPipelineSubprocess(commandArgv, commandState))
@@ -3585,6 +4123,7 @@ async function runCommand(command, state, options = {}) {
3585
4123
  for (const redirect of command.redirects) {
3586
4124
  if (redirect.op === "1>&2") { stdoutToStderr = true; continue; }
3587
4125
  if (redirect.op === "2>&1") { stderrToStdout = true; continue; }
4126
+ if (/^\d+>&\d+$/.test(redirect.op)) continue;
3588
4127
  if (redirect.op === "<<" || redirect.op === "<<-") {
3589
4128
  const heredoc = redirect.heredoc ?? { body: "", expand: false };
3590
4129
  const content = heredoc.expand
@@ -3674,6 +4213,7 @@ async function runCommand(command, state, options = {}) {
3674
4213
  stderrSink: stderrRedirect,
3675
4214
  onSpawn: options.onSpawn,
3676
4215
  onExit: options.onExit,
4216
+ background: Boolean(options.background),
3677
4217
  },
3678
4218
  );
3679
4219
 
@@ -3690,10 +4230,15 @@ async function runCommand(command, state, options = {}) {
3690
4230
  }
3691
4231
 
3692
4232
  async function runPipeline(pipeline, state, options = {}) {
4233
+ if (options.background && (pipeline.length !== 1 || pipeline[0].redirects.length !== 0))
4234
+ return result(2, "", "bunmsh: &: only a single external command without redirections is supported\n");
3693
4235
  if (pipeline.length === 1)
3694
4236
  return runCommand(pipeline[0], state, {
4237
+ input: options.input ?? null,
4238
+ stdoutSink: options.stdoutSink ?? null,
3695
4239
  captureStdout: Boolean(options.capture),
3696
4240
  captureStderr: Boolean(options.capture),
4241
+ background: Boolean(options.background),
3697
4242
  });
3698
4243
 
3699
4244
  const links = Array.from(
@@ -3726,7 +4271,7 @@ async function runPipeline(pipeline, state, options = {}) {
3726
4271
  const stages = pipeline.map((command, i) => {
3727
4272
  const last = i === pipeline.length - 1;
3728
4273
  return runCommand(command, state, {
3729
- input: i === 0 ? null : links[i - 1].readable,
4274
+ input: i === 0 ? options.input ?? null : links[i - 1].readable,
3730
4275
  captureStdout: last && Boolean(options.capture),
3731
4276
  captureStderr: Boolean(options.capture),
3732
4277
  stdoutSink: last ? null : links[i].writable,
@@ -3774,6 +4319,47 @@ export async function execute(source, state = createState(), io = {}) {
3774
4319
  return execution;
3775
4320
  }
3776
4321
  const rawSource = source.trimStart();
4322
+ const group = leadingSubshell(source);
4323
+ if (group) {
4324
+ let suffix = group.suffix;
4325
+ let discardStdout = false, discardStderr = false;
4326
+ while (true) {
4327
+ const redirect = /^\s*(?:(\d+)?(>>?|<)\s*([^\s;&|]+)|(\d+>&\d+))/.exec(suffix);
4328
+ if (!redirect) break;
4329
+ if (redirect[2] === ">" || redirect[2] === ">>") {
4330
+ if (redirect[1] === "2") discardStderr ||= redirect[3] === "/dev/null";
4331
+ else discardStdout ||= redirect[3] === "/dev/null";
4332
+ }
4333
+ suffix = suffix.slice(redirect[0].length);
4334
+ }
4335
+ const child = await runExternal(
4336
+ pipelineCompoundArgv(group.body),
4337
+ pipelineBuiltinState(state),
4338
+ io.input ?? null,
4339
+ true,
4340
+ true,
4341
+ );
4342
+ let execution = result(child.status,
4343
+ discardStdout ? "" : child.stdout,
4344
+ discardStderr ? "" : child.stderr);
4345
+ const connector = /^\s*(\|\||&&|;)\s*([\s\S]*)$/.exec(suffix);
4346
+ if (connector) {
4347
+ const shouldRun = connector[1] === ";" ||
4348
+ (connector[1] === "&&" ? execution.status === 0 : execution.status !== 0);
4349
+ if (shouldRun) {
4350
+ const tail = await execute(connector[2], state, io);
4351
+ execution = result(tail.status,
4352
+ concatBytes([execution.stdout, tail.stdout]),
4353
+ concatBytes([execution.stderr, tail.stderr]));
4354
+ }
4355
+ }
4356
+ state.lastStatus = execution.status;
4357
+ if (!io.capture) {
4358
+ if (execution.stdout.byteLength) await writeStream(process.stdout, execution.stdout);
4359
+ if (execution.stderr.byteLength) await writeStream(process.stderr, execution.stderr);
4360
+ }
4361
+ return execution;
4362
+ }
3777
4363
  if (isJavaScriptMode(rawSource)) {
3778
4364
  let execution;
3779
4365
  const previousCwd = process.cwd();
@@ -3844,6 +4430,7 @@ export async function execute(source, state = createState(), io = {}) {
3844
4430
  try {
3845
4431
  execution = await executeNodeList(parseCompoundScript(source), state, {
3846
4432
  capture: Boolean(io.capture),
4433
+ input: io.input ?? null,
3847
4434
  });
3848
4435
  }
3849
4436
  catch (error) {
@@ -3858,6 +4445,12 @@ export async function execute(source, state = createState(), io = {}) {
3858
4445
  return execution;
3859
4446
  }
3860
4447
 
4448
+ const withSubshells = rewriteSubshells(source, state);
4449
+ if (withSubshells !== source) {
4450
+ source = withSubshells;
4451
+ return execute(source, state, io);
4452
+ }
4453
+
3861
4454
  let jobs;
3862
4455
  try {
3863
4456
  jobs = parse(source);
@@ -3876,7 +4469,12 @@ export async function execute(source, state = createState(), io = {}) {
3876
4469
  for (const job of jobs) {
3877
4470
  if (job.connector === "&&" && execution.status !== 0) continue;
3878
4471
  if (job.connector === "||" && execution.status === 0) continue;
3879
- execution = await runPipeline(job.pipeline, state, { capture: io.capture });
4472
+ execution = await runPipeline(job.pipeline, state, {
4473
+ capture: io.capture,
4474
+ background: job.background,
4475
+ input: io.input ?? null,
4476
+ stdoutSink: io.stdoutSink ?? null,
4477
+ });
3880
4478
  if (job.negate) execution.status = execution.status === 0 ? 1 : 0;
3881
4479
  state.lastStatus = execution.status;
3882
4480
  if (execution.stdout.byteLength) {