buninu 0.4.18 → 0.4.20

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/apps/bunmsh/CHANGELOG.md +131 -0
  3. package/apps/bunmsh/PORTING.md +133 -15
  4. package/apps/bunmsh/help/awk.md +38 -0
  5. package/apps/bunmsh/help/break.md +21 -0
  6. package/apps/bunmsh/help/chmod.md +8 -2
  7. package/apps/bunmsh/help/cmp.md +29 -0
  8. package/apps/bunmsh/help/continue.md +22 -0
  9. package/apps/bunmsh/help/cp.md +4 -0
  10. package/apps/bunmsh/help/diff.md +30 -0
  11. package/apps/bunmsh/help/egrep.md +26 -0
  12. package/apps/bunmsh/help/export.md +2 -2
  13. package/apps/bunmsh/help/expr.md +30 -0
  14. package/apps/bunmsh/help/fgrep.md +25 -0
  15. package/apps/bunmsh/help/grep.md +4 -0
  16. package/apps/bunmsh/help/ls.md +4 -2
  17. package/apps/bunmsh/help/mkdir.md +1 -1
  18. package/apps/bunmsh/help/print.md +3 -0
  19. package/apps/bunmsh/help/return.md +23 -0
  20. package/apps/bunmsh/help/sed.md +8 -3
  21. package/apps/bunmsh/help/set.md +12 -3
  22. package/apps/bunmsh/help/tr.md +4 -0
  23. package/apps/bunmsh/help/trap.md +22 -0
  24. package/apps/bunmsh/help/uniq.md +27 -0
  25. package/apps/bunmsh/package.json +1 -1
  26. package/apps/bunmsh/src/awk.js +203 -0
  27. package/apps/bunmsh/src/diff.js +222 -0
  28. package/apps/bunmsh/src/fancy-ls.js +15 -4
  29. package/apps/bunmsh/src/main.js +28 -10
  30. package/apps/bunmsh/src/sed.js +159 -0
  31. package/apps/bunmsh/src/shell.js +1714 -462
  32. package/apps/bunmsh/test/awk-reference.test.js +35 -0
  33. package/apps/bunmsh/test/awk.test.js +62 -0
  34. package/apps/bunmsh/test/posix-bootstrap.test.js +595 -0
  35. package/apps/bunmsh/test/shell1.test.js +64 -0
  36. package/apps/bunmsh/test/utility-reference-cases.mjs +98 -0
  37. package/apps/bunmsh/test/utility-reference.test.js +23 -0
  38. package/apps/jsgotty/client-kitty.mjs +61 -0
  39. package/apps/jsgotty/client.mjs +16 -0
  40. package/apps/jsgotty/package-lock.json +2 -2
  41. package/apps/jsgotty/package.json +1 -1
  42. package/apps/jsgotty/tests/client-kitty.test.mjs +43 -0
  43. package/package.json +1 -1
  44. package/apps/bunmsh/test/shell.test.js +0 -2092
@@ -1,24 +1,37 @@
1
+ import {compileAwk, executeAwk} from "./awk.js";
2
+ import {normalFileDiff} from "./diff.js";
3
+ import {compileSed, applySed} from "./sed.js";
1
4
  import {
2
5
  constants as fsConstants,
3
6
  accessSync,
4
7
  closeSync,
5
8
  chmodSync,
9
+ chownSync,
10
+ copyFileSync,
6
11
  createReadStream,
7
12
  createWriteStream,
8
13
  lstatSync,
9
14
  linkSync,
10
15
  mkdirSync,
16
+ mkdtempSync,
17
+ rmSync,
18
+ writeFileSync,
11
19
  openSync,
12
20
  readdirSync,
13
21
  rmdirSync,
14
22
  readFileSync,
23
+ readSync,
24
+ read as fsRead,
25
+ writeSync,
15
26
  realpathSync,
16
27
  statSync,
17
28
  symlinkSync,
18
29
  unlinkSync,
30
+ utimesSync,
19
31
  } from "node:fs";
20
32
  import { createHash } from "node:crypto";
21
33
  import {
34
+ tmpdir,
22
35
  arch as osArch,
23
36
  constants as osConstants,
24
37
  hostname as osHostname,
@@ -33,7 +46,7 @@ import {
33
46
  isAbsolute,
34
47
  resolve as resolvePath,
35
48
  } from "node:path";
36
- import { Readable, Writable } from "node:stream";
49
+ import { Readable } from "node:stream";
37
50
  import { format as formatValue } from "node:util";
38
51
  import { EXECUTABLE_COMMAND, IS_COMPILED } from "../single-exe/compiled.js";
39
52
  import { readAssetText } from "../single-exe/assetsHelper.js";
@@ -258,13 +271,18 @@ export function tokenize(source, options = {}) {
258
271
  // word for the indented continuation lines commonly used in exec calls.
259
272
  const continuation = lineContinuationLength(source, i);
260
273
  if (continuation) {
274
+ options.continuations?.set(i, continuation);
261
275
  i += continuation;
262
276
  continue;
263
277
  }
264
278
 
265
279
  if (ch === "\n") {
266
280
  finishWord();
267
- tokens.push({ type: "op", value: ";", offset: i });
281
+ // Newlines after AND/OR/pipe operators continue the same command,
282
+ // including when a here-document body lies between the two sides.
283
+ const previous = tokens.at(-1);
284
+ if (!(previous?.type === "op" && ["&&", "||", "|"].includes(previous.value)))
285
+ tokens.push({ type: "op", value: ";", offset: i });
268
286
  i++;
269
287
  atWordBoundary = true;
270
288
  if (pendingHeredocs.length) {
@@ -283,27 +301,24 @@ export function tokenize(source, options = {}) {
283
301
  while (i < source.length && source[i] !== "\n") i++;
284
302
  continue;
285
303
  }
304
+ const fullDup = /^\d+>&\d+(?=$|[\s;|&<>])/.exec(source.slice(i))?.[0];
305
+ if (!current && fullDup) { tokens.push({type:"op",value:fullDup,offset:i}); i += fullDup.length; atWordBoundary=true; continue; }
306
+ const ioOperator = /^(?:[0-9]+)?(?:>&|<&|<>|>>|>(?!>)|<(?!<))/.exec(source.slice(i))?.[0];
307
+ if (!current && ioOperator) {
308
+ tokens.push({type:"op", value:ioOperator, offset:i});
309
+ i += ioOperator.length;
310
+ atWordBoundary = true;
311
+ continue;
312
+ }
286
313
  const two = source.slice(i, i + 2);
287
- const fdDup = source.slice(i, i + 4);
288
314
  const operatorAhead =
289
315
  ["|", ";", "<", ">"].includes(ch) ||
290
316
  two === "&&" ||
291
- two === "||" ||
292
- two === "2>" ||
293
- fdDup === "1>&2" ||
294
- fdDup === "2>&1";
317
+ two === "||";
295
318
  if (current && operatorAhead) {
296
319
  finishWord();
297
320
  continue;
298
321
  }
299
- // A descriptor duplication begins with the shorter `2>` redirect token,
300
- // so it must win before ordinary file redirects are considered.
301
- if (!current && (fdDup === "1>&2" || fdDup === "2>&1")) {
302
- tokens.push({ type: "op", value: fdDup, offset: i });
303
- i += 4;
304
- atWordBoundary = true;
305
- continue;
306
- }
307
322
  if (!current && ["&&", "||", ">>", "2>", "2>>"].includes(
308
323
  source.slice(i, i + 3) === "2>>" ? source.slice(i, i + 3) : two,
309
324
  )) {
@@ -358,19 +373,20 @@ export function tokenize(source, options = {}) {
358
373
  }
359
374
  const quotedContinuation = lineContinuationLength(source, i);
360
375
  if (quotedContinuation) {
376
+ options.continuations?.set(i, quotedContinuation);
361
377
  i += quotedContinuation;
362
378
  continue;
363
379
  }
364
380
  if (source[i] === "\\" && i + 1 < source.length) {
365
381
  const next = source[i + 1];
366
- if (next === "$" || next === '"' || next === "\\") {
382
+ if (next === "$" || next === "`" || next === '"' || next === "\\") {
367
383
  text += `\u0000${next}`;
368
384
  i += 2;
369
385
  continue;
370
386
  }
371
387
  }
372
388
  if ((source[i] === "$" && ["(", "{"].includes(source[i + 1])) || source[i] === "`") {
373
- const end = source[i + 1] === "{" ? parameterEnd(source, i) : substitutionEnd(source, i);
389
+ const end = source[i] === "$" && source[i + 1] === "{" ? parameterEnd(source, i) : substitutionEnd(source, i);
374
390
  text += source.slice(i, end);
375
391
  i = end;
376
392
  continue;
@@ -408,7 +424,7 @@ export function tokenize(source, options = {}) {
408
424
  while (i < source.length) {
409
425
  const c = source[i];
410
426
  if ((c === "$" && ["(", "{"].includes(source[i + 1])) || c === "`") {
411
- const end = source[i + 1] === "{" ? parameterEnd(source, i) : substitutionEnd(source, i);
427
+ const end = source[i] === "$" && source[i + 1] === "{" ? parameterEnd(source, i) : substitutionEnd(source, i);
412
428
  text += source.slice(i, end);
413
429
  i = end;
414
430
  continue;
@@ -416,10 +432,7 @@ export function tokenize(source, options = {}) {
416
432
  const maybeOp =
417
433
  ["|", ";", "<", ">"].includes(c) ||
418
434
  source.slice(i, i + 2) === "&&" ||
419
- source.slice(i, i + 2) === "||" ||
420
- source.slice(i, i + 2) === "2>" ||
421
- source.slice(i, i + 4) === "1>&2" ||
422
- source.slice(i, i + 4) === "2>&1";
435
+ source.slice(i, i + 2) === "||";
423
436
  if (isSpace(c) || c === "\n" || c === "'" || c === '"' || c === "\\" || maybeOp)
424
437
  break;
425
438
  text += c;
@@ -462,16 +475,17 @@ export function parse(source) {
462
475
  const pipeline = [];
463
476
  while (true) {
464
477
  const command = emptyCommand();
478
+ command.lineNumber = source.slice(0, tokens[i]?.offset ?? 0).split("\n").length;
465
479
  while (i < tokens.length) {
466
480
  const token = tokens[i];
467
481
  if (token.type === "op" && ["|", ";", "&&", "||"].includes(token.value))
468
482
  break;
469
- if (token.type === "op" && ["1>&2", "2>&1"].includes(token.value)) {
483
+ if (token.type === "op" && /^\d+>&\d+$/.test(token.value)) {
470
484
  command.redirects.push({ op: token.value, target: null });
471
485
  i++;
472
486
  continue;
473
487
  }
474
- if (token.type === "op" && ["<", ">", ">>", "2>", "2>>", "<<", "<<-", "<<<"].includes(token.value)) {
488
+ if (token.type === "op" && (/^\d*(?:>&|<&|<>|>>|>|<)$/.test(token.value) || ["<<", "<<-", "<<<"].includes(token.value))) {
475
489
  const target = tokens[i + 1];
476
490
  if (!target || target.type !== "word")
477
491
  throw new ShellSyntaxError(
@@ -497,6 +511,7 @@ export function parse(source) {
497
511
 
498
512
  if (tokens[i]?.value === "|") {
499
513
  i++;
514
+ while (tokens[i]?.value === ";" && source[tokens[i].offset] === "\n") i++;
500
515
  if (!tokens[i])
501
516
  throw new ShellSyntaxError("pipeline requires a command", source.length);
502
517
  continue;
@@ -541,16 +556,27 @@ export function parse(source) {
541
556
  }
542
557
 
543
558
  function parameterValue(name, state) {
559
+ if (name === "LINENO") return String(state.lineNumber ?? 1);
544
560
  if (name === "?") return String(state.lastStatus);
545
561
  if (name === "$") return String(process.pid);
546
562
  if (name === "#") return String(Math.max(0, state.args.length - 1));
547
- if (name === "-") return "";
563
+ if (name === "-") return Object.entries(state.shellOptions ?? {}).filter(([k,v])=>v && k.length === 1).map(([k])=>k).join("");
548
564
  if (name === "0") return state.args[0] ?? "bunmsh";
549
565
  if (name === "@" || name === "*") return state.args.slice(1).join((state.env.IFS ?? " ")[0] ?? " ");
550
566
  if (/^[1-9][0-9]*$/.test(name)) return state.args[Number(name)] ?? "";
551
567
  return state.env[name] ?? "";
552
568
  }
553
569
 
570
+ function checkedParameter(name,state) {
571
+ const unset = name === "LINENO" ? false : /^\d+$/.test(name) ? Number(name) >= state.args.length : /^[A-Za-z_]/.test(name) && !Object.hasOwn(state.env,name);
572
+ if (state.shellOptions.u && unset) {
573
+ state.exitRequested = true;
574
+ state.exitStatus = 1;
575
+ throw new ShellSyntaxError(`${name}: parameter not set`);
576
+ }
577
+ return parameterValue(name,state);
578
+ }
579
+
554
580
  function tildeDirectory(name, state) {
555
581
  if (name === "") return state.env.HOME ?? "";
556
582
  if (name === "+") return state.env.PWD ?? state.cwd;
@@ -608,28 +634,81 @@ function arithmeticValue(source, state) {
608
634
  return String(value | 0);
609
635
  }
610
636
 
611
- function globPatternRegex(pattern, anchoredStart, anchoredEnd, greedy) {
637
+ async function casePatternRegex(pattern, state) {
638
+ let marked = "";
639
+ const literal = (value) => {
640
+ for (const ch of value) marked += `\u0000${ch}`;
641
+ };
642
+ for (let i = 0; i < pattern.length;) {
643
+ if (pattern[i] === "\\") {
644
+ if (i + 1 < pattern.length) literal(pattern[i + 1]);
645
+ i += 2;
646
+ continue;
647
+ }
648
+ if (pattern[i] === "'") {
649
+ const end = pattern.indexOf("'", i + 1);
650
+ if (end < 0) throw new ShellSyntaxError("unterminated single quote in case pattern");
651
+ literal(pattern.slice(i + 1, end));
652
+ i = end + 1;
653
+ continue;
654
+ }
655
+ if (pattern[i] === '"') {
656
+ let end = i + 1;
657
+ while (end < pattern.length && pattern[end] !== '"') {
658
+ if (pattern[end] === "\\") end++;
659
+ end++;
660
+ }
661
+ if (end >= pattern.length) throw new ShellSyntaxError("unterminated double quote in case pattern");
662
+ literal(stripExpansionMarkers(await expandText(pattern.slice(i + 1, end), state)));
663
+ i = end + 1;
664
+ continue;
665
+ }
666
+ let end = i;
667
+ while (end < pattern.length && !["\\", "'", '"'].includes(pattern[end])) end++;
668
+ marked += stripExpansionMarkers(await expandText(pattern.slice(i, end), state));
669
+ i = end;
670
+ }
612
671
  let source = "";
613
- for (const ch of pattern) {
614
- if (ch === "*") source += greedy ? ".*" : ".*?";
615
- else if (ch === "?") source += ".";
616
- else source += ch.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
672
+ for (let i = 0; i < marked.length; i++) {
673
+ if (marked[i] === "\u0000") {
674
+ source += (marked[++i] ?? "").replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
675
+ } else if (marked[i] === "*") source += ".*";
676
+ else if (marked[i] === "?") source += ".";
677
+ else if (marked[i] === "[") {
678
+ let end = i + 1;
679
+ if (marked[end] === "!" || marked[end] === "^") end++;
680
+ if (marked[end] === "]") end++;
681
+ while (end < marked.length && marked[end] !== "]") {
682
+ if (marked[end] === "\u0000" && end + 1 < marked.length) end++;
683
+ end++;
684
+ }
685
+ if (end >= marked.length) source += "\\[";
686
+ else {
687
+ let body = marked.slice(i + 1, end);
688
+ const negate = body[0] === "!" || body[0] === "^";
689
+ if (negate) body = body.slice(1);
690
+ body = body.replace(/\u0000(.)/gs, (_,ch) => /[\\\]\-^]/.test(ch) ? `\\${ch}` : ch);
691
+ source += `[${negate ? "^" : ""}${body}]`;
692
+ i = end;
693
+ }
694
+ }
695
+ else source += marked[i].replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
617
696
  }
618
- return new RegExp(`${anchoredStart ? "^" : ""}${source}${anchoredEnd ? "$" : ""}`);
697
+ return new RegExp(`^${source}$`, "s");
619
698
  }
620
699
 
621
- async function parameterExpansion(body, state) {
700
+ async function parameterExpansion(body, state, quoted = false) {
622
701
  if (/^[!%][A-Za-z_][A-Za-z0-9_]*$/.test(body)) {
623
702
  const value = parameterValue(body.slice(1), state);
624
703
  return body[0] === "!" ? parameterValue(value, state) : String([...value].length);
625
704
  }
626
- if (body.startsWith("#") && /^[A-Za-z_][A-Za-z0-9_]*$/.test(body.slice(1)))
705
+ if (body.startsWith("#") && /^(?:[A-Za-z_][A-Za-z0-9_]*|[0-9]+|[?$#-])$/.test(body.slice(1)))
627
706
  return String(parameterValue(body.slice(1), state).length);
628
707
  const trim = /^([A-Za-z_][A-Za-z0-9_]*|[?$#@*]|[0-9]+)(%%|%|##|#)(.*)$/s.exec(body);
629
708
  if (trim) {
630
709
  const value = parameterValue(trim[1], state);
631
- const pattern = stripExpansionMarkers(await expandText(trim[3], state));
632
- const matches = (part) => globPatternRegex(pattern, true, true, true).test(part);
710
+ const pattern = await casePatternRegex(trim[3], state);
711
+ const matches = (part) => pattern.test(part);
633
712
  if (trim[2] === "%") {
634
713
  for (let i = value.length; i >= 0; i--) if (matches(value.slice(i))) return value.slice(0, i);
635
714
  } else if (trim[2] === "%%") {
@@ -660,13 +739,14 @@ async function parameterExpansion(body, state) {
660
739
  if (!match) throw new ShellSyntaxError(`unsupported parameter expansion: \${${body}}`);
661
740
  const [, name, operator, operand = ""] = match;
662
741
  const value = parameterValue(name, state);
663
- const unset = !Object.hasOwn(state.env, name) && !/^[?$#@*0-9]$/.test(name);
742
+ const unset = /^\d+$/.test(name) ? Number(name) >= state.args.length :
743
+ name !== "LINENO" && !Object.hasOwn(state.env, name) && !/^[?$#@*-]$/.test(name);
664
744
  const empty = value === "";
665
- if (!operator) return value;
745
+ if (!operator) return checkedParameter(name,state);
666
746
  const useEmpty = operator.startsWith(":");
667
747
  const op = operator.at(-1);
668
748
  const missing = unset || (useEmpty && empty);
669
- const expandedOperand = async () => stripExpansionMarkers(await expandText(operand, state));
749
+ const expandedOperand = () => expandParameterOperand(operand, state, quoted);
670
750
  if (op === "-") return missing ? expandedOperand() : value;
671
751
  if (op === "+") return missing ? "" : expandedOperand();
672
752
  if (op === "=") {
@@ -674,7 +754,7 @@ async function parameterExpansion(body, state) {
674
754
  if (!/^[A-Za-z_]/.test(name)) throw new ShellSyntaxError("cannot assign positional parameter");
675
755
  if (state.readonly?.has(name)) throw new ShellSyntaxError(`${name}: is read only`);
676
756
  const expanded = await expandedOperand();
677
- state.env[name] = expanded;
757
+ state.env[name] = stripExpansionMarkers(expanded);
678
758
  return expanded;
679
759
  }
680
760
  return value;
@@ -684,11 +764,48 @@ async function parameterExpansion(body, state) {
684
764
  }
685
765
 
686
766
  function markedExpansion(value) {
687
- return `\u0001${value}\u0002`;
767
+ return `\ufdd0${value}\ufdd1`;
688
768
  }
689
769
 
690
770
  function stripExpansionMarkers(value) {
691
- return value.replace(/[\u0001\u0002]/g, "");
771
+ return value.replace(/\ufdd4/g, " ").replace(/[\ufdd0-\ufdd3\ufdd5\ufdd6]/g, "");
772
+ }
773
+
774
+ // Like mksh eval.c's XARG/XARGSEP, retain quote and argument boundaries
775
+ // until field splitting. A selected ${...word} is shell word syntax, not text.
776
+ async function expandParameterOperand(source, state, outerQuoted) {
777
+ let output = "", plain = "", quote = "none";
778
+ const flush = async () => {
779
+ if (quote === "single") output += `\ufdd2${plain}\ufdd5\ufdd3`;
780
+ else {
781
+ const value = await expandText(plain, state, outerQuoted || quote === "double");
782
+ output += quote === "double" ? `\ufdd2${value}${value === "\ufdd6" ? "" : "\ufdd5"}\ufdd3` : value;
783
+ }
784
+ plain = "";
785
+ };
786
+ for (let i = 0; i < source.length;) {
787
+ const ch = source[i];
788
+ if (quote === "single") {
789
+ if (ch === "'") { await flush(); quote = "none"; i++; }
790
+ else plain += source[i++];
791
+ continue;
792
+ }
793
+ if ((ch === '"' && quote !== "single") || (ch === "'" && quote === "none" && !outerQuoted)) {
794
+ await flush(); quote = quote === "none" ? (ch === '"' ? "double" : "single") : "none"; i++; continue;
795
+ }
796
+ if (ch === "\\" && i + 1 < source.length) {
797
+ if (quote === "none" && !outerQuoted || '$`"\\'.includes(source[i + 1])) {
798
+ plain += `\u0000${source[i + 1]}`; i += 2; continue;
799
+ }
800
+ }
801
+ if (ch === "`" || ch === "$" && ["{", "("].includes(source[i + 1])) {
802
+ const end = source[i] === "$" && source[i + 1] === "{" ? parameterEnd(source, i) : substitutionEnd(source, i);
803
+ plain += source.slice(i, end); i = end; continue;
804
+ }
805
+ plain += source[i++];
806
+ }
807
+ await flush();
808
+ return output;
692
809
  }
693
810
 
694
811
  async function commandSubstitution(source, state) {
@@ -697,17 +814,24 @@ async function commandSubstitution(source, state) {
697
814
  env: { ...state.env },
698
815
  aliases: { ...state.aliases },
699
816
  readonly: new Set(state.readonly),
817
+ exported: new Set(state.exported),
700
818
  directoryHistory: [...state.directoryHistory],
701
819
  tabs: [...state.tabs],
702
820
  exitRequested: false,
703
821
  exitStatus: 0,
822
+ flow:null, loopDepth:0, returnDepth:0,
823
+ shellOptions:{...state.shellOptions}, fdBindings:{...state.fdBindings},
824
+ traps:{}, trapListeners:new Map(), pendingTraps:[], ownedFds:new Set(),
704
825
  };
826
+ // Command substitution installs its own stdout pipe even inside >file.
827
+ delete child.fdBindings[1];
705
828
  const output = await execute(source, child, { capture: true });
706
- state.expansionStatus = output.status;
707
- return decode(output.stdout).replace(/\n+$/, "");
829
+ const final = await finishShell(child,output.status,{capture:true});
830
+ state.expansionStatus = final.status;
831
+ return decode(concatBytes([output.stdout,final.stdout])).replace(/\n+$/, "");
708
832
  }
709
833
 
710
- async function expandText(text, state) {
834
+ async function expandText(text, state, quoted = false) {
711
835
  let output = "";
712
836
  for (let i = 0; i < text.length; ) {
713
837
  if (text[i] === "\u0000") {
@@ -717,7 +841,11 @@ async function expandText(text, state) {
717
841
  }
718
842
  if (text[i] === "`") {
719
843
  const end = substitutionEnd(text, i);
720
- output += markedExpansion(await commandSubstitution(text.slice(i + 1, end - 1), state));
844
+ // mksh lex.c SBQUOTE removes one escape layer before parsing the body,
845
+ // even inside quotes that belong to the inner command. Double-quoted
846
+ // backquotes additionally recognize an escaped double quote.
847
+ const source = text.slice(i + 1, end - 1).replace(quoted ? /\\([$`\\"])/g : /\\([$`\\])/g, "$1");
848
+ output += markedExpansion(await commandSubstitution(source, state));
721
849
  i = end;
722
850
  continue;
723
851
  }
@@ -727,7 +855,8 @@ async function expandText(text, state) {
727
855
  }
728
856
  if (text.startsWith("$((", i)) {
729
857
  const end = substitutionEnd(text, i);
730
- output += markedExpansion(arithmeticValue(text.slice(i + 3, end - 2), state));
858
+ const expression = stripExpansionMarkers(await expandText(text.slice(i + 3, end - 2), state));
859
+ output += markedExpansion(arithmeticValue(expression, state));
731
860
  i = end;
732
861
  continue;
733
862
  }
@@ -739,20 +868,22 @@ async function expandText(text, state) {
739
868
  }
740
869
  if (text[i + 1] === "{") {
741
870
  const end = parameterEnd(text, i);
742
- output += markedExpansion(await parameterExpansion(text.slice(i + 2, end - 1), state));
871
+ output += markedExpansion(await parameterExpansion(text.slice(i + 2, end - 1), state, quoted));
743
872
  i = end;
744
873
  continue;
745
874
  }
746
875
  const next = text[i + 1];
747
876
  if (["?", "$", "#", "@", "*", "-"].includes(next) || /[0-9]/.test(next ?? "")) {
748
- output += markedExpansion(parameterValue(next, state));
877
+ output += next === "@" && quoted
878
+ ? (state.args.length > 1 ? state.args.slice(1).map(arg => `\ufdd2${arg}\ufdd5\ufdd3`).join("\ufdd4") : "\ufdd6")
879
+ : markedExpansion(checkedParameter(next, state));
749
880
  i += 2;
750
881
  continue;
751
882
  }
752
883
  if (isNameStart(next)) {
753
884
  let end = i + 2;
754
885
  while (isNameChar(text[end])) end++;
755
- output += markedExpansion(parameterValue(text.slice(i + 1, end), state));
886
+ output += markedExpansion(checkedParameter(text.slice(i + 1, end), state));
756
887
  i = end;
757
888
  continue;
758
889
  }
@@ -774,6 +905,7 @@ function splitFields(segments, state) {
774
905
  forced = false;
775
906
  };
776
907
  for (const segment of segments) {
908
+ if (segment.boundary) { finish(true); continue; }
777
909
  if (segment.quoted && segment.text === "") forced = true;
778
910
  for (const ch of segment.text) {
779
911
  if (!segment.quoted && segment.splittable && ifs.includes(ch)) {
@@ -838,42 +970,41 @@ function braceFields(field) {
838
970
  }
839
971
 
840
972
  export async function expandWord(word, state, options = {}) {
841
- if (!options.single && word.fragments.length === 1 &&
842
- word.fragments[0].quote === "double" && word.fragments[0].text === "$@")
843
- return state.args.slice(1);
844
973
  const segments = [];
845
- for (let index = 0; index < word.fragments.length; index++) {
846
- const fragment = word.fragments[index];
974
+ for (const fragment of word.fragments) {
847
975
  const source = fragment.quote === "none" && !options.noTilde
848
- ? expandTildes(fragment.text, state, Boolean(options.assignment))
849
- : fragment.text;
850
- let text = fragment.quote === "single"
851
- ? source.replaceAll("\u0000", "")
852
- : await expandText(source, state);
853
- if (fragment.quote !== "none") {
854
- segments.push({ text: stripExpansionMarkers(text.replaceAll("\u0000", "")), quoted: true, splittable: false });
855
- } else {
856
- let plain = "";
857
- let splittable = false;
858
- const flush = () => {
859
- if (plain) segments.push({ text: plain, quoted: false, splittable });
860
- plain = "";
861
- };
862
- for (let i = 0; i < text.length; i++) {
863
- if (text[i] === "\u0001") { flush(); splittable = true; continue; }
864
- if (text[i] === "\u0002") { flush(); splittable = false; continue; }
865
- if (text[i] !== "\u0000") { plain += text[i]; continue; }
866
- flush();
867
- segments.push({ text: text[++i] ?? "", quoted: true, splittable: false });
868
- }
869
- flush();
976
+ ? expandTildes(fragment.text, state, Boolean(options.assignment)) : fragment.text;
977
+ if (fragment.quote === "single") {
978
+ segments.push({text:source.replaceAll("\u0000", ""),quoted:true,splittable:false});
979
+ continue;
870
980
  }
981
+ const text = await expandText(source, state, fragment.quote === "double");
982
+ let plain = "", splitDepth = 0, quoteDepth = fragment.quote === "double" ? 1 : 0;
983
+ const flush = () => {
984
+ if (plain) segments.push({text:plain,quoted:quoteDepth > 0,splittable:splitDepth > 0});
985
+ plain = "";
986
+ };
987
+ if (fragment.quote === "double" && !text.includes("\ufdd6"))
988
+ segments.push({text:"",quoted:true,splittable:false});
989
+ for (let i = 0; i < text.length; i++) {
990
+ const ch = text[i];
991
+ if (ch === "\ufdd0") { flush(); splitDepth++; }
992
+ else if (ch === "\ufdd1") { flush(); splitDepth--; }
993
+ else if (ch === "\ufdd2") { flush(); quoteDepth++; }
994
+ else if (ch === "\ufdd3") { flush(); quoteDepth--; }
995
+ else if (ch === "\ufdd4") { flush(); segments.push({text:options.single ? " " : "",boundary:!options.single}); }
996
+ else if (ch === "\ufdd5") { flush(); segments.push({text:"",quoted:true,splittable:false}); }
997
+ else if (ch === "\ufdd6") { flush(); }
998
+ else if (ch === "\u0000") { flush(); segments.push({text:text[++i] ?? "",quoted:true,splittable:false}); }
999
+ else plain += ch;
1000
+ }
1001
+ flush();
871
1002
  }
872
1003
  if (options.single) return [segments.map(({ text }) => text).join("")];
873
1004
  const fields = splitFields(segments, state);
874
1005
  const output = [];
875
1006
  for (const field of fields)
876
- for (const expanded of braceFields(field)) output.push(...await pathnameFields(expanded, state));
1007
+ for (const expanded of braceFields(field)) output.push(...(state.shellOptions.f ? [expanded.map(part=>part.ch).join("")] : await pathnameFields(expanded, state)));
877
1008
  return output;
878
1009
  }
879
1010
 
@@ -999,10 +1130,16 @@ async function sourceFile(argv, state) {
999
1130
  try {
1000
1131
  const source = await Bun.file(path).text();
1001
1132
  const previousArgs = state.args;
1133
+ const previousLoopDepth = state.loopDepth;
1134
+ state.loopDepth = 0;
1002
1135
  if (argv.length > 2) state.args = [previousArgs[0], ...argv.slice(2)];
1136
+ state.returnDepth++;
1003
1137
  try {
1004
1138
  return await execute(source, state, { capture: true });
1005
1139
  } finally {
1140
+ state.returnDepth--;
1141
+ state.loopDepth = previousLoopDepth;
1142
+ if (state.flow?.kind === "return") state.flow = null;
1006
1143
  state.args = previousArgs;
1007
1144
  }
1008
1145
  } catch (error) {
@@ -1067,7 +1204,112 @@ function evaluateTest(argv, state) {
1067
1204
  throw new Error("unsupported expression");
1068
1205
  }
1069
1206
 
1207
+ function loopControl(kind, argv, state) {
1208
+ const levels = argv[1] === undefined ? 1 : Number(argv[1]);
1209
+ if (argv.length > 2 || !Number.isInteger(levels) || levels < 1)
1210
+ return result(2, "", `bunmsh: ${kind}: expected a positive integer\n`);
1211
+ if (!state.loopDepth) return result(2, "", `bunmsh: ${kind}: not in a loop\n`);
1212
+ state.flow = { kind, levels: Math.min(levels, state.loopDepth) };
1213
+ return result();
1214
+ }
1215
+
1216
+ function trapName(value) {
1217
+ if (value === "0" || value === "EXIT") return "EXIT";
1218
+ const name = /^\d+$/.test(value)
1219
+ ? Object.keys(osConstants.signals).find(key => osConstants.signals[key] === Number(value))
1220
+ : value.startsWith("SIG") ? value : `SIG${value}`;
1221
+ return name && osConstants.signals[name] && !["SIGKILL","SIGSTOP"].includes(name) ? name : null;
1222
+ }
1223
+
1224
+ function setTrap(argv, state) {
1225
+ let args = argv.slice(1);
1226
+ if (args[0] === "--") args.shift();
1227
+ if (!args.length) return result(0,Object.entries(state.traps).map(([name,command]) => `trap -- ${quoteShellWord(command)} ${name === "EXIT" ? "0" : name.slice(3)}\n`).join(""));
1228
+ let action = args.shift();
1229
+ if (/^\d+$/.test(action)) { args.unshift(action); action = "-"; }
1230
+ if (!args.length) return result(2,"","bunmsh: trap: expected a signal\n");
1231
+ for (const value of args) {
1232
+ const name = trapName(value);
1233
+ if (!name) return result(2,"",`bunmsh: trap: ${value}: invalid or uncatchable signal\n`);
1234
+ const listener = state.trapListeners.get(name);
1235
+ if (listener) process.off(name,listener);
1236
+ state.trapListeners.delete(name);
1237
+ if (action === "-") { delete state.traps[name]; continue; }
1238
+ state.traps[name] = action;
1239
+ if (name !== "EXIT") {
1240
+ // Like mksh's pending-trap flag: callbacks enqueue only. Shell code
1241
+ // runs at command boundaries, never reentrantly from a signal callback.
1242
+ const handler = () => { if (state.traps[name] && !state.pendingTraps.includes(name)) state.pendingTraps.push(name); };
1243
+ process.on(name,handler);
1244
+ state.trapListeners.set(name,handler);
1245
+ }
1246
+ }
1247
+ return result();
1248
+ }
1249
+
1250
+ async function runPendingTraps(state, io) {
1251
+ if (state.inTrap) return result();
1252
+ const stdout = [], stderr = [];
1253
+ while (state.pendingTraps.length) {
1254
+ const name = state.pendingTraps.shift();
1255
+ const source = state.traps[name];
1256
+ if (!source) continue;
1257
+ const savedStatus = state.lastStatus;
1258
+ state.inTrap = true;
1259
+ try {
1260
+ const execution = await execute(source,state,io);
1261
+ stdout.push(execution.stdout); stderr.push(execution.stderr);
1262
+ } finally { state.inTrap = false; state.lastStatus = savedStatus; }
1263
+ if (state.exitRequested) break;
1264
+ }
1265
+ return result(state.lastStatus,concatBytes(stdout),concatBytes(stderr));
1266
+ }
1267
+
1268
+ export async function finishShell(state, status = state.lastStatus, io = {}) {
1269
+ if (state.finished) return result(status);
1270
+ state.finished = true;
1271
+ let execution = result(status);
1272
+ try {
1273
+ const handler = state.traps.EXIT;
1274
+ delete state.traps.EXIT;
1275
+ if (handler) {
1276
+ state.exitRequested = false;
1277
+ state.flow = null;
1278
+ state.lastStatus = status;
1279
+ execution = await execute(handler,state,io);
1280
+ execution.status = state.exitRequested ? state.exitStatus : status;
1281
+ }
1282
+ return execution;
1283
+ } finally {
1284
+ for (const [signal,listener] of state.trapListeners) process.off(signal,listener);
1285
+ state.trapListeners.clear();
1286
+ for (const fd of state.ownedFds) { try { closeSync(fd); } catch {} }
1287
+ state.ownedFds.clear();
1288
+ // Background processes deliberately remain unref'ed and accessible by
1289
+ // stable index in Bun.sha.procs. No waiting, killing or registry cleanup.
1290
+ }
1291
+ }
1292
+
1293
+ function consumeLoopFlow(state) {
1294
+ if (!state.flow) return false;
1295
+ if (state.flow.kind === "return") return true;
1296
+ const kind = state.flow.kind;
1297
+ if (--state.flow.levels > 0) return true;
1298
+ state.flow = null;
1299
+ return kind === "break";
1300
+ }
1301
+
1070
1302
  const builtins = {
1303
+ return: async (argv, state) => {
1304
+ if (!state.returnDepth) return result(2, "", "bunmsh: return: not in a function or sourced script\n");
1305
+ const status = argv[1] === undefined ? state.lastStatus : Number(argv[1]);
1306
+ if (argv.length > 2 || !Number.isInteger(status))
1307
+ return result(2, "", "bunmsh: return: expected an integer status\n");
1308
+ state.flow = { kind: "return" };
1309
+ return result(status & 255);
1310
+ },
1311
+ break: async (argv, state) => loopControl("break", argv, state),
1312
+ continue: async (argv, state) => loopControl("continue", argv, state),
1071
1313
  ":": async () => result(),
1072
1314
  true: async () => result(),
1073
1315
  false: async () => result(1),
@@ -1214,11 +1456,13 @@ const builtins = {
1214
1456
  fd = Number(arg.slice(2));
1215
1457
  continue;
1216
1458
  }
1217
- if (arg === "-r" || arg === "-R") raw = true;
1218
- else if (arg === "-n") newline = false;
1219
- else if (arg === "-l") separator = "\n";
1220
- else if (arg === "-N") separator = terminator = "\0";
1221
- else return result(1, "", `bunmsh: print: ${arg}: unsupported option\n`);
1459
+ for (const flag of arg.slice(1)) {
1460
+ if (flag === "r" || flag === "R") raw = true;
1461
+ else if (flag === "n") newline = false;
1462
+ else if (flag === "l") separator = "\n";
1463
+ else if (flag === "N") separator = terminator = "\0";
1464
+ else return result(1, "", `bunmsh: print: -${flag}: unsupported option\n`);
1465
+ }
1222
1466
  }
1223
1467
  let output = argv.slice(i).join(separator);
1224
1468
  if (!raw) {
@@ -1282,6 +1526,25 @@ const builtins = {
1282
1526
  if (state.readLines) {
1283
1527
  gotInput = state.readLines.length > 0;
1284
1528
  line = state.readLines.shift() ?? "";
1529
+ } else if (typeof input === "number" || input == null) {
1530
+ // Like mksh's normal read mode, consume only through the delimiter.
1531
+ // A fresh buffered stream would read ahead and cancelling it can close
1532
+ // the shared descriptor, breaking subsequent reads and fd duplicates.
1533
+ const byte = Buffer.alloc(1), data = [];
1534
+ gotInput = false;
1535
+ while (true) {
1536
+ let count;
1537
+ try {
1538
+ count = input == null
1539
+ ? await new Promise((resolve,reject) => fsRead(0,byte,0,1,null,(error,n) => error ? reject(error) : resolve(n)))
1540
+ : readSync(input, byte, 0, 1, null);
1541
+ }
1542
+ catch (error) { if (error.code === "EINTR") continue; throw error; }
1543
+ if (!count) break;
1544
+ if (byte[0] === 10) { gotInput = true; break; }
1545
+ data.push(byte[0]);
1546
+ }
1547
+ line = decoder.decode(Uint8Array.from(data));
1285
1548
  } else {
1286
1549
  const reader = fallbackInput(input).getReader();
1287
1550
  const chunks = [];
@@ -1403,8 +1666,9 @@ const builtins = {
1403
1666
  "..": async (_argv, state) => changeDirectory(state, ".."),
1404
1667
  "//": async (_argv, state) => previousChildDirectory(state),
1405
1668
  export: async (argv, state) => {
1406
- if (argv.length === 1) {
1669
+ if (argv.length === 1 || argv.length === 2 && argv[1] === "-p") {
1407
1670
  const listing = Object.entries(state.env)
1671
+ .filter(([name]) => !state.shellOptions.posix || state.exported.has(name))
1408
1672
  .sort(([a], [b]) => a.localeCompare(b))
1409
1673
  .map(([name, value]) => `export ${name}=${JSON.stringify(value)}\n`)
1410
1674
  .join("");
@@ -1419,6 +1683,7 @@ const builtins = {
1419
1683
  else if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(item))
1420
1684
  state.env[item] ??= "";
1421
1685
  else return result(1, "", `bunmsh: export: ${item}: invalid name\n`);
1686
+ state.exported.add(assignment?.name ?? item);
1422
1687
  }
1423
1688
  return result();
1424
1689
  },
@@ -1430,6 +1695,7 @@ const builtins = {
1430
1695
  return result(1, "", `bunmsh: unset: ${name}: invalid name\n`);
1431
1696
  if (state.readonly.has(name)) return readonlyError(name);
1432
1697
  delete state.env[name];
1698
+ state.exported.delete(name);
1433
1699
  }
1434
1700
  return result();
1435
1701
  },
@@ -1455,7 +1721,7 @@ const builtins = {
1455
1721
  return result();
1456
1722
  },
1457
1723
  env: async (argv, state, input, context = {}) => {
1458
- let env = { ...state.env };
1724
+ let env = { ...externalEnvironment(state) };
1459
1725
  let i = 1;
1460
1726
  for (; i < argv.length; i++) {
1461
1727
  const item = argv[i];
@@ -1477,6 +1743,7 @@ const builtins = {
1477
1743
  const childState = {
1478
1744
  ...state,
1479
1745
  env,
1746
+ exported: new Set(Object.keys(env)),
1480
1747
  directoryHistory: [...state.directoryHistory],
1481
1748
  tabs: [...state.tabs],
1482
1749
  readonly: new Set(state.readonly),
@@ -1619,14 +1886,37 @@ const builtins = {
1619
1886
  }
1620
1887
  return result(status, "", stderr);
1621
1888
  },
1889
+ trap: async (argv,state) => setTrap(argv,state),
1622
1890
  set: async (argv, state) => {
1623
1891
  if (argv.length === 1)
1624
1892
  return result(0, Object.entries(state.env).sort().map(([k, v]) => `${k}=${JSON.stringify(v)}\n`).join(""));
1625
- if (argv[1] === "--") {
1626
- state.args = [state.args[0], ...argv.slice(2)];
1627
- return result();
1893
+ const names = {errexit:"e",nounset:"u",noglob:"f",xtrace:"x",verbose:"v",posix:"posix"};
1894
+ let i = 1;
1895
+ for (; i < argv.length; i++) {
1896
+ const item = argv[i];
1897
+ if (item === "--") { i++; break; }
1898
+ if (item === "-") {
1899
+ // Traditional set - ends options and disables verbose/xtrace. Like
1900
+ // mksh parse_args, preserve positional parameters if none follow.
1901
+ state.shellOptions.x = state.shellOptions.v = false;
1902
+ i++; break;
1903
+ }
1904
+ if (!/^[+-]/.test(item)) break;
1905
+ const enabled = item[0] === "-";
1906
+ if (item === "-o" || item === "+o") {
1907
+ if (argv[i+1] === undefined) return result(0,Object.entries(names).map(([name,key]) => enabled ? `${name} ${state.shellOptions[key] ? "on" : "off"}\n` : `set ${state.shellOptions[key] ? "-" : "+"}o ${name}\n`).join(""));
1908
+ const name = argv[++i];
1909
+ if (!names[name]) return result(2,"",`bunmsh: set: unknown option ${name}\n`);
1910
+ state.shellOptions[names[name]] = enabled;
1911
+ } else {
1912
+ for (const key of item.slice(1)) {
1913
+ if (!"eufxv".includes(key)) return result(2,"",`bunmsh: set: unknown option ${key}\n`);
1914
+ state.shellOptions[key] = enabled;
1915
+ }
1916
+ }
1628
1917
  }
1629
- return result(2, "", "bunmsh: set: option handling is not implemented yet\n");
1918
+ if (i < argv.length || argv.at(-1) === "--") state.args = [state.args[0],...argv.slice(i)];
1919
+ return result();
1630
1920
  },
1631
1921
  };
1632
1922
 
@@ -1644,6 +1934,13 @@ export function bunShellFallbackArgv(argv) {
1644
1934
  let commandArgv = argv;
1645
1935
  if (argv[0] === "ls")
1646
1936
  commandArgv = commandArgv.filter((value, index) => index === 0 || value !== "--color=auto");
1937
+ if (["mv", "rm"].includes(argv[0]) && argv.includes("--")) {
1938
+ const end = argv.indexOf("--");
1939
+ // Bun.$ mv/rm do not accept --. Protect option-looking path operands when
1940
+ // removing the delimiter, so a filename cannot become an option.
1941
+ commandArgv = [...argv.slice(0, end), ...argv.slice(end + 1).map(value =>
1942
+ value.startsWith("-") ? `./${value}` : value)];
1943
+ }
1647
1944
  if (argv[0] === "cp")
1648
1945
  commandArgv = commandArgv.map((value, index) =>
1649
1946
  index > 0 && /^-[^-]/.test(value) ? `-${value.slice(1).replaceAll("r", "R")}` : value);
@@ -1651,9 +1948,36 @@ export function bunShellFallbackArgv(argv) {
1651
1948
  }
1652
1949
 
1653
1950
  async function runBunShellFallback(argv, state) {
1951
+ if (argv[0] === "mkdir") {
1952
+ const flags = argv.slice(1).filter(arg => /^-[pv]+$/.test(arg) || ["--parents","--vebose"].includes(arg));
1953
+ const operands = argv.slice(1).filter(arg => !flags.includes(arg));
1954
+ // Bun.$ starts sibling mkdir operands concurrently. POSIX callers can
1955
+ // pass parent then child without -p, so preserve operand order here.
1956
+ if (operands.length > 1 && operands.every(arg => !arg.startsWith("-") || arg === "-")) {
1957
+ const output = [], errors = []; let status = 0;
1958
+ for (const operand of operands) {
1959
+ const r = await runBunShellFallback(["mkdir",...flags,operand],state);
1960
+ status ||= r.status; output.push(r.stdout); errors.push(r.stderr);
1961
+ }
1962
+ return result(status,concatBytes(output),concatBytes(errors));
1963
+ }
1964
+ }
1965
+ // GNU/POSIX rm -f ignores nonexistent operands, including the empty name.
1966
+ // Bun.$ instead resolves that empty name to cwd and reports a directory.
1967
+ if (argv[0] === "rm") {
1968
+ const delimiter = argv.indexOf("--");
1969
+ const options = argv.slice(1, delimiter < 0 ? undefined : delimiter)
1970
+ .filter(arg => /^-[frRi]+$/.test(arg)).join("");
1971
+ if (options.lastIndexOf("f") > options.lastIndexOf("i"))
1972
+ argv = argv.filter((arg, index) => index === 0 || arg !== "");
1973
+ }
1974
+ // Bun.$ rejects an empty operand list even with rm -f. Autoconf probes it.
1975
+ const rmFlags = argv.at(-1) === "--" ? argv.slice(1, -1) : argv.slice(1);
1976
+ if (argv[0] === "rm" && rmFlags.every(arg => /^-[frRi]+$/.test(arg)) &&
1977
+ rmFlags.join("").lastIndexOf("f") > rmFlags.join("").lastIndexOf("i")) return result();
1654
1978
  const shell = Bun.$`${bunShellFallbackArgv(argv)}`
1655
1979
  .cwd(nativePath(state.cwd))
1656
- .env(state.env)
1980
+ .env(externalEnvironment(state))
1657
1981
  .nothrow();
1658
1982
  if (state.pipelineChild) {
1659
1983
  const output = await shell;
@@ -1669,6 +1993,42 @@ async function runFancyLsFallback(argv, state) {
1669
1993
  }
1670
1994
 
1671
1995
  async function runBunShellCpFallback(argv, state) {
1996
+ let preserve=false, verbose=false, end=false;
1997
+ const files=[], unsupported=[];
1998
+ for(const arg of argv.slice(1)) {
1999
+ if(!end&&arg==='--'){end=true;continue;}
2000
+ if(!end&&arg.startsWith('-')&&arg!=='-') {
2001
+ for(const flag of arg.slice(1)) {
2002
+ if(flag==='p')preserve=true;
2003
+ else if(flag==='v')verbose=true;
2004
+ else unsupported.push(flag);
2005
+ }
2006
+ } else files.push(arg);
2007
+ }
2008
+ if(preserve) {
2009
+ if(unsupported.length)return result(1,'',`bunmsh: cp: unsupported option with -p: -${unsupported[0]}\n`);
2010
+ if(files.length<2)return result(1,'','bunmsh: cp: missing file operand\n');
2011
+ const destination=resolvePath(nativePath(state.cwd),nativePath(files.pop()));
2012
+ let directory=false;
2013
+ try {directory=statSync(destination).isDirectory();}catch(error){if(error.code!=='ENOENT')return result(1,'',`bunmsh: cp: ${error.message}\n`);}
2014
+ if(files.length>1&&!directory)return result(1,'','bunmsh: cp: destination must be a directory\n');
2015
+ let stdout='',stderr='',status=0;
2016
+ for(const file of files)try {
2017
+ const source=resolvePath(nativePath(state.cwd),nativePath(file));
2018
+ const target=directory?resolvePath(destination,pathBasename(source)):destination;
2019
+ const info=statSync(source);
2020
+ if(!info.isFile())throw new Error(`-p currently requires a regular file: ${file}`);
2021
+ let previous;try{previous=statSync(target);}catch(error){if(error.code!=='ENOENT')throw error;}
2022
+ if(previous?.dev===info.dev&&previous?.ino===info.ino)throw new Error(`${file} and destination are the same file`);
2023
+ copyFileSync(source,target);
2024
+ const copied=statSync(target);
2025
+ if(copied.uid!==info.uid||copied.gid!==info.gid)chownSync(target,info.uid,info.gid);
2026
+ chmodSync(target,info.mode&0o7777);
2027
+ utimesSync(target,info.atime,info.mtime);
2028
+ if(verbose)stdout+=`'${file}' -> '${target}'\n`;
2029
+ }catch(error){status=1;stderr+=`bunmsh: cp: ${error.message}\n`;}
2030
+ return result(status,stdout,stderr);
2031
+ }
1672
2032
  const cmd = IS_COMPILED
1673
2033
  ? [...EXECUTABLE_COMMAND, "--bun-shell-fallback", ...bunShellFallbackArgv(argv)]
1674
2034
  : [...BUN_RUNTIME_COMMAND, BUNMSH_ENTRY, "--bun-shell-fallback", ...bunShellFallbackArgv(argv)];
@@ -1676,7 +2036,7 @@ async function runBunShellCpFallback(argv, state) {
1676
2036
  cmd,
1677
2037
  cwd: nativePath(state.cwd),
1678
2038
  env: {
1679
- ...state.env,
2039
+ ...externalEnvironment(state),
1680
2040
  BUN_ENABLE_EXPERIMENTAL_SHELL_BUILTINS: "1",
1681
2041
  },
1682
2042
  stdin: "inherit",
@@ -1698,7 +2058,7 @@ async function runReflectedProcess(cmd, state) {
1698
2058
  const proc = Bun.spawn({
1699
2059
  cmd,
1700
2060
  cwd: nativePath(state.cwd),
1701
- env: state.env,
2061
+ env: externalEnvironment(state),
1702
2062
  stdin: "inherit",
1703
2063
  stdout: inherit ? "inherit" : "pipe",
1704
2064
  stderr: inherit ? "inherit" : "pipe",
@@ -1744,7 +2104,10 @@ async function runCatFallback(argv, state, input) {
1744
2104
  const chunks = [];
1745
2105
  let status = 0;
1746
2106
  let stderr = "";
1747
- const writer = state.pipelineChild ? Bun.stdout.writer() : null;
2107
+ // A subprocess still needs shell-level descriptor routing for redirects.
2108
+ const endpoint = state.fdBindings?.[1] ?? {channel:1};
2109
+ const writer = state.pipelineChild && endpoint.channel === 1 &&
2110
+ endpoint.fd === undefined && !endpoint.closed ? Bun.stdout.writer() : null;
1748
2111
  const emit = async (chunk) => {
1749
2112
  if (writer) {
1750
2113
  writer.write(chunk);
@@ -1875,6 +2238,44 @@ async function runHeadFallback(argv, state, input) {
1875
2238
  return result(0, concatBytes(output));
1876
2239
  }
1877
2240
 
2241
+ async function runUniqFallback(argv, state, input) {
2242
+ let count = false, repeated = false, single = false, i = 1;
2243
+ for (; i < argv.length; i++) {
2244
+ const arg = argv[i];
2245
+ if (arg === "--") { i++; break; }
2246
+ if (arg === "-" || !arg.startsWith("-")) break;
2247
+ if (!/^-[cdu]+$/.test(arg)) return result(1, "", `bunmsh: uniq: unsupported option: ${arg}\n`);
2248
+ count ||= arg.includes("c"); repeated ||= arg.includes("d"); single ||= arg.includes("u");
2249
+ }
2250
+ const operands = argv.slice(i);
2251
+ if (operands.length > 2) return result(1, "", "bunmsh: uniq: too many operands\n");
2252
+ try {
2253
+ const data = await readFallbackFiles([operands[0] ?? "-"], state, input);
2254
+ const output = [];
2255
+ let previous = null, occurrences = 0;
2256
+ const emit = () => {
2257
+ if (previous === null || (repeated && occurrences === 1) || (single && occurrences !== 1)) return;
2258
+ if (count) output.push(String(occurrences).padStart(7) + " ");
2259
+ output.push(previous, "\n");
2260
+ };
2261
+ for (let start = 0; start < data.length;) {
2262
+ const newline = data.indexOf(10, start);
2263
+ const end = newline < 0 ? data.length : newline;
2264
+ const line = data.subarray(start, end);
2265
+ if (previous !== null && previous.length === line.length && line.every((b, j) => b === previous[j])) occurrences++;
2266
+ else { emit(); previous = line; occurrences = 1; }
2267
+ start = end + 1;
2268
+ }
2269
+ emit();
2270
+ const bytes = concatBytes(output);
2271
+ if (operands[1] && operands[1] !== "-") {
2272
+ writeFileSync(resolvePath(state.cwd, operands[1]), bytes);
2273
+ return result(0);
2274
+ }
2275
+ return result(0, bytes);
2276
+ } catch (error) { return result(1, "", `bunmsh: uniq: ${error.message}\n`); }
2277
+ }
2278
+
1878
2279
  async function runTextFilter(argv, state, input, kind) {
1879
2280
  try {
1880
2281
  let args = argv.slice(1), reverse = false, numeric = false, unique = false;
@@ -1991,17 +2392,29 @@ function expandTrSet(value) {
1991
2392
  }
1992
2393
 
1993
2394
  async function runTrFallback(argv, _state, input) {
1994
- let del = false, i = 1;
1995
- if (argv[i] === "-d") del = true, i++;
1996
- if (!argv[i] || (!del && !argv[i + 1])) return result(1, "", "bunmsh: tr: missing operand\n");
1997
- const from = expandTrSet(argv[i++]);
1998
- const to = expandTrSet(argv[i] ?? "");
2395
+ let del = false, squeeze = false, i = 1;
2396
+ while (argv[i]?.startsWith("-") && argv[i] !== "-") {
2397
+ const option = argv[i++];
2398
+ if (option === "--") break;
2399
+ if (!/^-[ds]+$/.test(option)) return result(1, "", `bunmsh: tr: unsupported option: ${option}\n`);
2400
+ del ||= option.includes("d"); squeeze ||= option.includes("s");
2401
+ }
2402
+ const operands = argv.slice(i);
2403
+ const count = del ? (squeeze ? 2 : 1) : (squeeze && operands.length === 1 ? 1 : 2);
2404
+ if (operands.length !== count) return result(1, "", "bunmsh: tr: wrong number of operands\n");
2405
+ const from = expandTrSet(operands[0]);
2406
+ const to = expandTrSet(operands[1] ?? "");
2407
+ const translate = !del && operands.length === 2;
2408
+ if (translate && !to) return result(1, "", "bunmsh: tr: empty translation set\n");
2409
+ const squeezed = new Set(operands.length === 2 ? to : from);
1999
2410
  const text = decoder.decode(await readFallbackInput(input));
2000
- let output = "";
2411
+ let output = "", previous;
2001
2412
  for (const ch of text) {
2002
2413
  const index = from.indexOf(ch);
2003
- if (index < 0) output += ch;
2004
- else if (!del) output += to[Math.min(index, Math.max(0, to.length - 1))] ?? "";
2414
+ if (del && index >= 0) continue;
2415
+ const value = translate && index >= 0 ? to[Math.min(index, to.length - 1)] : ch;
2416
+ if (squeeze && value === previous && squeezed.has(value)) continue;
2417
+ output += value; previous = value;
2005
2418
  }
2006
2419
  return result(0, output);
2007
2420
  }
@@ -2018,7 +2431,7 @@ async function runCurlFallback(argv, state, input, context = {}) {
2018
2431
  const stderr = [];
2019
2432
  const io = {
2020
2433
  cwd: nativePath(state.cwd),
2021
- env: state.env,
2434
+ env: externalEnvironment(state),
2022
2435
  stdoutIsTTY: streamStdout && Boolean(process.stdout.isTTY),
2023
2436
  readStdin: () => readFallbackInput(input),
2024
2437
  async writeStdout(chunk) {
@@ -2148,17 +2561,24 @@ function grepFiles(operands, state, recursive) {
2148
2561
  }
2149
2562
 
2150
2563
  function grepBasicRegex(pattern) {
2151
- let output = "", inClass = false;
2564
+ let output = "", inClass = false, branchStart = true;
2152
2565
  for (let i = 0; i < pattern.length; i++) {
2153
2566
  const ch = pattern[i];
2154
- if (ch === "[") inClass = true;
2155
- if (ch === "]" && inClass) inClass = false;
2156
- if (!inClass && ch === "\\" && i + 1 < pattern.length && "+?|(){}".includes(pattern[i + 1])) {
2157
- output += pattern[++i];
2567
+ if (!inClass && ch === "\\" && i + 1 < pattern.length) {
2568
+ const next=pattern[++i];
2569
+ output += "+?|(){}".includes(next) ? next : `\\${next}`;
2570
+ branchStart = next === "(" || next === "|";
2158
2571
  continue;
2159
2572
  }
2160
- if (!inClass && "+?|(){}".includes(ch)) output += `\\${ch}`;
2573
+ if (!inClass && ch === "$") {
2574
+ const anchored = i === pattern.length-1 || pattern.startsWith("\\)",i+1) || pattern.startsWith("\\|",i+1);
2575
+ output += anchored ? "$" : "\\$";
2576
+ } else if (!inClass && ch === "^") output += branchStart ? "^" : "\\^";
2577
+ else if (!inClass && "+?|(){}".includes(ch)) output += `\\${ch}`;
2161
2578
  else output += ch;
2579
+ if (ch === "[") inClass = true;
2580
+ else if (ch === "]" && inClass) inClass = false;
2581
+ branchStart=false;
2162
2582
  }
2163
2583
  return output;
2164
2584
  }
@@ -2175,6 +2595,7 @@ function grepPosixClasses(pattern) {
2175
2595
 
2176
2596
  async function runGrepFallback(argv, state, input, context = {}) {
2177
2597
  let flags = "", colorMode = "never", i = 1;
2598
+ const patterns=[];
2178
2599
  while (argv[i]?.startsWith("-") && argv[i] !== "-") {
2179
2600
  if (argv[i] === "--") { i++; break; }
2180
2601
  const colorOption = /^--colou?r(?:=(always|auto|never))?$/.exec(argv[i]);
@@ -2183,16 +2604,24 @@ async function runGrepFallback(argv, state, input, context = {}) {
2183
2604
  i++;
2184
2605
  continue;
2185
2606
  }
2186
- if (!/^-[EFiqvnorx]+$/.test(argv[i]))
2607
+ if (argv[i] === "-e" || argv[i].startsWith("-e")) {
2608
+ const option=argv[i++]; const pattern=option.length>2 ? option.slice(2) : argv[i++];
2609
+ if (pattern === undefined) return result(2,"","bunmsh: grep: -e requires a pattern\n");
2610
+ patterns.push(...pattern.split("\n")); continue;
2611
+ }
2612
+ if (!/^-[EFiqvnorxc]+$/.test(argv[i]))
2187
2613
  return result(2, "", `bunmsh: grep: unsupported option: ${argv[i]}\n`);
2188
2614
  flags += argv[i++].slice(1);
2189
2615
  }
2190
- const pattern = argv[i++];
2191
- if (pattern === undefined) return result(2, "", "bunmsh: grep: missing pattern\n");
2616
+ if (!patterns.length) {
2617
+ const pattern=argv[i++];
2618
+ if (pattern === undefined) return result(2,"","bunmsh: grep: missing pattern\n");
2619
+ patterns.push(...pattern.split("\n"));
2620
+ }
2192
2621
  let regex;
2193
- const regexSource = flags.includes("F")
2194
- ? pattern.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&")
2195
- : grepPosixClasses(flags.includes("E") ? pattern : grepBasicRegex(pattern));
2622
+ const regexSource=patterns.map(pattern => flags.includes("F")
2623
+ ? pattern.replace(/[\\^$.*+?()[\]{}|]/g,"\\$&")
2624
+ : grepPosixClasses(flags.includes("E") ? pattern : grepBasicRegex(pattern))).map(p=>`(?:${p})`).join("|");
2196
2625
  try { regex = new RegExp(flags.includes("x") ? `^(?:${regexSource})$` : regexSource, `${flags.includes("i") ? "i" : ""}${flags.includes("o") ? "g" : ""}`); }
2197
2626
  catch (error) { return result(2, "", `bunmsh: grep: ${error.message}\n`); }
2198
2627
  const invert = flags.includes("v"), quiet = flags.includes("q"), numbers = flags.includes("n"), only = flags.includes("o");
@@ -2203,15 +2632,16 @@ async function runGrepFallback(argv, state, input, context = {}) {
2203
2632
  const highlightRegex = color && !only
2204
2633
  ? new RegExp(regex.source, regex.flags.includes("g") ? regex.flags : `${regex.flags}g`)
2205
2634
  : null;
2206
- let matched = false;
2635
+ let matched = false, count = 0;
2207
2636
  const output = [];
2208
2637
  const processLine = (line, lineNumber, label, showLabel) => {
2209
2638
  regex.lastIndex = 0;
2210
2639
  const isMatch = regex.test(line);
2211
2640
  regex.lastIndex = 0;
2212
2641
  if (isMatch === invert) return false;
2213
- matched = true;
2642
+ matched = true; count++;
2214
2643
  if (quiet) return true;
2644
+ if (flags.includes("c")) return false;
2215
2645
  const prefix = `${showLabel ? `${label}:` : ""}${numbers ? `${lineNumber}:` : ""}`;
2216
2646
  if (only) {
2217
2647
  if (invert) return false;
@@ -2234,11 +2664,13 @@ async function runGrepFallback(argv, state, input, context = {}) {
2234
2664
  const files = grepFiles(operands, state, flags.includes("r"));
2235
2665
  const showLabel = files.length > 1 || flags.includes("r");
2236
2666
  for (const file of files) {
2667
+ count=0;
2237
2668
  const lines = (await Bun.file(file.path).text()).split("\n");
2238
2669
  if (lines.at(-1) === "") lines.pop();
2239
2670
  for (let line = 0; line < lines.length; line++)
2240
2671
  if (processLine(lines[line], line + 1,
2241
2672
  implicitCwd ? file.shown.replace(/^\.\//, "") : file.shown, showLabel)) return result(0);
2673
+ if (flags.includes("c")) output.push(`${showLabel ? `${file.shown}:` : ""}${count}\n`);
2242
2674
  }
2243
2675
  } catch (error) { return result(2, "", `bunmsh: grep: ${error.message}\n`); }
2244
2676
  } else {
@@ -2258,93 +2690,10 @@ async function runGrepFallback(argv, state, input, context = {}) {
2258
2690
  }
2259
2691
  } finally { try { reader.releaseLock(); } catch {} }
2260
2692
  }
2693
+ if (!operands.length && flags.includes("c") && !quiet) output.push(`${count}\n`);
2261
2694
  return result(matched ? 0 : 1, output.join(""));
2262
2695
  }
2263
2696
 
2264
- function splitSedCommands(script) {
2265
- const commands = [];
2266
- let start = 0, escaped = false;
2267
- for (let i = 0; i < script.length; i++) {
2268
- if (escaped) { escaped = false; continue; }
2269
- if (script[i] === "\\") { escaped = true; continue; }
2270
- if (script[i] === ";") {
2271
- commands.push(script.slice(start, i).trim());
2272
- start = i + 1;
2273
- }
2274
- }
2275
- commands.push(script.slice(start).trim());
2276
- return commands.filter(Boolean);
2277
- }
2278
-
2279
- function readSedField(command, start, delimiter) {
2280
- let value = "";
2281
- for (let i = start; i < command.length; i++) {
2282
- if (command[i] === "\\" && i + 1 < command.length) {
2283
- if (command[i + 1] === delimiter) value += command[++i];
2284
- else value += command[i] + command[++i];
2285
- } else if (command[i] === delimiter) return [value, i + 1];
2286
- else value += command[i];
2287
- }
2288
- throw new Error("unterminated substitute command");
2289
- }
2290
-
2291
- function sedReplacement(value) {
2292
- let output = "";
2293
- for (let i = 0; i < value.length; i++) {
2294
- if (value[i] === "\\" && /[1-9]/.test(value[i + 1] ?? "")) output += `$${value[++i]}`;
2295
- else if (value[i] === "\\" && i + 1 < value.length) output += value[++i];
2296
- else if (value[i] === "&") output += "$&";
2297
- else if (value[i] === "$") output += "$$";
2298
- else output += value[i];
2299
- }
2300
- return output;
2301
- }
2302
-
2303
- function compileSedCommand(source, extended) {
2304
- const numberedPrint = /^(\d+)p$/.exec(source);
2305
- if (numberedPrint) return { type: "print", line: Number(numberedPrint[1]) };
2306
- if (!source.startsWith("s") || source.length < 2)
2307
- throw new Error(`unsupported command: ${source}`);
2308
- const delimiter = source[1];
2309
- const [pattern, replacementStart] = readSedField(source, 2, delimiter);
2310
- const [replacement, flagsStart] = readSedField(source, replacementStart, delimiter);
2311
- const flags = source.slice(flagsStart);
2312
- if (!/^[gp]*$/.test(flags)) throw new Error(`unsupported substitute flags: ${flags}`);
2313
- const regexSource = grepPosixClasses(extended ? pattern : grepBasicRegex(pattern));
2314
- return {
2315
- type: "substitute",
2316
- regex: new RegExp(regexSource, flags.includes("g") ? "g" : ""),
2317
- replacement: sedReplacement(replacement),
2318
- print: flags.includes("p"),
2319
- };
2320
- }
2321
-
2322
- function applySed(text, commands, quiet) {
2323
- const trailingNewline = text.endsWith("\n");
2324
- const lines = text.split("\n");
2325
- if (trailingNewline) lines.pop();
2326
- const output = [];
2327
- for (let index = 0; index < lines.length; index++) {
2328
- let line = lines[index];
2329
- for (const command of commands) {
2330
- if (command.type === "print") {
2331
- if (command.line === index + 1) output.push(line);
2332
- continue;
2333
- }
2334
- command.regex.lastIndex = 0;
2335
- const matched = command.regex.test(line);
2336
- command.regex.lastIndex = 0;
2337
- if (matched) {
2338
- line = line.replace(command.regex, command.replacement);
2339
- if (command.print) output.push(line);
2340
- }
2341
- }
2342
- if (!quiet) output.push(line);
2343
- }
2344
- if (!output.length) return "";
2345
- return output.join("\n") + (trailingNewline ? "\n" : "");
2346
- }
2347
-
2348
2697
  async function runSedFallback(argv, state, input) {
2349
2698
  let quiet = false, extended = false, inPlace = false, i = 1;
2350
2699
  const scripts = [];
@@ -2357,6 +2706,13 @@ async function runSedFallback(argv, state, input) {
2357
2706
  continue;
2358
2707
  }
2359
2708
  if (option.startsWith("-e")) { scripts.push(option.slice(2)); continue; }
2709
+ if (option.startsWith("-f")) {
2710
+ const file=option.length>2 ? option.slice(2) : argv[i++];
2711
+ if (file === undefined) return result(1,"","bunmsh: sed: -f requires a file\n");
2712
+ try { scripts.push(readFileSync(resolvePath(nativePath(state.cwd),nativePath(file)),"utf8")); }
2713
+ catch(error) { return result(1,"",`bunmsh: sed: ${error.message}\n`); }
2714
+ continue;
2715
+ }
2360
2716
  if (!/^-[nEri]+$/.test(option))
2361
2717
  return result(1, "", `bunmsh: sed: unsupported option: ${option}\n`);
2362
2718
  quiet ||= option.includes("n");
@@ -2366,19 +2722,23 @@ async function runSedFallback(argv, state, input) {
2366
2722
  if (!scripts.length && i < argv.length) scripts.push(argv[i++]);
2367
2723
  if (!scripts.length) return result(1, "", "bunmsh: sed: missing script\n");
2368
2724
  let commands;
2369
- try { commands = scripts.flatMap(splitSedCommands).map((script) => compileSedCommand(script, extended)); }
2725
+ try { commands = compileSed(scripts.join("\n"), pattern => grepPosixClasses(extended ? pattern : grepBasicRegex(pattern))); }
2370
2726
  catch (error) { return result(1, "", `bunmsh: sed: ${error.message}\n`); }
2371
2727
  const operands = argv.slice(i);
2372
2728
  if (inPlace && !operands.length) return result(1, "", "bunmsh: sed: -i requires a file operand\n");
2729
+ const readExtra = file => {
2730
+ try { return readFileSync(resolvePath(nativePath(state.cwd),nativePath(file)),"utf8"); }
2731
+ catch { return ""; } // sed r silently skips unreadable or missing files.
2732
+ };
2373
2733
  try {
2374
2734
  if (!operands.length) {
2375
2735
  const text = decoder.decode(await readFallbackInput(input));
2376
- return result(0, applySed(text, commands, quiet));
2736
+ return result(0, applySed(text, commands, quiet, readExtra));
2377
2737
  }
2378
2738
  let stdout = "";
2379
2739
  for (const operand of operands) {
2380
2740
  const file = isAbsolute(nativePath(operand)) ? nativePath(operand) : resolvePath(nativePath(state.cwd), nativePath(operand));
2381
- const changed = applySed(await Bun.file(file).text(), commands, quiet);
2741
+ const changed = applySed(await Bun.file(file).text(), commands, quiet, readExtra);
2382
2742
  if (inPlace) await Bun.write(file, changed);
2383
2743
  else stdout += changed;
2384
2744
  }
@@ -2452,16 +2812,47 @@ function runLnFallback(argv, state) {
2452
2812
  return result(status, "", stderr);
2453
2813
  }
2454
2814
 
2815
+ function symbolicFileMode(spec, stat) {
2816
+ let mode = stat.mode & 0o7777;
2817
+ // Retain the original shorthand; other symbolic forms require explicit who.
2818
+ if (spec === "+x") return mode | 0o111;
2819
+ for (const clause of spec.split(",")) {
2820
+ const match = /^([ugoa]+)((?:[+=-][rwxX]*)+)$/.exec(clause);
2821
+ if (!match) throw new Error(`unsupported mode: ${spec}`);
2822
+ const who = match[1].includes("a") ? "ugo" : match[1];
2823
+ let selected = 0, special = 0;
2824
+ for (const [name, mask, bit] of [["u",0o700,0o4000],["g",0o070,0o2000],["o",0o007,0o1000]])
2825
+ if (who.includes(name)) { selected |= mask; special |= bit; }
2826
+ for (const action of match[2].matchAll(/([+=-])([rwxX]*)/g)) {
2827
+ let bits = 0;
2828
+ for (const ch of action[2]) {
2829
+ if (ch === "r") bits |= 0o444;
2830
+ if (ch === "w") bits |= 0o222;
2831
+ if (ch === "x" || (ch === "X" && (stat.isDirectory() || (mode & 0o111)))) bits |= 0o111;
2832
+ }
2833
+ bits &= selected;
2834
+ if (action[1] === "+") mode |= bits;
2835
+ else if (action[1] === "-") mode &= ~bits;
2836
+ else {
2837
+ // GNU chmod preserves directory set-ID bits unless explicitly changed.
2838
+ const clearedSpecial = stat.isDirectory() ? special & 0o1000 : special;
2839
+ mode = (mode & ~(selected | clearedSpecial)) | bits;
2840
+ }
2841
+ }
2842
+ }
2843
+ return mode;
2844
+ }
2845
+
2455
2846
  function runChmodFallback(argv, state) {
2456
- if (argv.length < 3) return result(1, "", "bunmsh: chmod: usage: chmod MODE FILE...\n");
2457
- const mode = argv[1];
2847
+ let i = argv[1] === "--" ? 2 : 1;
2848
+ const mode = argv[i++];
2849
+ if (argv[i] === "--") i++;
2850
+ if (!mode || i >= argv.length) return result(1, "", "bunmsh: chmod: usage: chmod MODE FILE...\n");
2458
2851
  let status = 0, stderr = "";
2459
- for (const operand of argv.slice(2)) {
2852
+ for (const operand of argv.slice(i)) {
2460
2853
  const path = isAbsolute(nativePath(operand)) ? nativePath(operand) : resolvePath(nativePath(state.cwd), nativePath(operand));
2461
2854
  try {
2462
- if (/^[0-7]{3,4}$/.test(mode)) chmodSync(path, Number.parseInt(mode, 8));
2463
- else if (mode === "+x" || mode === "a+x") chmodSync(path, statSync(path).mode | 0o111);
2464
- else throw new Error(`unsupported mode: ${mode}`);
2855
+ chmodSync(path, /^[0-7]{1,4}$/.test(mode) ? Number.parseInt(mode, 8) : symbolicFileMode(mode, statSync(path)));
2465
2856
  } catch (error) { status = 1; stderr += `bunmsh: chmod: ${operand}: ${error.message}\n`; }
2466
2857
  }
2467
2858
  return result(status, "", stderr);
@@ -2582,7 +2973,7 @@ async function queryProcessList(name, state) {
2582
2973
  proc = Bun.spawn({
2583
2974
  cmd: command,
2584
2975
  cwd: nativePath(state.cwd),
2585
- env: state.env,
2976
+ env: externalEnvironment(state),
2586
2977
  stdin: "ignore",
2587
2978
  stdout: "pipe",
2588
2979
  stderr: "pipe",
@@ -2764,12 +3155,157 @@ async function runPspacFallback(argv, state) {
2764
3155
  return result(0, colorProcessTable(rows), query.stderr);
2765
3156
  }
2766
3157
 
3158
+ function runExprFallback(argv) {
3159
+ const args = argv.slice(argv[1] === "--" ? 2 : 1);
3160
+ let i = 0;
3161
+ const precedence = {"|":1,"&":2,"=":3,"!=":3,"<":3,"<=":3,">":3,">=":3,"+":4,"-":4,"*":5,"/":5,"%":5,":":6};
3162
+ const truth = value => value !== "" && !/^-?0+$/.test(value);
3163
+ const integer = value => { if (!/^-?\d+$/.test(value)) throw new Error("non-integer argument"); return BigInt(value); };
3164
+ const atom = () => {
3165
+ if (i >= args.length) throw new Error("missing operand");
3166
+ const value = args[i++];
3167
+ if (value !== "(") return value;
3168
+ const nested = expression(1);
3169
+ if (args[i++] !== ")") throw new Error("expected closing parenthesis");
3170
+ return nested;
3171
+ };
3172
+ const expression = minimum => {
3173
+ let left = atom();
3174
+ while ((precedence[args[i]] ?? 0) >= minimum) {
3175
+ const op = args[i++], right = expression(precedence[op] + 1);
3176
+ if (op === "|") left = truth(left) ? left : truth(right) ? right : "0";
3177
+ else if (op === "&") left = truth(left) && truth(right) ? left : "0";
3178
+ else if (op === ":") {
3179
+ const re = new RegExp(`^(?:${grepPosixClasses(grepBasicRegex(right))})`);
3180
+ const match = re.exec(left);
3181
+ // BRE parentheses are captures; a failed capturing match returns empty.
3182
+ const capture = /(^|[^\\])(?:\\\\)*\\\(/.test(right);
3183
+ left = capture ? (match?.[1] ?? "") : String(match?.[0].length ?? 0);
3184
+ } else if (["+","-","*","/","%"].includes(op)) {
3185
+ const a = integer(left), b = integer(right);
3186
+ if ((op === "/" || op === "%") && b === 0n) throw new Error("division by zero");
3187
+ left = String(op === "+" ? a+b : op === "-" ? a-b : op === "*" ? a*b : op === "/" ? a/b : a%b);
3188
+ } else {
3189
+ const numeric = /^-?\d+$/.test(left) && /^-?\d+$/.test(right);
3190
+ const a = numeric ? BigInt(left) : left, b = numeric ? BigInt(right) : right;
3191
+ left = String(Number(op === "=" ? a===b : op === "!=" ? a!==b : op === "<" ? a<b : op === "<=" ? a<=b : op === ">" ? a>b : a>=b));
3192
+ }
3193
+ }
3194
+ return left;
3195
+ };
3196
+ try {
3197
+ const value = expression(1);
3198
+ if (i !== args.length) throw new Error("unexpected operand");
3199
+ return result(truth(value) ? 0 : 1, `${value}\n`);
3200
+ } catch (error) { return result(2,"",`bunmsh: expr: ${error.message}\n`); }
3201
+ }
3202
+
3203
+ async function runAwkFallback(argv, state, input) {
3204
+ try {
3205
+ const scripts=[], variables={}; let i=1;
3206
+ const assignment = text => {
3207
+ const m=/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/s.exec(text);
3208
+ if (!m) throw new Error(`invalid variable assignment: ${text}`);
3209
+ return [m[1], m[2].replace(/\\([ntr\\])/g, (_,c)=>({n:"\n",t:"\t",r:"\r","\\":"\\"})[c])];
3210
+ };
3211
+ for (; i<argv.length && argv[i].startsWith("-") && argv[i]!=="-"; i++) {
3212
+ const option=argv[i]; if(option==="--") {i++;break;}
3213
+ const flag=option.slice(0,2);
3214
+ if(!["-F","-v","-f","-e"].includes(flag)) throw new Error(`unsupported option: ${option}`);
3215
+ const value=option.length>2?option.slice(2):argv[++i];
3216
+ if(value===undefined)throw new Error(`${flag} requires an argument`);
3217
+ if(flag==="-F")variables.FS=value;
3218
+ else if(flag==="-v") {const [name,text]=assignment(value);variables[name]=text;}
3219
+ else scripts.push(flag==="-f" ? readFileSync(resolvePath(nativePath(state.cwd),nativePath(value)),"utf8") : value);
3220
+ }
3221
+ if(!scripts.length){if(i>=argv.length)throw new Error("missing program");scripts.push(argv[i++]);}
3222
+ const program=compileAwk(scripts.join("\n"));
3223
+ const needsInput = node => node && typeof node==="object" && (node.type==="getline"&&!node.file ||
3224
+ (node instanceof Map ? [...node.values()] : Object.values(node)).some(needsInput));
3225
+ const operands=argv.slice(i), inputs=[];
3226
+ if(program.rules.length || needsInput(program)) {
3227
+ for (const operand of operands.length?operands:["-"]) {
3228
+ if(/^[A-Za-z_][A-Za-z0-9_]*=/.test(operand))inputs.push({assignment:assignment(operand)});
3229
+ else inputs.push({name:operand,text:operand==="-"?decode(await readFallbackInput(input)):readFileSync(resolvePath(nativePath(state.cwd),nativePath(operand)),"utf8")});
3230
+ }
3231
+ }
3232
+ const path = file => resolvePath(nativePath(state.cwd),nativePath(file));
3233
+ const value=executeAwk(program,inputs,{variables,argv:operands,env:externalEnvironment(state),
3234
+ runPipe:(command,text)=>{
3235
+ const directory=mkdtempSync(resolvePath(tmpdir(),"bunmsh-awk-state-"));
3236
+ try {
3237
+ const file=resolvePath(directory,"state.json");
3238
+ writeFileSync(file,pipelineBuiltinState(state).env[PIPELINE_STATE_ENV],{mode:0o600});
3239
+ const p=Bun.spawnSync([process.execPath,BUNMSH_ENTRY,"-c",command],{
3240
+ cwd:nativePath(state.cwd),env:{...externalEnvironment(state),[PIPELINE_STATE_ENV]:`@file:${file}`},
3241
+ stdin:bytes(text),stdout:"pipe",stderr:"pipe"});
3242
+ if(p.error)throw p.error;
3243
+ return {status:p.exitCode,stdout:decode(p.stdout),stderr:decode(p.stderr)};
3244
+ } finally {rmSync(directory,{recursive:true,force:true});}
3245
+ },
3246
+ readFile:file=>readFileSync(path(file),"utf8"),
3247
+ writeFile:(file,text,append)=>writeFileSync(path(file),text,{flag:append?"a":"w"})});
3248
+ return result(value.status,value.stdout,value.stderr??"");
3249
+ } catch(error) {return result(2,"",`bunmsh: ${error.message.startsWith("awk:")?"":"awk: "}${error.message}\n`);}
3250
+ }
3251
+
3252
+ async function runCmpFallback(argv, state, input) {
3253
+ let silent = false, i = 1;
3254
+ for (; i < argv.length && argv[i].startsWith("-") && argv[i] !== "-"; i++) {
3255
+ if (argv[i] === "--") { i++; break; }
3256
+ if (["-s", "--silent", "--quiet"].includes(argv[i])) silent = true;
3257
+ else return result(2, "", `bunmsh: cmp: unsupported option: ${argv[i]}\n`);
3258
+ }
3259
+ const files = argv.slice(i);
3260
+ if (files.length !== 2) return result(2, "", "bunmsh: cmp: expected two files\n");
3261
+ try {
3262
+ let stdin;
3263
+ const read = async file => file === "-" ? (stdin ??= await readFallbackInput(input)) :
3264
+ readFileSync(resolvePath(nativePath(state.cwd), nativePath(file)));
3265
+ const a = await read(files[0]), b = await read(files[1]);
3266
+ let offset = 0, line = 1;
3267
+ while (offset < Math.min(a.length, b.length) && a[offset] === b[offset]) {
3268
+ if (a[offset] === 10) line++;
3269
+ offset++;
3270
+ }
3271
+ if (offset === a.length && offset === b.length) return result();
3272
+ if (silent) return result(1);
3273
+ if (offset < Math.min(a.length, b.length))
3274
+ return result(1, `${files[0]} ${files[1]} differ: char ${offset + 1}, line ${line}\n`);
3275
+ const file = files[a.length < b.length ? 0 : 1];
3276
+ return result(1, "", `cmp: EOF on ${file}${offset ? ` after byte ${offset}, in line ${line}` : " which is empty"}\n`);
3277
+ } catch (error) { return result(2, "", silent ? "" : `bunmsh: cmp: ${error.message}\n`); }
3278
+ }
3279
+
3280
+ async function runDiffFallback(argv, state) {
3281
+ let brief=false, i=1;
3282
+ for (; i<argv.length && argv[i].startsWith("-"); i++) {
3283
+ if (argv[i] === "--") { i++; break; }
3284
+ if (["-q","--brief"].includes(argv[i])) brief=true;
3285
+ else if (!/^--color(?:=(?:always|auto|never))?$/.test(argv[i]))
3286
+ return result(2,"",`bunmsh: diff: unsupported option: ${argv[i]}\n`);
3287
+ }
3288
+ if (argv.length-i !== 2) return result(2,"","bunmsh: diff: expected two files\n");
3289
+ const [left,right]=argv.slice(i);
3290
+ try {
3291
+ const a=readFileSync(resolvePath(nativePath(state.cwd),nativePath(left)));
3292
+ const b=readFileSync(resolvePath(nativePath(state.cwd),nativePath(right)));
3293
+ if (a.equals(b)) return result();
3294
+ if (brief || a.includes(0) || b.includes(0)) return result(1,`Files ${left} and ${right} differ\n`);
3295
+ return result(1, Buffer.from(normalFileDiff(a.toString("latin1"), b.toString("latin1")), "latin1").toString());
3296
+ } catch(error) {return result(2,"",`bunmsh: diff: ${error.message}\n`);}
3297
+ }
3298
+
2767
3299
  const fallbackBuiltins = {
3300
+ awk: runAwkFallback,
3301
+ diff: runDiffFallback,
3302
+ cmp: runCmpFallback,
3303
+ expr: runExprFallback,
2768
3304
  basename: async (argv) => {
2769
3305
  const i = argv[1] === "--" ? 2 : 1;
2770
3306
  if (i >= argv.length || argv.length - i > 2)
2771
3307
  return result(1, "", "bunmsh: basename: usage: basename string [suffix]\n");
2772
- let name = pathBasename(argv[i]);
3308
+ let name = /^\/+$/.test(argv[i]) ? "/" : pathBasename(argv[i]);
2773
3309
  const suffix = argv[i + 1];
2774
3310
  if (suffix && suffix !== name && name.endsWith(suffix)) name = name.slice(0, -suffix.length);
2775
3311
  return result(0, `${name}\n`);
@@ -2809,11 +3345,14 @@ const fallbackBuiltins = {
2809
3345
  clear: async () => result(0, "\x1b[2J\x1b[H"),
2810
3346
  rmdir: async (argv, state) => runRmdirFallback(argv, state),
2811
3347
  mktemp: async (argv, state) => runMktempFallback(argv, state),
3348
+ uniq: runUniqFallback,
2812
3349
  sort: (argv, state, input) => runTextFilter(argv, state, input, "sort"),
2813
3350
  date: async (argv) => runDateFallback(argv),
2814
3351
  md5sum: (argv, state, input) => runHashFallback(argv, state, input, "md5"),
2815
3352
  sha256sum: (argv, state, input) => runHashFallback(argv, state, input, "sha256"),
2816
3353
  grep: runGrepFallback,
3354
+ egrep: (argv, state, input, context) => runGrepFallback(["grep", "-E", ...argv.slice(1)], state, input, context),
3355
+ fgrep: (argv, state, input, context) => runGrepFallback(["grep", "-F", ...argv.slice(1)], state, input, context),
2817
3356
  sed: runSedFallback,
2818
3357
  cut: runCutFallback,
2819
3358
  ln: async (argv, state) => runLnFallback(argv, state),
@@ -2863,8 +3402,14 @@ function formatMilliseconds(milliseconds, color = Boolean(process.stderr.isTTY))
2863
3402
  return output;
2864
3403
  }
2865
3404
 
3405
+ function externalEnvironment(state) {
3406
+ if (!state.shellOptions.posix) return state.env;
3407
+ return Object.fromEntries(Object.entries(state.env).filter(([name]) =>
3408
+ state.exported.has(name) || name === PIPELINE_STATE_ENV));
3409
+ }
3410
+
2866
3411
  export function createState(options = {}) {
2867
- const env = canonicalEnvironment({ ...process.env, ...(options.env ?? {}) });
3412
+ const env = canonicalEnvironment({ ...(options.inheritEnvironment === false ? {} : process.env), ...(options.env ?? {}) });
2868
3413
  const cwd = shellPath(options.cwd ?? process.cwd());
2869
3414
  env.PWD = cwd;
2870
3415
  return {
@@ -2879,17 +3424,29 @@ export function createState(options = {}) {
2879
3424
  mouseTracking: options.mouseTracking ?? /^(?:1|true|on|yes)$/i.test(env.BUNMSH_MOUSE ?? ""),
2880
3425
  pathSearch: options.pathSearch ?? true,
2881
3426
  readonly: new Set(options.readonly ?? []),
3427
+ exported: new Set(options.exported ?? Object.keys(env)),
2882
3428
  getoptsOffset: 1,
2883
3429
  args: options.args ?? ["bunmsh"],
2884
3430
  lastStatus: 0,
2885
3431
  exitRequested: false,
2886
3432
  exitStatus: 0,
2887
3433
  pipelineChild: options.pipelineChild ?? false,
3434
+ flow: null,
3435
+ loopDepth: 0,
3436
+ returnDepth: 0,
3437
+ fdBindings: {...(options.fdBindings ?? {})},
3438
+ ownedFds: new Set(),
3439
+ shellOptions: {...(options.shellOptions ?? {})},
3440
+ ignoreErrexit: options.ignoreErrexit ?? 0,
3441
+ traps: {},
3442
+ trapListeners: new Map(),
3443
+ pendingTraps: [],
3444
+ inTrap: false,
2888
3445
  };
2889
3446
  }
2890
3447
 
2891
3448
  function hasCompoundSyntax(source) {
2892
- if (/(^|\n)\s*(?:\(|[A-Za-z_][A-Za-z0-9_]*\s*\(\s*\)\s*\{)/.test(source)) return true;
3449
+ if (/(^|\n)\s*(?:\(|[A-Za-z_][A-Za-z0-9_]*\s*\(\s*\))/.test(source)) return true;
2893
3450
  let commandStart = true;
2894
3451
  for (const token of tokenize(source)) {
2895
3452
  if (token.type === "op") {
@@ -2898,14 +3455,15 @@ function hasCompoundSyntax(source) {
2898
3455
  }
2899
3456
  const word = token.fragments.length === 1 && token.fragments[0].quote === "none"
2900
3457
  ? token.fragments[0].text : null;
2901
- if (commandStart && ["if", "while", "until", "for", "case"].includes(word)) return true;
3458
+ if (commandStart && ["if", "while", "until", "for", "case", "{"].includes(word)) return true;
2902
3459
  if (!(commandStart && /^[A-Za-z_][A-Za-z0-9_]*=/.test(word ?? ""))) commandStart = false;
2903
3460
  }
2904
3461
  return false;
2905
3462
  }
2906
3463
 
2907
- function compoundLogicalSource(source) {
2908
- const tokens = tokenize(source);
3464
+ function compoundLogicalSource(source, firstLine = 1, sourceLines = null) {
3465
+ const continuations = new Map();
3466
+ const tokens = tokenize(source, { continuations });
2909
3467
  const breaks = new Set();
2910
3468
  let commandStart = true;
2911
3469
  const literal = (token) => token.type === "word" && token.fragments.length === 1 &&
@@ -2917,6 +3475,24 @@ function compoundLogicalSource(source) {
2917
3475
  continue;
2918
3476
  }
2919
3477
  const word = literal(token);
3478
+ // Function headers allow linebreaks before the opening brace. Braces
3479
+ // elsewhere remain ordinary arguments unless at a command boundary.
3480
+ const functionHeader = word === "{" && /[A-Za-z_][A-Za-z0-9_]*\s*\(\s*\)(?:\s|\\\r?\n)*$/.exec(source.slice(0, token.offset));
3481
+ const functionBrace = Boolean(functionHeader);
3482
+ if ((commandStart || functionBrace) && word === "{") {
3483
+ if (!functionBrace) breaks.add(token.offset);
3484
+ else breaks.add(functionHeader.index);
3485
+ if (tokens[index + 1]) breaks.add(tokens[index + 1].offset);
3486
+ commandStart = true;
3487
+ continue;
3488
+ }
3489
+ if (commandStart && word === "}") {
3490
+ breaks.add(token.offset);
3491
+ if (tokens[index + 1]?.value === ";" && tokens[index + 2] &&
3492
+ tokens[index + 2].value !== ";") breaks.add(tokens[index + 2].offset);
3493
+ commandStart = false;
3494
+ continue;
3495
+ }
2920
3496
  if (!commandStart) continue;
2921
3497
  if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word ?? "")) continue;
2922
3498
  // Like mksh's KEYWORD lexer mode, these words are special only where a
@@ -2925,34 +3501,48 @@ function compoundLogicalSource(source) {
2925
3501
  breaks.add(token.offset);
2926
3502
  if (["elif", "else", "fi", "done", "esac"].includes(word))
2927
3503
  breaks.add(token.offset);
3504
+ if (["fi", "done", "esac"].includes(word) && tokens[index + 1]?.value === ";" &&
3505
+ tokens[index + 2] && tokens[index + 2].value !== ";")
3506
+ breaks.add(tokens[index + 2].offset);
2928
3507
  if (["then", "do", "else"].includes(word) && tokens[index + 1])
2929
3508
  breaks.add(tokens[index + 1].offset);
2930
3509
  commandStart = ["if", "while", "until", "elif", "then", "do", "else"].includes(word);
2931
3510
  }
2932
- let logical = source;
2933
- for (const offset of [...breaks].sort((a, b) => b - a))
2934
- if (offset > 0 && source[offset - 1] !== "\n")
2935
- logical = `${logical.slice(0, offset)}\n${logical.slice(offset)}`;
2936
- return logical;
3511
+ let logical = "", line = firstLine, physicalIndex = 0;
3512
+ const lineNumbers = [line];
3513
+ for (let i = 0; i < source.length; i++) {
3514
+ if (breaks.has(i) && logical && !logical.endsWith("\n")) { logical += "\n"; lineNumbers.push(line); }
3515
+ if (continuations.has(i)) { i += continuations.get(i) - 1; line = sourceLines?.[++physicalIndex] ?? line + 1; continue; }
3516
+ logical += source[i];
3517
+ if (source[i] === "\n") { line = sourceLines?.[++physicalIndex] ?? line + 1; lineNumbers.push(line); }
3518
+ }
3519
+ return {logical, lineNumbers};
2937
3520
  }
2938
3521
 
2939
- // A leaf command line inside a compound body (if/while/for/case) is scanned
2940
- // one physical line at a time, but a here-document's body lives on the lines
2941
- // that follow it. Grow the line by re-tokenizing with more lines appended
2942
- // until the heredoc(s) it opens are fully resolved, so the folded lines never
2943
- // get misread as separate commands or as clauses of the surrounding compound.
2944
- function growHeredocLine(lines, index) {
3522
+ // A leaf command inside a compound body may continue across physical lines
3523
+ // inside quotes, substitutions, or a here-document. Keep those lines together
3524
+ // until mksh-style lexical tokenization says the command is complete, so
3525
+ // keywords inside a multiline word are never mistaken for compound syntax.
3526
+ function growLogicalCommand(lines, index) {
2945
3527
  const line = lines[index];
2946
- if (!line.includes("<<")) return { source: line, next: index + 1 };
2947
3528
  let end = index;
2948
3529
  let candidate = line;
2949
3530
  while (true) {
2950
3531
  try {
2951
- tokenize(candidate, { strict: true });
3532
+ const tokens = tokenize(candidate, { strict: true });
3533
+ const last = tokens.at(-1);
3534
+ if (last?.type === "op" && ["|", "&&", "||"].includes(last.value) && end + 1 < lines.length) {
3535
+ // Leave a following compound node for parseSequence to attach, but
3536
+ // grow ordinary pipelines and AND/OR continuations across newlines.
3537
+ if (!/^\s*(?:\{|if\b|for\b|while\b|until\b|case\b)/.test(lines[end + 1])) {
3538
+ candidate += `\n${lines[++end]}`;
3539
+ continue;
3540
+ }
3541
+ }
2952
3542
  return { source: candidate, next: end + 1 };
2953
3543
  } catch (error) {
2954
3544
  if (!(error instanceof ShellSyntaxError) ||
2955
- !error.message.startsWith("unterminated here-document"))
3545
+ !/^(?:unterminated (?:here-document|single quote|double quote|command substitution|parameter expansion|substitution))/.test(error.message))
2956
3546
  throw error;
2957
3547
  end++;
2958
3548
  if (end >= lines.length) throw error;
@@ -2961,8 +3551,301 @@ function growHeredocLine(lines, index) {
2961
3551
  }
2962
3552
  }
2963
3553
 
2964
- function parseCompoundScript(source) {
2965
- const lines = compoundLogicalSource(source.replace(/\\\r?\n/g, "")).split(/\r?\n/);
3554
+ // Heredoc bodies follow the operator's source position even when it is the
3555
+ // final token. Remove only the connector, never the rest of the source.
3556
+ function removeSourceToken(source, token) {
3557
+ return source.slice(0, token.offset) + source.slice(token.offset + token.value.length);
3558
+ }
3559
+
3560
+ function compoundSource(node) {
3561
+ if (node.type === "subshell") return `(${node.source})`;
3562
+ if (node.type === "compoundPostfix")
3563
+ return `${compoundSource(node.compound)} ${node.operator} ${node.right ? compoundSource(node.right) : node.command}`;
3564
+ if (typeof node.source !== "string") throw new ShellSyntaxError(`missing source for ${node.type}`);
3565
+ // A following serialized connector must not become part of a heredoc's
3566
+ // delimiter line. Braces preserve the command's scope and give it a boundary.
3567
+ if (node.type === "command" && tokenize(node.source).some(token => token.heredoc))
3568
+ return `{\n${node.source}\n}`;
3569
+ return node.source;
3570
+ }
3571
+
3572
+ function makeCompoundPostfix(compound, suffix, source, nextLine) {
3573
+ const suffixCommand = growLogicalCommand([suffix ?? "", ...source.slice(nextLine)], 0);
3574
+ suffix = suffixCommand.source;
3575
+ nextLine += suffixCommand.next - 1;
3576
+ const tokens = tokenize(suffix ?? "");
3577
+ const connector = tokens.find(t => t.type === "op" && [";","&&","||","|"].includes(t.value));
3578
+ const redirectionText = (suffix ?? "").slice(0,connector?.offset ?? undefined).trim();
3579
+ if (redirectionText && !redirectionText.startsWith("#")) {
3580
+ if (redirectionText === "&") throw new ShellSyntaxError("&: only a single external command is supported");
3581
+ const parsed = parse(`: ${redirectionText}`)[0]?.pipeline[0];
3582
+ if (!parsed || parsed.words.length !== 1) throw new ShellSyntaxError(`unexpected compound suffix: ${redirectionText} after ${compound.source.slice(0,120)}`);
3583
+ compound = {type:"redirected", compound, redirects:parsed.redirects, source:compoundSource(compound) + " " + redirectionText};
3584
+ suffix = connector ? suffix.slice(connector.offset) : "";
3585
+ }
3586
+
3587
+ const match = /^\s*(\|\||&&|\||;)\s*(.*)$/s.exec(suffix ?? "");
3588
+ if (!match) return { node: compound, nextLine };
3589
+ let command = match[2].trim();
3590
+ let commandLine = null;
3591
+ if (match[1] === ";" && (!command || command.startsWith("#"))) return { node: compound, nextLine };
3592
+ while (!command && nextLine < source.length) {
3593
+ commandLine = nextLine;
3594
+ command = source[nextLine++].trim();
3595
+ if (!command || command.startsWith("#")) command = "";
3596
+ }
3597
+ if (!command)
3598
+ throw new ShellSyntaxError(`compound command requires a command after ${match[1]}`);
3599
+ if (commandLine !== null && /^(?:\{|if\b|case\b|for\b|while\b|until\b|\()/.test(command))
3600
+ return {node:{type:"compoundPostfix",compound,operator:match[1],rightPending:true},nextLine:commandLine};
3601
+ // A postfix command can contain multiline quotes, substitutions or heredocs.
3602
+ // Grow it just like an ordinary command before resuming the outer parser.
3603
+ const remaining = [command, ...source.slice(nextLine)];
3604
+ const grown = growLogicalCommand(remaining, 0);
3605
+ command = grown.source;
3606
+ nextLine += grown.next - 1;
3607
+ const trailing = tokenize(command).at(-1);
3608
+ if (trailing?.type === "op" && ["&&", "||", "|"].includes(trailing.value)) {
3609
+ const middle = {type:"command",source:removeSourceToken(command,trailing)};
3610
+ // Pipes bind more tightly; AND/OR lists associate left-to-right.
3611
+ if (trailing.value !== "|") return {
3612
+ node:{type:"compoundPostfix",compound:{type:"compoundPostfix",compound,operator:match[1],right:middle},operator:trailing.value,rightPending:true},
3613
+ nextLine,
3614
+ };
3615
+ return {
3616
+ node: {type:"compoundPostfix", compound, operator:match[1], right:{
3617
+ type:"compoundPostfix", compound:middle,
3618
+ operator:trailing.value, rightPending:true,
3619
+ }},
3620
+ nextLine,
3621
+ };
3622
+ }
3623
+ return {
3624
+ node: {
3625
+ type: "compoundPostfix",
3626
+ compound,
3627
+ operator: match[1],
3628
+ command,
3629
+ },
3630
+ nextLine,
3631
+ };
3632
+ }
3633
+
3634
+ function findShellKeyword(source, keyword) {
3635
+ if (source.trimStart().startsWith("#")) return -1;
3636
+ let quote = null;
3637
+ for (let i = 0; i < source.length;) {
3638
+ const ch = source[i];
3639
+ if (quote === "'") {
3640
+ if (ch === "'") quote = null;
3641
+ i++;
3642
+ continue;
3643
+ }
3644
+ if (quote === '"') {
3645
+ if (ch === "\\" && /[\\"$`\n]/.test(source[i + 1] ?? "")) { i += 2; continue; }
3646
+ if (ch === '"') quote = null;
3647
+ i++;
3648
+ continue;
3649
+ }
3650
+ if (ch === "\\") { i += 2; continue; }
3651
+ if (ch === "'" || ch === '"') { quote = ch; i++; continue; }
3652
+ if (ch === "`" || (ch === "$" && source[i + 1] === "(")) {
3653
+ try { i = substitutionEnd(source, i); }
3654
+ catch { return -1; } // This physical line may continue the substitution.
3655
+ continue;
3656
+ }
3657
+ if (ch === "$" && source[i + 1] === "{") {
3658
+ try { i = parameterEnd(source, i); }
3659
+ catch { return -1; }
3660
+ continue;
3661
+ }
3662
+ if (/[A-Za-z_]/.test(ch)) {
3663
+ const start = i++;
3664
+ while (/[A-Za-z0-9_]/.test(source[i] ?? "")) i++;
3665
+ if (source.slice(start, i) === keyword) {
3666
+ let previous = start - 1;
3667
+ while (previous >= 0 && /\s/.test(source[previous])) previous--;
3668
+ if (keyword !== "esac" || previous < 0 || source[previous] === ";" || source[previous] === "\n")
3669
+ return start;
3670
+ }
3671
+ continue;
3672
+ }
3673
+ i++;
3674
+ }
3675
+ return -1;
3676
+ }
3677
+
3678
+ function splitCasePatterns(source) {
3679
+ const patterns = [];
3680
+ let start = 0;
3681
+ let quote = null;
3682
+ for (let i = 0; i < source.length; i++) {
3683
+ const ch = source[i];
3684
+ if (ch === "\\") { i++; continue; }
3685
+ if (quote) {
3686
+ if (ch === quote) quote = null;
3687
+ continue;
3688
+ }
3689
+ if (ch === "'" || ch === '"') { quote = ch; continue; }
3690
+ if (ch === "$" && (source[i + 1] === "(" || source[i + 1] === "{")) {
3691
+ i = (source[i + 1] === "(" ? substitutionEnd(source, i) : parameterEnd(source, i)) - 1;
3692
+ continue;
3693
+ }
3694
+ if (ch === "`") { i = substitutionEnd(source, i) - 1; continue; }
3695
+ if (ch === "|") { patterns.push(source.slice(start, i).trim()); start = i + 1; }
3696
+ }
3697
+ patterns.push(source.slice(start).trim());
3698
+ return patterns;
3699
+ }
3700
+
3701
+ // Scan case clauses in the same lexical spirit as mksh's CASEPAT/KEYWORD
3702
+ // modes. In particular, clause boundaries are shell tokens, not line
3703
+ // boundaries: Autoconf commonly writes an entire clause on one line.
3704
+ function shellLiteral(token) {
3705
+ return token?.type === "word" && token.fragments.length === 1 && token.fragments[0].quote === "none" ? token.fragments[0].text : null;
3706
+ }
3707
+
3708
+ function caseKeywords(tokens) {
3709
+ const words = new Map();
3710
+ let start = true;
3711
+ for (let i = 0; i < tokens.length; i++) {
3712
+ const token = tokens[i];
3713
+ let word = shellLiteral(token);
3714
+ if (token.type === "op") {
3715
+ if ([";", "|", "&&", "||"].includes(token.value)) start = true;
3716
+ continue;
3717
+ }
3718
+ // Parentheses are retained in word tokens by this tokenizer. At command
3719
+ // start they can introduce subshells without whitespace before `case`.
3720
+ const openings = start ? /^\(+/.exec(word ?? "")?.[0].length ?? 0 : 0;
3721
+ if (openings) {
3722
+ word = word.slice(openings);
3723
+ if (!word) continue;
3724
+ }
3725
+ if (start && word === "case" && shellLiteral(tokens[i + 2]) === "in") words.set(token.offset + openings, "case");
3726
+ if (start && /^esac\)*$/.test(word ?? "")) words.set(token.offset + openings, "esac");
3727
+ start = ["then", "do", "else", "{"].includes(word) || Boolean(word?.endsWith(")"));
3728
+ }
3729
+ return words;
3730
+ }
3731
+
3732
+ function parseCaseArms(source, sourceLines) {
3733
+ const arms = [];
3734
+ let i = 0;
3735
+ const skipSpace = () => {
3736
+ while (/\s/.test(source[i] ?? "")) i++;
3737
+ };
3738
+ while (i < source.length) {
3739
+ skipSpace();
3740
+ if (i >= source.length) break;
3741
+ if (source[i] === "#") {
3742
+ while (i < source.length && source[i] !== "\n") i++;
3743
+ continue;
3744
+ }
3745
+ if (source[i] === "(") { i++; skipSpace(); }
3746
+ const patternStart = i;
3747
+ let quote = null;
3748
+ let extensionDepth = 0;
3749
+ for (; i < source.length; i++) {
3750
+ const ch = source[i];
3751
+ if (ch === "\\") { i++; continue; }
3752
+ if (quote) {
3753
+ if (ch === quote) quote = null;
3754
+ continue;
3755
+ }
3756
+ if (ch === "'" || ch === '"') { quote = ch; continue; }
3757
+ if (ch === "$" && (source[i + 1] === "(" || source[i + 1] === "{")) {
3758
+ i = (source[i + 1] === "(" ? substitutionEnd(source, i) : parameterEnd(source, i)) - 1;
3759
+ continue;
3760
+ }
3761
+ if (ch === "`") { i = substitutionEnd(source, i) - 1; continue; }
3762
+ if (ch === "(" && /[?*+@!]/.test(source[i - 1] ?? "")) { extensionDepth++; continue; }
3763
+ if (ch === ")" && extensionDepth) { extensionDepth--; continue; }
3764
+ if (ch === ")") break;
3765
+ }
3766
+ if (i >= source.length)
3767
+ throw new ShellSyntaxError(`case pattern requires ): ${source.slice(patternStart, Math.min(source.length, patternStart + 120))}`);
3768
+ const patterns = splitCasePatterns(source.slice(patternStart, i));
3769
+ if (!patterns.length || patterns.some((pattern) => !pattern))
3770
+ throw new ShellSyntaxError("case requires a pattern");
3771
+ i++;
3772
+
3773
+ const bodyStart = i;
3774
+ let nestedCases = 0;
3775
+ let terminator = null;
3776
+ const bodyTokens = tokenize(source.slice(bodyStart));
3777
+ const keywords = caseKeywords(bodyTokens);
3778
+ i = source.length;
3779
+ for (let t = 0; t < bodyTokens.length; t++) {
3780
+ const token = bodyTokens[t];
3781
+ const word = keywords.get(token.offset);
3782
+ if (word === "case") nestedCases++;
3783
+ else if (word === "esac" && nestedCases) nestedCases--;
3784
+ if (!nestedCases && token.type === "op" && token.value === ";") {
3785
+ const offset = bodyStart + token.offset;
3786
+ if ([";", "&", "|"].includes(source[offset + 1]) && source[offset] === ";") {
3787
+ terminator = source.slice(offset, offset + 2);
3788
+ i = offset;
3789
+ break;
3790
+ }
3791
+ }
3792
+ }
3793
+ // POSIX allows the final clause to omit `;;` immediately before esac.
3794
+ if (!terminator) {
3795
+ if (source.slice(bodyStart, i).trim() || arms.length) terminator = ";;";
3796
+ else throw new ShellSyntaxError("unterminated case clause");
3797
+ }
3798
+ const bodySource = source.slice(bodyStart, i);
3799
+ let body;
3800
+ try {
3801
+ const first = source.slice(0,bodyStart).split("\n").length - 1;
3802
+ body = parseCompoundScript(bodySource, sourceLines?.[first] ?? 1, sourceLines?.slice(first));
3803
+ } catch (error) {
3804
+ if (error instanceof ShellSyntaxError)
3805
+ error.message += ` in case arm ${patterns.join("|")}`;
3806
+ throw error;
3807
+ }
3808
+ arms.push({ patterns, body, terminator });
3809
+ if (i < source.length) i += 2;
3810
+ }
3811
+ return arms;
3812
+ }
3813
+
3814
+ function readCompoundHeader(lines, start, keyword) {
3815
+ let source = "";
3816
+ for (let end = start; end < lines.length; end++) {
3817
+ source += (end === start ? "" : "\n") + lines[end];
3818
+ let tokens;
3819
+ try { tokens = tokenize(source, {strict:true}); } catch (error) {
3820
+ if (error.message.startsWith("unterminated")) continue;
3821
+ throw error;
3822
+ }
3823
+ const stack = [];
3824
+ let commandStart = true;
3825
+ for (let index = 1; index < tokens.length; index++) {
3826
+ const token = tokens[index];
3827
+ if (token.type === "op") {
3828
+ if ([";", "&&", "||", "|"].includes(token.value)) commandStart = true;
3829
+ continue;
3830
+ }
3831
+ const word = token.fragments.length === 1 && token.fragments[0].quote === "none" ? token.fragments[0].text : null;
3832
+ if (commandStart && word === keyword && !stack.length) {
3833
+ return {condition:source.slice(tokens[0].offset + tokens[0].fragments[0].text.length, token.offset).trim(),
3834
+ tail:source.slice(token.offset + keyword.length).trim(), end};
3835
+ }
3836
+ if (commandStart && ["{", "if", "case", "while", "until", "for"].includes(word)) {
3837
+ stack.push(word === "{" ? "}" : word === "if" ? "fi" : word === "case" ? "esac" : "done");
3838
+ } else if (word === stack.at(-1)) stack.pop();
3839
+ if (commandStart && /^[A-Za-z_][A-Za-z0-9_]*=/.test(word ?? "")) continue;
3840
+ commandStart = ["{", "if", "while", "until", "then", "do", "else", "elif"].includes(word);
3841
+ }
3842
+ }
3843
+ throw new ShellSyntaxError(`unterminated condition: expected ${keyword}`);
3844
+ }
3845
+
3846
+ export function parseCompoundScript(source, firstLine = 1, sourceLines = null) {
3847
+ const {logical, lineNumbers} = compoundLogicalSource(source, firstLine, sourceLines);
3848
+ const lines = logical.split(/\r?\n/);
2966
3849
  const parseSequence = (start, stops = []) => {
2967
3850
  const nodes = [];
2968
3851
  let i = start;
@@ -2972,122 +3855,177 @@ function parseCompoundScript(source) {
2972
3855
  if (stops.some((stop) => stop.test(trimmed))) break;
2973
3856
  if (!trimmed || trimmed.startsWith("#")) { i++; continue; }
2974
3857
 
2975
- const functionMatch = /^([A-Za-z_][A-Za-z0-9_]*)\s*\(\s*\)\s*\{\s*$/.exec(trimmed);
3858
+ const functionMatch = /^([A-Za-z_][A-Za-z0-9_]*)\s*\(\s*\)\s*(\{)?\s*(?:#.*)?$/.exec(trimmed);
2976
3859
  if (functionMatch) {
2977
- const body = parseSequence(i + 1, [/^}\s*;?$/]);
3860
+ if (!functionMatch[2]) {
3861
+ do { i++; } while (i < lines.length && (!lines[i].trim() || lines[i].trim().startsWith("#")));
3862
+ if (lines[i]?.trim() !== "{") throw new ShellSyntaxError(`unterminated function ${functionMatch[1]}: expected {`);
3863
+ }
3864
+ const body = parseSequence(i + 1, [/^}(?:\s|;|$)/]);
2978
3865
  if (body.index >= lines.length) throw new ShellSyntaxError(`unterminated function ${functionMatch[1]}`);
2979
3866
  nodes.push({ type: "function", name: functionMatch[1], body: body.nodes });
2980
3867
  i = body.index + 1;
2981
3868
  continue;
2982
3869
  }
2983
3870
 
2984
- const subshellMatch = /^\((.*)\)\s*;?$/.exec(trimmed);
2985
- if (subshellMatch) {
2986
- nodes.push({ type: "subshell", source: subshellMatch[1] });
2987
- i++;
3871
+ if (trimmed === "{") {
3872
+ const body = parseSequence(i + 1, [/^}(?:\s|;|$)/]);
3873
+ if (body.index >= lines.length) throw new ShellSyntaxError(`unterminated brace group near ${lines.slice(i, i + 5).join("\n")}`);
3874
+ const compound = { type: "group", body: body.nodes, source: [...lines.slice(i, body.index), "}"].join("\n") };
3875
+ const postfix = makeCompoundPostfix(compound, lines[body.index].trim().slice(1), lines, body.index + 1);
3876
+ nodes.push(postfix.node);
3877
+ i = postfix.nextLine;
2988
3878
  continue;
2989
3879
  }
2990
3880
 
3881
+ if (trimmed.startsWith("(")) {
3882
+ let end=i, text=line, group=leadingSubshell(text);
3883
+ while (!group && ++end < lines.length) { text += `\n${lines[end]}`; group=leadingSubshell(text); }
3884
+ if (!group) throw new ShellSyntaxError("unterminated subshell");
3885
+ // Preserve the existing rewriteSubshells -> external Bun path for &.
3886
+ if (group.suffix.trim() === "&") {
3887
+ nodes.push({type:"command",source:text,lineNumber:lineNumbers[i]}); i=end+1; continue;
3888
+ }
3889
+ const compound={type:"subshell",source:group.body};
3890
+ const postfix=makeCompoundPostfix(compound,group.suffix,lines,end+1);
3891
+ nodes.push(postfix.node); i=postfix.nextLine; continue;
3892
+ }
3893
+
2991
3894
  if (/^if(?:\s|$)/.test(trimmed)) {
3895
+ const compoundStart = i;
2992
3896
  const branches = [];
2993
- let header = trimmed;
2994
- while (!/;\s*then\s*$/.test(header) && ++i < lines.length)
2995
- header += ` ${lines[i].trim()}`;
2996
- if (!/;\s*then\s*$/.test(header)) throw new ShellSyntaxError("if requires then");
2997
- let condition = header.replace(/^if\s*/, "").replace(/;\s*then\s*$/, "");
2998
- i++;
3897
+ let header = readCompoundHeader(lines, i, "then");
3898
+ let condition = header.condition;
3899
+ let thenCommand = header.tail;
3900
+ i = header.end + 1;
2999
3901
  while (true) {
3000
- const body = parseSequence(i, [/^elif(?:\s|$)/, /^else\s*;?$/, /^fi\s*;?$/]);
3001
- branches.push({ condition, body: body.nodes });
3902
+ const body = parseSequence(i, [/^elif(?:\s|$)/, /^else(?:\s*;\s*|\s+|$)(?:#.*)?$/, /^fi\b/]);
3903
+ branches.push({ condition, body: body.nodes, thenCommand });
3002
3904
  i = body.index;
3003
3905
  const stop = lines[i]?.trim() ?? "";
3004
3906
  if (/^elif(?:\s|$)/.test(stop)) {
3005
- header = stop;
3006
- while (!/;\s*then\s*$/.test(header) && ++i < lines.length)
3007
- header += ` ${lines[i].trim()}`;
3008
- if (!/;\s*then\s*$/.test(header)) throw new ShellSyntaxError("elif requires then");
3009
- condition = header.replace(/^elif\s*/, "").replace(/;\s*then\s*$/, "");
3010
- i++;
3907
+ header = readCompoundHeader(lines, i, "then");
3908
+ condition = header.condition;
3909
+ thenCommand = header.tail;
3910
+ i = header.end + 1;
3011
3911
  continue;
3012
3912
  }
3013
3913
  let otherwise = [];
3014
- if (/^else\s*;?$/.test(stop)) {
3015
- const body = parseSequence(i + 1, [/^fi\s*;?$/]);
3914
+ if (/^else(?:\s*;\s*|\s+|$)(?:#.*)?$/.test(stop)) {
3915
+ const body = parseSequence(i + 1, [/^fi\b/]);
3016
3916
  otherwise = body.nodes;
3017
3917
  i = body.index;
3018
3918
  }
3019
- if (!/^fi\s*;?$/.test(lines[i]?.trim() ?? ""))
3919
+ if (!/^fi\b/.test(lines[i]?.trim() ?? ""))
3020
3920
  throw new ShellSyntaxError("unterminated if");
3021
- nodes.push({ type: "if", branches, otherwise });
3022
- i++;
3921
+ const compound = { type: "if", branches, otherwise };
3922
+ const close = /^fi\b(.*)$/.exec(lines[i].trim());
3923
+ const result = makeCompoundPostfix(
3924
+ { ...compound, source: [...lines.slice(compoundStart, i), "fi"].join("\n") },
3925
+ close?.[1] ?? "",
3926
+ lines,
3927
+ i + 1,
3928
+ );
3929
+ nodes.push(result.node);
3930
+ i = result.nextLine;
3023
3931
  break;
3024
3932
  }
3025
3933
  continue;
3026
3934
  }
3027
3935
  if (/^(?:while|until)(?:\s|$)/.test(trimmed)) {
3936
+ const compoundStart = i;
3028
3937
  const kind = trimmed.startsWith("until") ? "until" : "while";
3029
3938
  let header = trimmed;
3030
- while (!/;\s*do\s*$/.test(header) && ++i < lines.length)
3939
+ while (!/(?:;\s*|\s+)do\s*$/.test(header) && ++i < lines.length)
3031
3940
  header += ` ${lines[i].trim()}`;
3032
- if (!/;\s*do\s*$/.test(header)) throw new ShellSyntaxError(`${kind} requires do`);
3033
- const condition = header.replace(new RegExp(`^${kind}\\s*`), "").replace(/;\s*do\s*$/, "");
3034
- const body = parseSequence(i + 1, [/^done(?:\s*<\s*.+)?\s*;?$/]);
3035
- const done = /^done(?:\s*<\s*(.+?))?\s*;?$/.exec(lines[body.index]?.trim() ?? "");
3941
+ if (!/(?:;\s*|\s+)do\s*$/.test(header)) throw new ShellSyntaxError(`${kind} requires do`);
3942
+ const condition = header.replace(new RegExp(`^${kind}\\s*`), "").replace(/(?:;\s*|\s+)do\s*$/, "");
3943
+ const body = parseSequence(i + 1, [/^done\b/]);
3944
+ const done = /^done(?=$|[\s;<|&])(?:\s*<\s*(.+?))?\s*;?\s*((?:\|\||&&|\||;|#).*)?$/.exec(lines[body.index]?.trim() ?? "");
3036
3945
  if (!done)
3037
3946
  throw new ShellSyntaxError(`unterminated ${kind}`);
3038
- nodes.push({ type: "loop", kind, condition, body: body.nodes, input: done[1] ?? null });
3039
- i = body.index + 1;
3947
+ const compound = {
3948
+ type: "loop", kind, condition, body: body.nodes, input: done[1] ?? null,
3949
+ source: [...lines.slice(compoundStart, body.index), `done${done[1] ? ` < ${done[1]}` : ""}`].join("\n"),
3950
+ };
3951
+ const result = makeCompoundPostfix(compound, done[2] ?? "", lines, body.index + 1);
3952
+ nodes.push(result.node);
3953
+ i = result.nextLine;
3040
3954
  continue;
3041
3955
  }
3042
3956
  if (/^for(?:\s|$)/.test(trimmed)) {
3957
+ const compoundStart = i;
3043
3958
  let header = trimmed;
3044
- while (!/;\s*do\s*$/.test(header) && ++i < lines.length)
3959
+ let bodyStart = -1;
3960
+ while (i < lines.length) {
3961
+ if (/;\s*do\s*$/.test(header)) {
3962
+ header = header.replace(/;\s*do\s*$/, "");
3963
+ bodyStart = i + 1;
3964
+ break;
3965
+ }
3966
+ if (lines[i + 1]?.trim() === "do") {
3967
+ bodyStart = i + 2;
3968
+ i++;
3969
+ break;
3970
+ }
3971
+ if (++i >= lines.length) break;
3045
3972
  header += ` ${lines[i].trim()}`;
3046
- const match = /^for\s+([A-Za-z_][A-Za-z0-9_]*)(?:\s+in\s+(.*?))?\s*;\s*do\s*$/.exec(header);
3973
+ }
3974
+ const match = /^for\s+([A-Za-z_][A-Za-z0-9_]*)(?:\s+in(?:\s+(.*?))?)?\s*;?$/.exec(header);
3047
3975
  if (!match) throw new ShellSyntaxError("invalid for command");
3048
- const body = parseSequence(i + 1, [/^done\s*;?$/]);
3049
- if (!/^done\s*;?$/.test(lines[body.index]?.trim() ?? ""))
3050
- throw new ShellSyntaxError("unterminated for");
3051
- nodes.push({ type: "for", name: match[1], words: match[2] ?? null, body: body.nodes });
3052
- i = body.index + 1;
3976
+ if (bodyStart < 0) throw new ShellSyntaxError("for requires do");
3977
+ const body = parseSequence(bodyStart, [/^done\b/]);
3978
+ if (!/^done\b/.test(lines[body.index]?.trim() ?? ""))
3979
+ throw new ShellSyntaxError(`unterminated for: ${lines[compoundStart]?.trim()} (stopped at ${lines[body.index]?.trim()})`);
3980
+ const compound = {
3981
+ type: "for", name: match[1], words: match[2] ?? null, body: body.nodes,
3982
+ source: [...lines.slice(compoundStart, body.index), "done"].join("\n"),
3983
+ };
3984
+ const close = /^done\b(.*)$/.exec(lines[body.index].trim());
3985
+ const result = makeCompoundPostfix(compound, close?.[1] ?? "", lines, body.index + 1);
3986
+ nodes.push(result.node);
3987
+ i = result.nextLine;
3053
3988
  continue;
3054
3989
  }
3055
3990
  if (/^case(?:\s|$)/.test(trimmed)) {
3056
- let header = trimmed;
3057
- while (!/\s+in\s*$/.test(header) && ++i < lines.length)
3058
- header += ` ${lines[i].trim()}`;
3059
- const match = /^case\s+(.+?)\s+in\s*$/.exec(header);
3060
- if (!match) throw new ShellSyntaxError("case requires in");
3061
- const arms = [];
3062
- i++;
3063
- while (i < lines.length && !/^esac\s*;?$/.test(lines[i].trim())) {
3064
- if (!lines[i].trim() || lines[i].trim().startsWith("#")) { i++; continue; }
3065
- const arm = /^(.+?)\)\s*$/.exec(lines[i].trim());
3066
- if (!arm) throw new ShellSyntaxError("case pattern requires )");
3067
- const bodyLines = [];
3068
- i++;
3069
- while (i < lines.length && !/^esac\s*;?$/.test(lines[i].trim())) {
3070
- const current = lines[i];
3071
- const terminator = current.indexOf(";;");
3072
- if (terminator >= 0) {
3073
- if (current.slice(0, terminator).trim()) bodyLines.push(current.slice(0, terminator));
3074
- i++;
3075
- break;
3076
- }
3077
- bodyLines.push(current);
3078
- i++;
3991
+ const remaining = lines.slice(i).join("\n");
3992
+ const headerTokens=tokenize(remaining).slice(0,3);
3993
+ if (headerTokens[2]?.fragments?.map(f=>f.text).join("") !== "in")
3994
+ throw new ShellSyntaxError(`case requires in: ${remaining.slice(0,180)}`);
3995
+ const caseWord=remaining.slice(headerTokens[1].offset,headerTokens[2].offset).trim();
3996
+ const start = headerTokens[2].offset + 2;
3997
+ let depth = 0, end = -1;
3998
+ for (const [offset, word] of caseKeywords(tokenize(remaining.slice(start)))) {
3999
+ if (word === "case") depth++;
4000
+ if (word === "esac") {
4001
+ if (!depth) { end = start + offset; break; }
4002
+ depth--;
3079
4003
  }
3080
- arms.push({ patterns: arm[1].split("|").map((pattern) => pattern.trim()), body: parseCompoundScript(bodyLines.join("\n")) });
3081
4004
  }
3082
- if (!/^esac\s*;?$/.test(lines[i]?.trim() ?? "")) throw new ShellSyntaxError("unterminated case");
3083
- nodes.push({ type: "case", word: match[1], arms });
3084
- i++;
4005
+ if (end < 0) throw new ShellSyntaxError(`unterminated case: ${trimmed}`);
4006
+ const compound = {type:"case", word:caseWord, arms:parseCaseArms(remaining.slice(start,end),lineNumbers.slice(i + remaining.slice(0,start).split("\n").length - 1)), source:remaining.slice(0,end+4)};
4007
+ const nextLine = i + remaining.slice(0,end+4).split("\n").length;
4008
+ const suffix = remaining.slice(end+4).split("\n")[0];
4009
+ const postfix = makeCompoundPostfix(compound,suffix,lines,nextLine);
4010
+ nodes.push(postfix.node);
4011
+ i = postfix.nextLine;
3085
4012
  continue;
3086
4013
  }
3087
- const grown = growHeredocLine(lines, i);
3088
- nodes.push({ type: "command", source: grown.source });
4014
+ const grown = growLogicalCommand(lines, i);
4015
+ nodes.push({ type: "command", source: grown.source, lineNumber:lineNumbers[i], sourceLines:lineNumbers.slice(i,grown.next) });
3089
4016
  i = grown.next;
3090
4017
  }
4018
+ for (let n = nodes.length - 2; n >= 0; n--) {
4019
+ let pending = nodes[n];
4020
+ while (pending.right) pending = pending.right;
4021
+ if (pending.rightPending) {
4022
+ pending.right = nodes[n+1]; delete pending.rightPending; nodes.splice(n+1,1); continue;
4023
+ }
4024
+ if (nodes[n].type !== "command") continue;
4025
+ const last = tokenize(nodes[n].source).at(-1);
4026
+ if (last?.type !== "op" || !["&&", "||", "|"].includes(last.value)) continue;
4027
+ nodes.splice(n,2,{type:"compoundPostfix",compound:{...nodes[n],source:removeSourceToken(nodes[n].source,last)},operator:last.value,right:nodes[n+1]});
4028
+ }
3091
4029
  return { nodes, index: i };
3092
4030
  };
3093
4031
  const parsed = parseSequence(0);
@@ -3124,39 +4062,119 @@ export function needsMoreInput(source) {
3124
4062
  return false;
3125
4063
  }
3126
4064
 
4065
+ // Buffered compound pipelines need the same descriptor routing as leaf commands.
4066
+ function routeCommandOutput(execution, bindings) {
4067
+ const output = [], errors = [];
4068
+ for (const [number, data] of [[1, execution.stdout], [2, execution.stderr]]) {
4069
+ if (!data.byteLength) continue;
4070
+ const endpoint = bindings[number] ?? {channel:number};
4071
+ if (endpoint.closed) { execution.status = 1; errors.push(bytes("bunmsh: bad file descriptor\n")); }
4072
+ else if (endpoint.fd !== undefined) writeSync(endpoint.fd, data);
4073
+ else (endpoint.channel === 2 ? errors : output).push(data);
4074
+ }
4075
+ execution.stdout = concatBytes(output);
4076
+ execution.stderr = concatBytes(errors);
4077
+ return execution;
4078
+ }
4079
+
3127
4080
  async function executeNodeList(nodes, state, options = {}) {
3128
4081
  const capture = options.capture ?? true;
3129
4082
  const stdout = [], stderr = [];
3130
- let execution = result(state.lastStatus);
4083
+ let execution = result();
3131
4084
  const append = (value) => {
3132
4085
  if (!capture) return;
3133
4086
  if (value.stdout.byteLength) stdout.push(value.stdout);
3134
4087
  if (value.stderr.byteLength) stderr.push(value.stderr);
3135
4088
  };
3136
4089
  for (const node of nodes) {
4090
+ if (state.exitRequested || state.flow) break;
4091
+ append(await runPendingTraps(state,{capture}));
3137
4092
  if (state.exitRequested) break;
4093
+ if (node.type === "redirected") {
4094
+ execution = await runCommand({words:parse(":")[0].pipeline[0].words,redirects:node.redirects,body:[node.compound]},state,{captureStdout:capture,captureStderr:capture,input:options.input});
4095
+ append(execution);
4096
+ if (!capture) {
4097
+ if (execution.stdout.byteLength) await writeStream(process.stdout,execution.stdout);
4098
+ if (execution.stderr.byteLength) await writeStream(process.stderr,execution.stderr);
4099
+ }
4100
+ continue;
4101
+ }
4102
+ if (node.type === "group") {
4103
+ execution = await executeNodeList(node.body, state, options);
4104
+ append(execution);
4105
+ continue;
4106
+ }
4107
+ if (node.type === "compoundPostfix") {
4108
+ if (node.operator === "|") {
4109
+ const link = new TransformStream();
4110
+ const leftPromise = runExternal(
4111
+ pipelineCompoundArgv(compoundSource(node.compound)),
4112
+ pipelineBuiltinState(state),
4113
+ options.input ?? null,
4114
+ false,
4115
+ true,
4116
+ link.writable,
4117
+ { pipelineKillSignal: "SIGTERM" },
4118
+ );
4119
+ const rightState = {
4120
+ ...state,
4121
+ env: { ...state.env },
4122
+ aliases: { ...state.aliases },
4123
+ functions: { ...state.functions },
4124
+ readonly: new Set(state.readonly),
4125
+ exported: new Set(state.exported),
4126
+ directoryHistory: [...state.directoryHistory],
4127
+ tabs: [...state.tabs],
4128
+ exitRequested: false,
4129
+ exitStatus: 0,
4130
+ };
4131
+ const right = await runExternal(
4132
+ pipelineCompoundArgv(node.right ? compoundSource(node.right) : node.command),
4133
+ pipelineBuiltinState(rightState),
4134
+ link.readable,
4135
+ true,
4136
+ true,
4137
+ );
4138
+ const left = await leftPromise;
4139
+ execution = routeCommandOutput(result(right.status, right.stdout, concatBytes([left.stderr, right.stderr])), state.fdBindings ?? {});
4140
+ } else {
4141
+ const previousIgnore = state.ignoreErrexit;
4142
+ if (["&&","||"].includes(node.operator)) state.ignoreErrexit++;
4143
+ try { execution = await executeNodeList([node.compound], state, { capture }); }
4144
+ finally { state.ignoreErrexit=previousIgnore; }
4145
+ append(execution);
4146
+ const shouldRun = node.operator === "&&"
4147
+ ? execution.status === 0
4148
+ : node.operator === "||"
4149
+ ? execution.status !== 0
4150
+ : true;
4151
+ if (shouldRun && !state.flow && !state.exitRequested) {
4152
+ execution = node.right ? await executeNodeList([node.right],state,{capture}) : await execute(node.command, state, { capture });
4153
+ append(execution);
4154
+ }
4155
+ state.lastStatus = execution.status;
4156
+ continue;
4157
+ }
4158
+ state.lastStatus = execution.status;
4159
+ if (capture) append(execution);
4160
+ else {
4161
+ if (execution.stdout.byteLength) stdout.push(execution.stdout);
4162
+ if (execution.stderr.byteLength) stderr.push(execution.stderr);
4163
+ }
4164
+ continue;
4165
+ }
3138
4166
  if (node.type === "function") {
3139
4167
  state.functions[node.name] = node.body;
4168
+ state.lastStatus = 0; execution = result();
3140
4169
  continue;
3141
4170
  }
3142
4171
  if (node.type === "command") {
3143
- execution = await execute(node.source, state, { capture, simple: true });
4172
+ execution = await execute(node.source, state, { capture, simple: true, firstLine:node.lineNumber, sourceLines:node.sourceLines });
3144
4173
  append(execution);
3145
4174
  continue;
3146
4175
  }
3147
4176
  if (node.type === "subshell") {
3148
- const child = {
3149
- ...state,
3150
- env: { ...state.env },
3151
- aliases: { ...state.aliases },
3152
- functions: { ...state.functions },
3153
- readonly: new Set(state.readonly),
3154
- directoryHistory: [...state.directoryHistory],
3155
- tabs: [...state.tabs],
3156
- exitRequested: false,
3157
- exitStatus: 0,
3158
- };
3159
- execution = await execute(node.source, child, { capture });
4177
+ execution = await execute(`(${node.source})`,state,{capture,input:options.input});
3160
4178
  append(execution);
3161
4179
  state.lastStatus = execution.status;
3162
4180
  continue;
@@ -3164,6 +4182,7 @@ async function executeNodeList(nodes, state, options = {}) {
3164
4182
  if (node.type === "loop") {
3165
4183
  const previousReadLines = state.readLines;
3166
4184
  let ranBody = false;
4185
+ state.loopDepth++;
3167
4186
  try {
3168
4187
  if (node.input !== null) {
3169
4188
  const parsed = parse(`__input ${node.input}`)[0]?.pipeline[0];
@@ -3174,7 +4193,7 @@ async function executeNodeList(nodes, state, options = {}) {
3174
4193
  if (state.readLines.at(-1) === "") state.readLines.pop();
3175
4194
  }
3176
4195
  while (true) {
3177
- const checked = await execute(node.condition, state, { capture, simple: true });
4196
+ const checked = await execute(node.condition, state, { capture, errexitIgnored:true });
3178
4197
  append(checked);
3179
4198
  const proceed = node.kind === "until" ? checked.status !== 0 : checked.status === 0;
3180
4199
  if (!proceed || state.exitRequested) {
@@ -3184,12 +4203,13 @@ async function executeNodeList(nodes, state, options = {}) {
3184
4203
  execution = await executeNodeList(node.body, state, { capture });
3185
4204
  ranBody = true;
3186
4205
  append(execution);
3187
- if (state.exitRequested) break;
4206
+ if (state.exitRequested || consumeLoopFlow(state)) break;
3188
4207
  }
3189
4208
  } catch (error) {
3190
4209
  execution = result(1, "", `bunmsh: ${error.message}\n`);
3191
4210
  append(execution);
3192
4211
  } finally {
4212
+ state.loopDepth--;
3193
4213
  state.readLines = previousReadLines;
3194
4214
  }
3195
4215
  state.lastStatus = execution.status;
@@ -3204,13 +4224,14 @@ async function executeNodeList(nodes, state, options = {}) {
3204
4224
  for (const word of parsed?.words.slice(1) ?? []) values.push(...await expandWord(word, state));
3205
4225
  }
3206
4226
  execution = result();
3207
- for (const value of values) {
4227
+ state.loopDepth++;
4228
+ try { for (const value of values) {
3208
4229
  if (state.readonly.has(node.name)) { execution = readonlyError(node.name); break; }
3209
4230
  state.env[node.name] = value;
3210
4231
  execution = await executeNodeList(node.body, state, { capture });
3211
4232
  append(execution);
3212
- if (state.exitRequested) break;
3213
- }
4233
+ if (state.exitRequested || consumeLoopFlow(state)) break;
4234
+ } } finally { state.loopDepth--; }
3214
4235
  state.lastStatus = execution.status;
3215
4236
  continue;
3216
4237
  }
@@ -3218,29 +4239,37 @@ async function executeNodeList(nodes, state, options = {}) {
3218
4239
  const parsed = parse(`__case ${node.word}`)[0]?.pipeline[0];
3219
4240
  const value = (await expandWord(parsed.words[1], state, { single: true }))[0] ?? "";
3220
4241
  execution = result();
4242
+ let fallThrough = false;
3221
4243
  for (const arm of node.arms) {
3222
- let matches = false;
3223
- for (let pattern of arm.patterns) {
3224
- pattern = pattern.replace(/^(['"])(.*)\1$/, "$2");
3225
- pattern = stripExpansionMarkers(await expandText(pattern, state));
3226
- if (globPatternRegex(pattern, true, true, true).test(value)) { matches = true; break; }
4244
+ let matches = fallThrough;
4245
+ if (!matches) for (const pattern of arm.patterns) {
4246
+ if ((await casePatternRegex(pattern, state)).test(value)) { matches = true; break; }
3227
4247
  }
3228
4248
  if (!matches) continue;
3229
4249
  execution = await executeNodeList(arm.body, state, { capture });
3230
4250
  append(execution);
4251
+ if (arm.terminator === ";&") { fallThrough = true; continue; }
4252
+ if (arm.terminator === ";|") { fallThrough = false; continue; }
3231
4253
  break;
3232
4254
  }
3233
4255
  state.lastStatus = execution.status;
3234
4256
  continue;
3235
4257
  }
3236
4258
  let selected = null;
4259
+ let selectedThen = "";
3237
4260
  for (const branch of node.branches) {
3238
4261
  let condition = branch.condition.trim();
3239
- let negate = false;
3240
- while (/^!\s+/.test(condition)) { negate = !negate; condition = condition.replace(/^!\s+/, ""); }
3241
- const checked = await execute(condition, state, { capture, simple: true });
4262
+ const checked = await execute(condition, state, { capture, errexitIgnored:true });
3242
4263
  append(checked);
3243
- if ((checked.status === 0) !== negate) { selected = branch.body; break; }
4264
+ if (checked.status === 0) {
4265
+ selected = branch.body;
4266
+ selectedThen = branch.thenCommand ?? "";
4267
+ break;
4268
+ }
4269
+ }
4270
+ if (selectedThen) {
4271
+ const initial = await execute(selectedThen, state, { capture });
4272
+ append(initial);
3244
4273
  }
3245
4274
  execution = selected
3246
4275
  ? await executeNodeList(selected, state, { capture })
@@ -3253,9 +4282,17 @@ async function executeNodeList(nodes, state, options = {}) {
3253
4282
 
3254
4283
  async function executeFunction(name, argv, state) {
3255
4284
  const previousArgs = state.args;
4285
+ const previousLoopDepth = state.loopDepth;
4286
+ state.loopDepth = 0;
3256
4287
  state.args = [previousArgs[0], ...argv.slice(1)];
4288
+ state.returnDepth++;
3257
4289
  try { return await executeNodeList(state.functions[name], state); }
3258
- finally { state.args = previousArgs; }
4290
+ finally {
4291
+ state.returnDepth--;
4292
+ state.loopDepth = previousLoopDepth;
4293
+ if (state.flow?.kind === "return") state.flow = null;
4294
+ state.args = previousArgs;
4295
+ }
3259
4296
  }
3260
4297
 
3261
4298
  async function runExternal(
@@ -3275,7 +4312,7 @@ async function runExternal(
3275
4312
  const options = {
3276
4313
  cmd: spawnArgv,
3277
4314
  cwd: nativePath(state.cwd),
3278
- env: state.env,
4315
+ env: externalEnvironment(state),
3279
4316
  stdin: runtimeOptions.background ? "ignore" : input === null ? "inherit" : input,
3280
4317
  stdout: runtimeOptions.background ? "inherit" : captureStdout || stdoutSink ? "pipe" : "inherit",
3281
4318
  stderr: runtimeOptions.background ? "inherit" : captureStderr || runtimeOptions.stderrSink ? "pipe" : "inherit",
@@ -3283,7 +4320,30 @@ async function runExternal(
3283
4320
  runtimeOptions.onExit?.(proc, exitCode, signalCode, error);
3284
4321
  },
3285
4322
  };
4323
+ let stateDirectory;
3286
4324
  try {
4325
+ if (options.env[PIPELINE_STATE_ENV] === "@current")
4326
+ options.env[PIPELINE_STATE_ENV] = pipelineBuiltinState(state).env[PIPELINE_STATE_ENV];
4327
+ const serialized = options.env[PIPELINE_STATE_ENV];
4328
+ if (serialized?.length > 2048 && !runtimeOptions.background) {
4329
+ stateDirectory = mkdtempSync(resolvePath(tmpdir(), "bunmsh-state-"));
4330
+ const file = resolvePath(stateDirectory, "state.json");
4331
+ writeFileSync(file, serialized, {mode:0o600});
4332
+ options.env = {...options.env, [PIPELINE_STATE_ENV]: `@file:${file}`};
4333
+ }
4334
+ if (!runtimeOptions.background) {
4335
+ options.stdout = runtimeOptions.stdoutFd ?? options.stdout;
4336
+ options.stderr = runtimeOptions.stderrFd ?? options.stderr;
4337
+ const extra = Object.keys(state.fdBindings ?? {}).map(Number).filter(n => n > 2);
4338
+ if (extra.length) {
4339
+ options.stdio = [options.stdin, options.stdout, options.stderr];
4340
+ for (let n = 3; n <= Math.max(...extra); n++) {
4341
+ const endpoint = state.fdBindings[n];
4342
+ options.stdio[n] = endpoint?.fd ?? endpoint?.channel ?? "ignore";
4343
+ }
4344
+ delete options.stdin; delete options.stdout; delete options.stderr;
4345
+ }
4346
+ }
3287
4347
  const proc = Bun.spawn(options);
3288
4348
  if (runtimeOptions.background) {
3289
4349
  if (!Array.isArray(Bun.sha.procs)) Bun.sha.procs = [];
@@ -3308,10 +4368,21 @@ async function runExternal(
3308
4368
  const abandonStreaming = new Promise((resolve) => {
3309
4369
  runtimeOptions.onSpawn?.(proc, pipelineKillSignal, resolve);
3310
4370
  });
3311
- const outputPromise = captureStdout
4371
+ const sharedSink = stdoutSink && stdoutSink === runtimeOptions.stderrSink;
4372
+ const sharedStreamPromise = sharedSink ? Promise.race([
4373
+ (async()=>{
4374
+ const writer=stdoutSink.getWriter();
4375
+ const pump=async stream=>{const reader=stream.getReader();try{while(true){const chunk=await reader.read();if(chunk.done)break;await writer.write(chunk.value);}}finally{reader.releaseLock();}};
4376
+ try {await Promise.all([pump(proc.stdout),pump(proc.stderr)]);await writer.close();}
4377
+ catch {try{proc.kill(pipelineKillSignal);}catch{}}
4378
+ finally {writer.releaseLock();}
4379
+ })(),
4380
+ abandonStreaming,
4381
+ ]) : Promise.resolve();
4382
+ const outputPromise = captureStdout && !stdoutSink
3312
4383
  ? new Response(proc.stdout).arrayBuffer()
3313
4384
  : Promise.resolve(new ArrayBuffer(0));
3314
- const streamPromise = stdoutSink
4385
+ const streamPromise = stdoutSink && !sharedSink
3315
4386
  ? Promise.race([
3316
4387
  proc.stdout.pipeTo(stdoutSink).catch(() => {
3317
4388
  try { proc.kill(pipelineKillSignal); } catch {}
@@ -3319,13 +4390,13 @@ async function runExternal(
3319
4390
  abandonStreaming,
3320
4391
  ])
3321
4392
  : Promise.resolve();
3322
- const errorStreamPromise = runtimeOptions.stderrSink
4393
+ const errorStreamPromise = runtimeOptions.stderrSink && !sharedSink
3323
4394
  ? Promise.race([
3324
4395
  proc.stderr.pipeTo(runtimeOptions.stderrSink).catch(() => {}),
3325
4396
  abandonStreaming,
3326
4397
  ])
3327
4398
  : Promise.resolve();
3328
- const errorPromise = captureStderr
4399
+ const errorPromise = captureStderr && !runtimeOptions.stderrSink
3329
4400
  ? new Response(proc.stderr).arrayBuffer()
3330
4401
  : Promise.resolve(new ArrayBuffer(0));
3331
4402
  const [status, output, error] = await Promise.all([
@@ -3334,13 +4405,18 @@ async function runExternal(
3334
4405
  errorPromise,
3335
4406
  streamPromise,
3336
4407
  errorStreamPromise,
4408
+ sharedStreamPromise,
3337
4409
  ]);
3338
4410
  const execution = result(status, new Uint8Array(output), new Uint8Array(error));
3339
- execution.stdoutStreamed = Boolean(stdoutSink);
4411
+ execution.stdoutStreamed = Boolean(stdoutSink || runtimeOptions.stderrSink);
3340
4412
  execution.stderrStreamed = Boolean(runtimeOptions.stderrSink);
3341
4413
  return execution;
3342
4414
  } catch (error) {
3343
4415
  return result(127, "", `bunmsh: ${argv[0]}: ${error.message}\n`);
4416
+ } finally {
4417
+ if (stateDirectory) {
4418
+ try { unlinkSync(resolvePath(stateDirectory,"state.json")); rmdirSync(stateDirectory); } catch {}
4419
+ }
3344
4420
  }
3345
4421
  }
3346
4422
 
@@ -3359,17 +4435,144 @@ function pipelineBuiltinArgv(argv) {
3359
4435
  : [...BUN_RUNTIME_COMMAND, BUNMSH_ENTRY, "-cc", ...argv];
3360
4436
  }
3361
4437
 
4438
+ function pipelineCompoundArgv(source) {
4439
+ return IS_COMPILED
4440
+ ? [...EXECUTABLE_COMMAND, "-c", source]
4441
+ : [...BUN_RUNTIME_COMMAND, BUNMSH_ENTRY, "-c", source];
4442
+ }
4443
+
4444
+ function heredocRanges(source, tokens = tokenize(source)) {
4445
+ const ranges = new Map();
4446
+ let previousEnd = 0;
4447
+ for (const token of tokens) {
4448
+ if (!token.heredoc) continue;
4449
+ const delimiter = token.fragments.map(f=>f.text.replaceAll("\u0000", "")).join("");
4450
+ const start = Math.max(source.indexOf("\n",token.offset)+1, previousEnd);
4451
+ if (!start) continue;
4452
+ let end=start;
4453
+ while (end < source.length) {
4454
+ const newline=source.indexOf("\n",end);
4455
+ const lineEnd=newline<0 ? source.length : newline;
4456
+ const line=source.slice(end,lineEnd);
4457
+ end=newline<0 ? source.length : newline+1;
4458
+ if (line === delimiter || line.replace(/^\t+/,"") === delimiter) break;
4459
+ }
4460
+ ranges.set(start,end); previousEnd=end;
4461
+ }
4462
+ return ranges;
4463
+ }
4464
+
4465
+ function leadingSubshell(source) {
4466
+ const leading = source.length - source.trimStart().length;
4467
+ if (source[leading] !== "(") return null;
4468
+ let depth = 0, quote = null;
4469
+ let keywords, ranges;
4470
+ try { const tokens=tokenize(source); keywords = caseKeywords(tokens); ranges=heredocRanges(source,tokens); } catch (error) {
4471
+ if (error.message.startsWith("unterminated")) return null;
4472
+ throw error;
4473
+ }
4474
+ const cases = [];
4475
+ for (let i = leading; i < source.length; i++) {
4476
+ if (ranges.has(i)) { i=ranges.get(i)-1; continue; }
4477
+ const ch = source[i];
4478
+ if (quote === "'") { if (ch === "'") quote = null; continue; }
4479
+ if (quote === '"') {
4480
+ if (ch === "\\" && /[\\"$`\n]/.test(source[i + 1] ?? "")) { i++; continue; }
4481
+ if (ch === '"') quote = null;
4482
+ continue;
4483
+ }
4484
+ if (ch === "#" && (i === leading || /[\s;|&]/.test(source[i-1]))) {
4485
+ const end=source.indexOf("\n",i); if (end<0) return null; i=end; continue;
4486
+ }
4487
+ const keyword=keywords.get(i);
4488
+ if (keyword === "case") cases.push("head");
4489
+ else if (keyword === "esac") cases.pop();
4490
+ if (cases.at(-1) === "head" && source.slice(i).match(/^in(?:\s|$)/) && /\s/.test(source[i-1] ?? "")) cases[cases.length-1]="pattern";
4491
+ if (cases.at(-1) === "body" && source.startsWith(";;",i)) cases[cases.length-1]="pattern";
4492
+ if (ch === "\\") { i++; continue; }
4493
+ if (ch === "'" || ch === '"') { quote = ch; continue; }
4494
+ if (ch === "`") {
4495
+ try { i = substitutionEnd(source, i) - 1; } catch { return null; }
4496
+ continue;
4497
+ }
4498
+ if (ch === "$" && source[i + 1] === "(") {
4499
+ try { i = substitutionEnd(source, i) - 1; } catch { return null; }
4500
+ continue;
4501
+ }
4502
+ if (cases.at(-1) === "pattern" && (ch === "(" || ch === ")")) {
4503
+ if (ch === ")") cases[cases.length-1]="body";
4504
+ continue;
4505
+ }
4506
+ if (ch === "(") depth++;
4507
+ else if (ch === ")" && --depth === 0)
4508
+ return { body: source.slice(leading + 1, i), suffix: source.slice(i + 1) };
4509
+ }
4510
+ return null;
4511
+ }
4512
+
4513
+ function rewriteSubshells(source, state) {
4514
+ let output = "", quote = null;
4515
+ const ranges=heredocRanges(source);
4516
+ for (let i = 0; i < source.length;) {
4517
+ if (ranges.has(i)) { const end=ranges.get(i); output+=source.slice(i,end); i=end; continue; }
4518
+ const ch = source[i];
4519
+ if (quote === "'") {
4520
+ output += ch;
4521
+ if (ch === "'") quote = null;
4522
+ i++;
4523
+ continue;
4524
+ }
4525
+ if (quote === '"') {
4526
+ output += ch;
4527
+ if (ch === "\\" && /[\\"$`\n]/.test(source[i + 1] ?? "")) output += source[++i];
4528
+ else if (ch === '"') quote = null;
4529
+ i++;
4530
+ continue;
4531
+ }
4532
+ if (ch === "\\") { output += source.slice(i, i + 2); i += 2; continue; }
4533
+ if (ch === "'" || ch === '"') { quote = ch; output += ch; i++; continue; }
4534
+ if (ch === "`") {
4535
+ try { const end = substitutionEnd(source, i); output += source.slice(i, end); i = end; continue; }
4536
+ catch { output += ch; i++; continue; }
4537
+ }
4538
+ if (ch === "$" && source[i + 1] === "(") {
4539
+ try { const end = substitutionEnd(source, i); output += source.slice(i, end); i = end; continue; }
4540
+ catch { output += ch; i++; continue; }
4541
+ }
4542
+ const boundary = i === 0 || /[\s;|&]/.test(source[i - 1]);
4543
+ if (ch === "(" && boundary) {
4544
+ const group = leadingSubshell(source.slice(i));
4545
+ if (group) {
4546
+ const argv = pipelineCompoundArgv(group.body);
4547
+ // Snapshot only when this command runs: earlier commands in the same
4548
+ // list may still change variables, functions, options or positional args.
4549
+ output += `BUNMSH_INTERNAL_PIPELINE_STATE=@current ${argv.map(quoteShellWord).join(" ")}`;
4550
+ i += group.body.length + 2;
4551
+ continue;
4552
+ }
4553
+ }
4554
+ output += ch;
4555
+ i++;
4556
+ }
4557
+ return output;
4558
+ }
4559
+
3362
4560
  function pipelineBuiltinState(state) {
3363
4561
  return {
3364
4562
  ...state,
3365
4563
  env: {
3366
4564
  ...state.env,
3367
4565
  [PIPELINE_STATE_ENV]: JSON.stringify({
4566
+ env: Object.fromEntries(Object.entries(state.env).filter(([name]) => name !== PIPELINE_STATE_ENV)),
4567
+ exported: [...state.exported],
3368
4568
  aliases: state.aliases,
3369
4569
  functions: state.functions,
3370
4570
  readonly: [...state.readonly],
3371
4571
  args: state.args,
3372
4572
  pathSearch: state.pathSearch,
4573
+ shellOptions:state.shellOptions,
4574
+ ignoreErrexit:state.ignoreErrexit,
4575
+ fdBindings: Object.fromEntries(Object.entries(state.fdBindings ?? {}).filter(([n]) => Number(n)>2).map(([n,v]) => [n, v.fd === undefined ? v : {fd:Number(n)}])),
3373
4576
  }),
3374
4577
  },
3375
4578
  };
@@ -3463,7 +4666,12 @@ async function runCommandArgv(
3463
4666
  )) return result(2, "", "bunmsh: &: only a single external command is supported\n");
3464
4667
 
3465
4668
  let commandState = state;
3466
- if (options.pipelineStage && runsInPipelineSubprocess(commandArgv, commandState))
4669
+ const ioCommand = ["builtin", "__builtin", "command"].includes(commandArgv[0])
4670
+ ? commandArgv.slice(1).filter(arg => arg !== "--")[0] : commandArgv[0];
4671
+ const redirectedStream = (options.stdoutFd !== undefined || options.stderrFd !== undefined) &&
4672
+ !Object.hasOwn(state.functions, ioCommand) &&
4673
+ (ioCommand === "yes" || Object.hasOwn(fallbackBuiltins, ioCommand));
4674
+ if ((options.pipelineStage || redirectedStream) && runsInPipelineSubprocess(commandArgv, commandState))
3467
4675
  return runExternal(
3468
4676
  pipelineBuiltinArgv(commandArgv),
3469
4677
  pipelineBuiltinState(commandState),
@@ -3473,6 +4681,8 @@ async function runCommandArgv(
3473
4681
  options.stdoutSink,
3474
4682
  { ...options, pipelineKillSignal: "SIGTERM" },
3475
4683
  );
4684
+ if (typeof input === "number" && ioCommand !== "read" && runsInPipelineSubprocess(commandArgv,commandState))
4685
+ input = Readable.toWeb(createReadStream("",{fd:input,autoClose:false}));
3476
4686
  while (["command", "builtin", "__builtin"].includes(commandArgv[0])) {
3477
4687
  if (requestsBuiltinHelp(commandArgv) && hasBuiltinHelp(commandArgv[0]))
3478
4688
  return builtinHelp(commandArgv[0]);
@@ -3578,16 +4788,6 @@ async function runCommandArgv(
3578
4788
  );
3579
4789
  }
3580
4790
 
3581
- function redirectInput(path) {
3582
- const fd = openSync(path, "r");
3583
- return Readable.toWeb(createReadStream(path, { fd, autoClose: true }));
3584
- }
3585
-
3586
- function redirectOutput(path, append) {
3587
- const fd = openSync(path, append ? "a" : "w");
3588
- return Writable.toWeb(createWriteStream(path, { fd, autoClose: true }));
3589
- }
3590
-
3591
4791
  // Mirrors the double-quote scanning in tokenize(): an unquoted heredoc body
3592
4792
  // only lets backslash escape $, `, \, and a trailing newline before the rest
3593
4793
  // goes through the normal $/`` expansion pipeline.
@@ -3620,6 +4820,7 @@ function redirectHeredoc(content) {
3620
4820
 
3621
4821
  async function runCommand(command, state, options = {}) {
3622
4822
  state.expansionStatus = null;
4823
+ if (command.lineNumber != null) state.lineNumber = command.lineNumber;
3623
4824
  const standaloneTilde =
3624
4825
  command.words.length === 1 &&
3625
4826
  command.words[0].fragments.length === 1 &&
@@ -3648,128 +4849,132 @@ async function runCommand(command, state, options = {}) {
3648
4849
  localEnv[assignment.name] = assignment.value;
3649
4850
  expanded.shift();
3650
4851
  }
3651
- if (expanded.length === 0) {
4852
+ const assignmentOnly = expanded.length === 0;
4853
+ if (assignmentOnly) {
3652
4854
  for (const name of Object.keys(localEnv))
3653
4855
  if (state.readonly.has(name)) return readonlyError(name);
3654
4856
  Object.assign(state.env, localEnv);
3655
- return result(state.expansionStatus ?? 0);
4857
+ if (!command.redirects.length) return result(state.expansionStatus ?? 0);
4858
+ expanded.push(":");
3656
4859
  }
3657
4860
  const alias = aliasEligible ? state.aliases[expanded[0]] : null;
3658
4861
  if (alias) expanded.splice(0, 1, ...alias);
3659
4862
 
4863
+ const prefixSaved = new Map();
4864
+ const special = [":", ".", "break", "continue", "eval", "exec", "exit", "export", "readonly", "return", "set", "shift", "trap", "unset"].includes(expanded[0]);
4865
+ const temporaryPrefix = state.shellOptions.posix && !assignmentOnly && !special;
4866
+ const previousBindings = state.fdBindings;
4867
+ const bindings = {...previousBindings};
4868
+ if (options.stdoutSink) bindings[1] = {channel:1};
4869
+ if (options.input !== null && options.input !== undefined) delete bindings[0];
4870
+ const opened = [];
4871
+ const redirectedFds = new Set();
3660
4872
  let input = options.input ?? null;
3661
- let stdoutPath = null;
3662
- let stdoutAppend = false;
3663
- let stderrPath = null;
3664
- let stderrAppend = false;
3665
- let stdoutToStderr = false;
3666
- let stderrToStdout = false;
3667
- for (const redirect of command.redirects) {
3668
- if (redirect.op === "1>&2") { stdoutToStderr = true; continue; }
3669
- if (redirect.op === "2>&1") { stderrToStdout = true; continue; }
3670
- if (redirect.op === "<<" || redirect.op === "<<-") {
3671
- const heredoc = redirect.heredoc ?? { body: "", expand: false };
3672
- const content = heredoc.expand
3673
- ? stripExpansionMarkers(
3674
- (await expandText(heredocLiteralPass(heredoc.body), state)).replaceAll("\u0000", ""),
3675
- )
3676
- : heredoc.body;
3677
- if (input instanceof ReadableStream) {
3678
- try { await input.cancel(); } catch {}
3679
- }
3680
- input = redirectHeredoc(content);
3681
- continue;
3682
- }
3683
- if (redirect.op === "<<<") {
3684
- const [word] = await expandWord(redirect.target, state, { single: true, noTilde: true });
3685
- if (input instanceof ReadableStream) {
3686
- try { await input.cancel(); } catch {}
3687
- }
3688
- input = redirectHeredoc(`${word}\n`);
3689
- continue;
3690
- }
3691
- const targets = await expandWord(redirect.target, state, { single: true });
3692
- const target = targets[0];
3693
- const path = target.startsWith("/") ? target : `${state.cwd}/${target}`;
3694
- if (redirect.op === "<") {
3695
- try {
3696
- if (input instanceof ReadableStream) {
3697
- try { await input.cancel(); } catch {}
4873
+ let persistent = expanded[0] === "exec" && expanded.length === 1;
4874
+ try {
4875
+ for (const redirect of command.redirects) {
4876
+ if (["<<", "<<-", "<<<"].includes(redirect.op)) {
4877
+ redirectedFds.add(0);
4878
+ let content;
4879
+ if (redirect.op === "<<<") content = `${(await expandWord(redirect.target,state,{single:true,noTilde:true}))[0]}\n`;
4880
+ else {
4881
+ const heredoc = redirect.heredoc ?? {body:"",expand:false};
4882
+ content = heredoc.expand ? stripExpansionMarkers((await expandText(heredocLiteralPass(heredoc.body),state)).replaceAll("\u0000","")) : heredoc.body;
3698
4883
  }
3699
- input = redirectInput(path);
3700
- } catch (error) {
3701
- return result(1, "", `bunmsh: ${target}: ${error.message}\n`);
4884
+ bindings[0] = {content};
4885
+ continue;
3702
4886
  }
3703
- } else if (redirect.op === ">" || redirect.op === ">>") {
3704
- stdoutPath = path;
3705
- stdoutAppend = redirect.op === ">>";
3706
- } else {
3707
- stderrPath = path;
3708
- stderrAppend = redirect.op === "2>>";
3709
- }
3710
- }
3711
-
3712
-
3713
- let stdoutRedirect = null;
3714
- let stderrRedirect = null;
3715
- try {
3716
- if (stdoutPath !== null) {
3717
- // A command-level redirect replaces the pipeline's stdout connection.
3718
- // Close that unused connection now so the next stage observes EOF.
3719
- if (options.stdoutSink) {
3720
- try { await options.stdoutSink.getWriter().close(); } catch {}
4887
+ const dup = /^(\d+)([<>]&)(\d+|-)$/.exec(redirect.op);
4888
+ const match = /^(\d*)(>&|<&|<>|>>|>|<)$/.exec(redirect.op);
4889
+ if (!dup && !match) throw new Error(`unsupported redirection ${redirect.op}`);
4890
+ const op = dup ? dup[2] : match[2];
4891
+ const fd = Number((dup ? dup[1] : match[1]) || (op.startsWith("<") ? 0 : 1));
4892
+ redirectedFds.add(fd);
4893
+ if (fd > 1023) throw new Error("file descriptor out of range");
4894
+ const target = dup ? dup[3] : (await expandWord(redirect.target,state,{single:true}))[0];
4895
+ if (op.endsWith("&")) {
4896
+ if (target === "-") bindings[fd] = {closed:true};
4897
+ else {
4898
+ if (!/^\d+$/.test(target)) throw new Error(`${target}: bad file descriptor`);
4899
+ const from = bindings[Number(target)] ?? (Number(target) <= 2 ? {channel:Number(target)} : null);
4900
+ if (!from || from.closed) throw new Error(`${target}: bad file descriptor`);
4901
+ bindings[fd] = from;
4902
+ }
4903
+ } else {
4904
+ const path = isAbsolute(nativePath(target)) ? nativePath(target) : resolvePath(nativePath(state.cwd),nativePath(target));
4905
+ const handle = openSync(path, op === "<" ? "r" : op === ">>" ? "a" : op === "<>" ? fsConstants.O_RDWR | fsConstants.O_CREAT : "w");
4906
+ opened.push(handle);
4907
+ bindings[fd] = {fd:handle};
3721
4908
  }
3722
- stdoutRedirect = redirectOutput(stdoutPath, stdoutAppend);
3723
4909
  }
3724
- if (stderrPath !== null)
3725
- stderrRedirect = redirectOutput(stderrPath, stderrAppend);
4910
+ const commandState = options.subshell ? {...state,env:{...state.env,...localEnv},exported:new Set(state.exported),fdBindings:bindings,flow:null,loopDepth:0,returnDepth:0} : state;
4911
+ if (!options.subshell) {
4912
+ for (const name of Object.keys(localEnv)) if (state.readonly.has(name)) return readonlyError(name);
4913
+ // Internal subprocess transport is command-scoped in both modes.
4914
+ // Otherwise an unrelated later shell inherits the parent's functions/options.
4915
+ for (const name of Object.keys(localEnv)) if (temporaryPrefix || name === PIPELINE_STATE_ENV)
4916
+ prefixSaved.set(name, {present:Object.hasOwn(state.env,name),value:state.env[name],exported:state.exported.has(name)});
4917
+ Object.assign(commandState.env,localEnv);
4918
+ }
4919
+ if (!assignmentOnly) for (const name of Object.keys(localEnv)) commandState.exported.add(name);
4920
+ commandState.fdBindings = bindings;
4921
+ if (persistent) {
4922
+ for (const fd of opened) state.ownedFds.add(fd);
4923
+ return result();
4924
+ }
4925
+ const stdin = bindings[0];
4926
+ if (stdin?.content !== undefined) input = redirectHeredoc(stdin.content);
4927
+ else if (stdin?.closed) input = redirectHeredoc("");
4928
+ else if (stdin?.fd !== undefined) input = stdin.fd;
4929
+ if (state.shellOptions.x) {
4930
+ const line = bytes(`${state.env.PS4 ?? "+ "}${expanded.join(" ")}\n`);
4931
+ const traceFd = bindings[2];
4932
+ if (traceFd?.fd !== undefined) writeSync(traceFd.fd,line);
4933
+ else await writeStream(process.stderr,line);
4934
+ }
4935
+ const nested = Boolean(command.body) || Object.hasOwn(commandState.functions,expanded[0]) || ["eval",".","source"].includes(expanded[0]);
4936
+ const stdout = bindings[1] ?? {channel:1};
4937
+ const stderr = bindings[2] ?? {channel:2};
4938
+ const direct = (endpoint) => endpoint.fd !== undefined || endpoint.closed;
4939
+ const execution = command.body ? await executeNodeList(command.body,commandState,{capture:true,input}) : await runCommandArgv(expanded,commandState,input,
4940
+ direct(stdout) ? false : Boolean(options.captureStdout || stdout.channel !== 1),
4941
+ direct(stderr) ? false : Boolean(options.captureStderr || stderr.channel !== 2), {
4942
+ ...options,
4943
+ pipelineStage: Boolean(options.pipelineStage),
4944
+ stdoutSink: !direct(stdout)&&stdout.channel===1 ? options.stdoutSink : null,
4945
+ stderrSink: !direct(stderr)&&stderr.channel===1 ? options.stdoutSink : null,
4946
+ stdoutFd: stdout.fd ?? (stdout.closed ? "ignore" : undefined),
4947
+ stderrFd: stderr.fd ?? (stderr.closed ? "ignore" : undefined),
4948
+ });
4949
+ if (!nested) routeCommandOutput(execution, bindings);
4950
+ return execution;
3726
4951
  } catch (error) {
3727
- return result(1, "", `bunmsh: ${stdoutPath ?? stderrPath}: ${error.message}\n`);
3728
- }
3729
-
3730
- const commandState = options.subshell
3731
- ? {
3732
- ...state,
3733
- env: { ...state.env, ...localEnv },
3734
- directoryHistory: [...state.directoryHistory],
3735
- tabs: [...state.tabs],
3736
- readonly: new Set(state.readonly),
4952
+ persistent = false;
4953
+ return result(1,"",`bunmsh: ${error.message}\n`);
4954
+ } finally {
4955
+ for (const [name,saved] of prefixSaved) {
4956
+ if (saved.present) state.env[name]=saved.value; else delete state.env[name];
4957
+ if (saved.exported) state.exported.add(name); else state.exported.delete(name);
4958
+ }
4959
+ if (!persistent) {
4960
+ if (options.subshell) state.fdBindings = previousBindings;
4961
+ else if (command.redirects.length) {
4962
+ // Restore just the redirected descriptors (mksh's savedfd scope),
4963
+ // not descriptors opened independently by exec inside the body.
4964
+ const restored = {...state.fdBindings};
4965
+ for (const fd of redirectedFds) {
4966
+ if (Object.hasOwn(previousBindings,fd)) restored[fd] = previousBindings[fd];
4967
+ else delete restored[fd];
4968
+ }
4969
+ state.fdBindings = restored;
3737
4970
  }
3738
- : state;
3739
- if (!options.subshell) {
3740
- for (const name of Object.keys(localEnv))
3741
- if (commandState.readonly.has(name)) return readonlyError(name);
3742
- Object.assign(commandState.env, localEnv);
3743
- }
3744
-
3745
- const captureStdout = (options.captureStdout || stdoutToStderr) && stdoutRedirect === null;
3746
- const captureStderr = (options.captureStderr || stderrToStdout) && stderrRedirect === null;
3747
- const execution = await runCommandArgv(
3748
- expanded,
3749
- commandState,
3750
- input,
3751
- captureStdout,
3752
- captureStderr,
3753
- {
3754
- pipelineStage: Boolean(options.pipelineStage || stdoutRedirect || stderrRedirect),
3755
- stdoutSink: stdoutRedirect ?? options.stdoutSink ?? null,
3756
- stderrSink: stderrRedirect,
3757
- onSpawn: options.onSpawn,
3758
- onExit: options.onExit,
3759
- background: Boolean(options.background),
3760
- },
3761
- );
3762
-
3763
- if (stdoutToStderr && execution.stdout.byteLength) {
3764
- execution.stderr = concatBytes([execution.stderr, execution.stdout]);
3765
- execution.stdout = bytes();
3766
- }
3767
- if (stderrToStdout && execution.stderr.byteLength) {
3768
- execution.stdout = concatBytes([execution.stdout, execution.stderr]);
3769
- execution.stderr = bytes();
4971
+ for (const fd of opened) {
4972
+ if (Object.values(state.fdBindings).some(endpoint => endpoint.fd === fd)) state.ownedFds.add(fd);
4973
+ else { try { closeSync(fd); } catch {} }
4974
+ }
4975
+ }
3770
4976
  }
3771
4977
 
3772
- return execution;
3773
4978
  }
3774
4979
 
3775
4980
  async function runPipeline(pipeline, state, options = {}) {
@@ -3777,6 +4982,8 @@ async function runPipeline(pipeline, state, options = {}) {
3777
4982
  return result(2, "", "bunmsh: &: only a single external command without redirections is supported\n");
3778
4983
  if (pipeline.length === 1)
3779
4984
  return runCommand(pipeline[0], state, {
4985
+ input: options.input ?? null,
4986
+ stdoutSink: options.stdoutSink ?? null,
3780
4987
  captureStdout: Boolean(options.capture),
3781
4988
  captureStderr: Boolean(options.capture),
3782
4989
  background: Boolean(options.background),
@@ -3788,9 +4995,9 @@ async function runPipeline(pipeline, state, options = {}) {
3788
4995
  );
3789
4996
  const linksConnected = links.map((_, i) => {
3790
4997
  const stdoutRedirected = pipeline[i].redirects.some((redirect) =>
3791
- redirect.op === ">" || redirect.op === ">>");
4998
+ /^(?:1)?>(?:>|&.*)?$/.test(redirect.op));
3792
4999
  const stdinRedirected = pipeline[i + 1].redirects.some((redirect) =>
3793
- ["<", "<<", "<<-", "<<<"].includes(redirect.op));
5000
+ /^(?:0)?<(?:<<?-?|&.*|>)?$/.test(redirect.op));
3794
5001
  return !stdoutRedirected && !stdinRedirected;
3795
5002
  });
3796
5003
  const processes = Array(pipeline.length).fill(null);
@@ -3812,7 +5019,7 @@ async function runPipeline(pipeline, state, options = {}) {
3812
5019
  const stages = pipeline.map((command, i) => {
3813
5020
  const last = i === pipeline.length - 1;
3814
5021
  return runCommand(command, state, {
3815
- input: i === 0 ? null : links[i - 1].readable,
5022
+ input: i === 0 ? options.input ?? null : links[i - 1].readable,
3816
5023
  captureStdout: last && Boolean(options.capture),
3817
5024
  captureStderr: Boolean(options.capture),
3818
5025
  stdoutSink: last ? null : links[i].writable,
@@ -3852,6 +5059,18 @@ async function runPipeline(pipeline, state, options = {}) {
3852
5059
  }
3853
5060
 
3854
5061
  export async function execute(source, state = createState(), io = {}) {
5062
+ const previous = state.ignoreErrexit;
5063
+ if (io.errexitIgnored) state.ignoreErrexit++;
5064
+ try { return await executeInternal(source,state,io); }
5065
+ catch (error) {
5066
+ const failure = result(state.exitRequested ? state.exitStatus : 2,"",`bunmsh: ${error.message}\n`);
5067
+ if (!io.capture) await writeStream(process.stderr,failure.stderr);
5068
+ return failure;
5069
+ }
5070
+ finally { state.ignoreErrexit = previous; }
5071
+ }
5072
+
5073
+ async function executeInternal(source, state, io) {
3855
5074
  source = normalizeSource(source);
3856
5075
  if (source === "?") {
3857
5076
  const execution = result(0, `${state.lastStatus}\n`);
@@ -3860,6 +5079,15 @@ export async function execute(source, state = createState(), io = {}) {
3860
5079
  return execution;
3861
5080
  }
3862
5081
  const rawSource = source.trimStart();
5082
+ const group = leadingSubshell(source);
5083
+ if (group) {
5084
+ const previous = state.env[PIPELINE_STATE_ENV];
5085
+ try { return await execute(rewriteSubshells(source,state),state,io); }
5086
+ finally {
5087
+ if (previous === undefined) delete state.env[PIPELINE_STATE_ENV];
5088
+ else state.env[PIPELINE_STATE_ENV] = previous;
5089
+ }
5090
+ }
3863
5091
  if (isJavaScriptMode(rawSource)) {
3864
5092
  let execution;
3865
5093
  const previousCwd = process.cwd();
@@ -3916,7 +5144,7 @@ export async function execute(source, state = createState(), io = {}) {
3916
5144
  if (execution.stdout.byteLength) stdout.push(execution.stdout);
3917
5145
  if (execution.stderr.byteLength) stderr.push(execution.stderr);
3918
5146
  }
3919
- if (state.exitRequested) break;
5147
+ if (state.exitRequested || state.flow) break;
3920
5148
  }
3921
5149
  return io.capture
3922
5150
  ? result(execution.status, concatBytes(stdout), concatBytes(stderr))
@@ -3928,8 +5156,9 @@ export async function execute(source, state = createState(), io = {}) {
3928
5156
  if (compoundSyntax) {
3929
5157
  let execution;
3930
5158
  try {
3931
- execution = await executeNodeList(parseCompoundScript(source), state, {
5159
+ execution = await executeNodeList(parseCompoundScript(source, io.firstLine ?? 1, io.sourceLines), state, {
3932
5160
  capture: Boolean(io.capture),
5161
+ input: io.input ?? null,
3933
5162
  });
3934
5163
  }
3935
5164
  catch (error) {
@@ -3944,9 +5173,16 @@ export async function execute(source, state = createState(), io = {}) {
3944
5173
  return execution;
3945
5174
  }
3946
5175
 
5176
+ const withSubshells = rewriteSubshells(source, state);
5177
+ if (withSubshells !== source) {
5178
+ source = withSubshells;
5179
+ return execute(source, state, io);
5180
+ }
5181
+
3947
5182
  let jobs;
3948
5183
  try {
3949
5184
  jobs = parse(source);
5185
+ for (const job of jobs) for (const command of job.pipeline) command.lineNumber = io.sourceLines?.[command.lineNumber-1] ?? command.lineNumber + (io.firstLine ?? 1) - 1;
3950
5186
  } catch (error) {
3951
5187
  const message = error instanceof ShellSyntaxError ? error.message : String(error);
3952
5188
  const output = `bunmsh: syntax error: ${message}\n`;
@@ -3959,13 +5195,24 @@ export async function execute(source, state = createState(), io = {}) {
3959
5195
  let execution = result(state.lastStatus);
3960
5196
  const capturedStdout = [];
3961
5197
  const capturedStderr = [];
3962
- for (const job of jobs) {
5198
+ for (const [jobIndex,job] of jobs.entries()) {
5199
+ const trapped = await runPendingTraps(state,io);
5200
+ if (io.capture) { capturedStdout.push(trapped.stdout); capturedStderr.push(trapped.stderr); }
5201
+ if (state.exitRequested || state.flow) break;
3963
5202
  if (job.connector === "&&" && execution.status !== 0) continue;
3964
5203
  if (job.connector === "||" && execution.status === 0) continue;
3965
- execution = await runPipeline(job.pipeline, state, {
5204
+ const ignored = state.ignoreErrexit || job.negate || ["&&","||"].includes(jobs[jobIndex+1]?.connector);
5205
+ const previousIgnore = state.ignoreErrexit;
5206
+ if (ignored) state.ignoreErrexit++;
5207
+ try { execution = await runPipeline(job.pipeline, state, {
3966
5208
  capture: io.capture,
3967
5209
  background: job.background,
3968
- });
5210
+ input: io.input ?? null,
5211
+ stdoutSink: io.stdoutSink ?? null,
5212
+ }); } catch (error) {
5213
+ execution = result(state.exitRequested ? state.exitStatus : 2,"",`bunmsh: ${error.message}\n`);
5214
+ } finally { state.ignoreErrexit = previousIgnore; }
5215
+ if (!ignored && state.shellOptions.e && execution.status && !state.flow) { state.exitRequested=true; state.exitStatus=execution.status; }
3969
5216
  if (job.negate) execution.status = execution.status === 0 ? 1 : 0;
3970
5217
  state.lastStatus = execution.status;
3971
5218
  if (execution.stdout.byteLength) {
@@ -3976,7 +5223,7 @@ export async function execute(source, state = createState(), io = {}) {
3976
5223
  if (io.capture) capturedStderr.push(execution.stderr);
3977
5224
  else await writeStream(process.stderr, execution.stderr);
3978
5225
  }
3979
- if (state.exitRequested) break;
5226
+ if (state.exitRequested || state.flow) break;
3980
5227
  }
3981
5228
  return io.capture
3982
5229
  ? result(execution.status, concatBytes(capturedStdout), concatBytes(capturedStderr))
@@ -4006,11 +5253,13 @@ export function pipelineChildState() {
4006
5253
  const serialized = process.env[PIPELINE_STATE_ENV];
4007
5254
  if (!serialized) return createState();
4008
5255
  try {
4009
- const inherited = JSON.parse(serialized);
4010
- const env = { ...process.env };
5256
+ const inherited = JSON.parse(serialized.startsWith("@file:") ? readFileSync(serialized.slice(6),"utf8") : serialized);
5257
+ const env = { ...process.env, ...(inherited.env ?? {}) };
4011
5258
  delete env[PIPELINE_STATE_ENV];
4012
5259
  return createState({
4013
5260
  env,
5261
+ inheritEnvironment: false,
5262
+ exported: inherited.exported,
4014
5263
  cwd: process.cwd(),
4015
5264
  aliases: inherited.aliases,
4016
5265
  functions: inherited.functions,
@@ -4018,6 +5267,9 @@ export function pipelineChildState() {
4018
5267
  args: inherited.args,
4019
5268
  pathSearch: inherited.pathSearch,
4020
5269
  pipelineChild: true,
5270
+ fdBindings: inherited.fdBindings,
5271
+ shellOptions:inherited.shellOptions,
5272
+ ignoreErrexit:inherited.ignoreErrexit,
4021
5273
  });
4022
5274
  } catch {
4023
5275
  return createState();