buninu-linux 0.8.55 → 0.9.56

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 (52) hide show
  1. package/NOTICE.md +46 -46
  2. package/README.md +7 -6
  3. package/initramfs/bin/tar +34 -46
  4. package/initramfs/buninu/apps/bunmsh/CHANGELOG.md +103 -0
  5. package/initramfs/buninu/apps/bunmsh/PORTING.md +133 -15
  6. package/initramfs/buninu/apps/bunmsh/help/awk.md +38 -0
  7. package/initramfs/buninu/apps/bunmsh/help/break.md +21 -0
  8. package/initramfs/buninu/apps/bunmsh/help/chmod.md +8 -2
  9. package/initramfs/buninu/apps/bunmsh/help/cmp.md +29 -0
  10. package/initramfs/buninu/apps/bunmsh/help/continue.md +22 -0
  11. package/initramfs/buninu/apps/bunmsh/help/cp.md +4 -0
  12. package/initramfs/buninu/apps/bunmsh/help/diff.md +30 -0
  13. package/initramfs/buninu/apps/bunmsh/help/egrep.md +26 -0
  14. package/initramfs/buninu/apps/bunmsh/help/export.md +2 -2
  15. package/initramfs/buninu/apps/bunmsh/help/expr.md +30 -0
  16. package/initramfs/buninu/apps/bunmsh/help/fgrep.md +25 -0
  17. package/initramfs/buninu/apps/bunmsh/help/grep.md +4 -0
  18. package/initramfs/buninu/apps/bunmsh/help/ls.md +4 -2
  19. package/initramfs/buninu/apps/bunmsh/help/mkdir.md +1 -1
  20. package/initramfs/buninu/apps/bunmsh/help/print.md +3 -0
  21. package/initramfs/buninu/apps/bunmsh/help/return.md +23 -0
  22. package/initramfs/buninu/apps/bunmsh/help/sed.md +8 -3
  23. package/initramfs/buninu/apps/bunmsh/help/set.md +12 -3
  24. package/initramfs/buninu/apps/bunmsh/help/tr.md +4 -0
  25. package/initramfs/buninu/apps/bunmsh/help/trap.md +22 -0
  26. package/initramfs/buninu/apps/bunmsh/help/uniq.md +27 -0
  27. package/initramfs/buninu/apps/bunmsh/package.json +1 -1
  28. package/initramfs/buninu/apps/bunmsh/src/awk.js +203 -0
  29. package/initramfs/buninu/apps/bunmsh/src/diff.js +222 -0
  30. package/initramfs/buninu/apps/bunmsh/src/fancy-ls.js +15 -4
  31. package/initramfs/buninu/apps/bunmsh/src/main.js +21 -5
  32. package/initramfs/buninu/apps/bunmsh/src/sed.js +159 -0
  33. package/initramfs/buninu/apps/bunmsh/src/shell.js +1283 -540
  34. package/initramfs/buninu/apps/bunmsh/test/awk-reference.test.js +35 -0
  35. package/initramfs/buninu/apps/bunmsh/test/awk.test.js +62 -0
  36. package/initramfs/buninu/apps/bunmsh/test/posix-bootstrap.test.js +595 -0
  37. package/initramfs/buninu/apps/bunmsh/test/utility-reference-cases.mjs +98 -0
  38. package/initramfs/buninu/apps/bunmsh/test/utility-reference.test.js +23 -0
  39. package/initramfs/buninu/apps/jsgotty/client-kitty.mjs +61 -0
  40. package/initramfs/buninu/apps/jsgotty/client.mjs +16 -0
  41. package/initramfs/buninu/apps/jsgotty/package-lock.json +2 -2
  42. package/initramfs/buninu/apps/jsgotty/package.json +1 -1
  43. package/initramfs/buninu/apps/jsgotty/tests/client-kitty.test.mjs +43 -0
  44. package/initramfs/etc/resolv.conf +2 -1
  45. package/initramfs/usr/share/doc/buninu-linux/tar.md +12 -1
  46. package/package.json +1 -1
  47. package/scripts/arch.js +5 -5
  48. package/scripts/arch.sh +5 -5
  49. package/scripts/fetch.js +2 -0
  50. package/scripts/npm-packages.md +4 -3
  51. package/test/fetch-npm.test.js +1 -1
  52. package/test/tar-strip.test.js +36 -0
@@ -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,26 +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 = /^(\d+>&\d+)/.exec(source.slice(i))?.[1] ?? "";
288
314
  const operatorAhead =
289
315
  ["|", ";", "<", ">"].includes(ch) ||
290
316
  two === "&&" ||
291
- two === "||" ||
292
- two === "2>" ||
293
- Boolean(fdDup);
317
+ two === "||";
294
318
  if (current && operatorAhead) {
295
319
  finishWord();
296
320
  continue;
297
321
  }
298
- // A descriptor duplication begins with the shorter `2>` redirect token,
299
- // so it must win before ordinary file redirects are considered.
300
- if (!current && fdDup) {
301
- tokens.push({ type: "op", value: fdDup, offset: i });
302
- i += fdDup.length;
303
- atWordBoundary = true;
304
- continue;
305
- }
306
322
  if (!current && ["&&", "||", ">>", "2>", "2>>"].includes(
307
323
  source.slice(i, i + 3) === "2>>" ? source.slice(i, i + 3) : two,
308
324
  )) {
@@ -357,6 +373,7 @@ export function tokenize(source, options = {}) {
357
373
  }
358
374
  const quotedContinuation = lineContinuationLength(source, i);
359
375
  if (quotedContinuation) {
376
+ options.continuations?.set(i, quotedContinuation);
360
377
  i += quotedContinuation;
361
378
  continue;
362
379
  }
@@ -369,7 +386,7 @@ export function tokenize(source, options = {}) {
369
386
  }
370
387
  }
371
388
  if ((source[i] === "$" && ["(", "{"].includes(source[i + 1])) || source[i] === "`") {
372
- 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);
373
390
  text += source.slice(i, end);
374
391
  i = end;
375
392
  continue;
@@ -407,7 +424,7 @@ export function tokenize(source, options = {}) {
407
424
  while (i < source.length) {
408
425
  const c = source[i];
409
426
  if ((c === "$" && ["(", "{"].includes(source[i + 1])) || c === "`") {
410
- 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);
411
428
  text += source.slice(i, end);
412
429
  i = end;
413
430
  continue;
@@ -415,9 +432,7 @@ export function tokenize(source, options = {}) {
415
432
  const maybeOp =
416
433
  ["|", ";", "<", ">"].includes(c) ||
417
434
  source.slice(i, i + 2) === "&&" ||
418
- source.slice(i, i + 2) === "||" ||
419
- source.slice(i, i + 2) === "2>" ||
420
- /^\d+>&\d+/.test(source.slice(i));
435
+ source.slice(i, i + 2) === "||";
421
436
  if (isSpace(c) || c === "\n" || c === "'" || c === '"' || c === "\\" || maybeOp)
422
437
  break;
423
438
  text += c;
@@ -460,6 +475,7 @@ export function parse(source) {
460
475
  const pipeline = [];
461
476
  while (true) {
462
477
  const command = emptyCommand();
478
+ command.lineNumber = source.slice(0, tokens[i]?.offset ?? 0).split("\n").length;
463
479
  while (i < tokens.length) {
464
480
  const token = tokens[i];
465
481
  if (token.type === "op" && ["|", ";", "&&", "||"].includes(token.value))
@@ -469,7 +485,7 @@ export function parse(source) {
469
485
  i++;
470
486
  continue;
471
487
  }
472
- if (token.type === "op" && ["<", ">", ">>", "2>", "2>>", "<<", "<<-", "<<<"].includes(token.value)) {
488
+ if (token.type === "op" && (/^\d*(?:>&|<&|<>|>>|>|<)$/.test(token.value) || ["<<", "<<-", "<<<"].includes(token.value))) {
473
489
  const target = tokens[i + 1];
474
490
  if (!target || target.type !== "word")
475
491
  throw new ShellSyntaxError(
@@ -495,6 +511,7 @@ export function parse(source) {
495
511
 
496
512
  if (tokens[i]?.value === "|") {
497
513
  i++;
514
+ while (tokens[i]?.value === ";" && source[tokens[i].offset] === "\n") i++;
498
515
  if (!tokens[i])
499
516
  throw new ShellSyntaxError("pipeline requires a command", source.length);
500
517
  continue;
@@ -539,16 +556,27 @@ export function parse(source) {
539
556
  }
540
557
 
541
558
  function parameterValue(name, state) {
559
+ if (name === "LINENO") return String(state.lineNumber ?? 1);
542
560
  if (name === "?") return String(state.lastStatus);
543
561
  if (name === "$") return String(process.pid);
544
562
  if (name === "#") return String(Math.max(0, state.args.length - 1));
545
- if (name === "-") return "";
563
+ if (name === "-") return Object.entries(state.shellOptions ?? {}).filter(([k,v])=>v && k.length === 1).map(([k])=>k).join("");
546
564
  if (name === "0") return state.args[0] ?? "bunmsh";
547
565
  if (name === "@" || name === "*") return state.args.slice(1).join((state.env.IFS ?? " ")[0] ?? " ");
548
566
  if (/^[1-9][0-9]*$/.test(name)) return state.args[Number(name)] ?? "";
549
567
  return state.env[name] ?? "";
550
568
  }
551
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
+
552
580
  function tildeDirectory(name, state) {
553
581
  if (name === "") return state.env.HOME ?? "";
554
582
  if (name === "+") return state.env.PWD ?? state.cwd;
@@ -606,16 +634,6 @@ function arithmeticValue(source, state) {
606
634
  return String(value | 0);
607
635
  }
608
636
 
609
- function globPatternRegex(pattern, anchoredStart, anchoredEnd, greedy) {
610
- let source = "";
611
- for (const ch of pattern) {
612
- if (ch === "*") source += greedy ? ".*" : ".*?";
613
- else if (ch === "?") source += ".";
614
- else source += ch.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
615
- }
616
- return new RegExp(`${anchoredStart ? "^" : ""}${source}${anchoredEnd ? "$" : ""}`);
617
- }
618
-
619
637
  async function casePatternRegex(pattern, state) {
620
638
  let marked = "";
621
639
  const literal = (value) => {
@@ -660,34 +678,37 @@ async function casePatternRegex(pattern, state) {
660
678
  let end = i + 1;
661
679
  if (marked[end] === "!" || marked[end] === "^") end++;
662
680
  if (marked[end] === "]") end++;
663
- while (end < marked.length && marked[end] !== "]") end++;
681
+ while (end < marked.length && marked[end] !== "]") {
682
+ if (marked[end] === "\u0000" && end + 1 < marked.length) end++;
683
+ end++;
684
+ }
664
685
  if (end >= marked.length) source += "\\[";
665
686
  else {
666
687
  let body = marked.slice(i + 1, end);
667
688
  const negate = body[0] === "!" || body[0] === "^";
668
689
  if (negate) body = body.slice(1);
669
- body = body.replaceAll("\\", "\\\\");
690
+ body = body.replace(/\u0000(.)/gs, (_,ch) => /[\\\]\-^]/.test(ch) ? `\\${ch}` : ch);
670
691
  source += `[${negate ? "^" : ""}${body}]`;
671
692
  i = end;
672
693
  }
673
694
  }
674
695
  else source += marked[i].replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
675
696
  }
676
- return new RegExp(`^${source}$`);
697
+ return new RegExp(`^${source}$`, "s");
677
698
  }
678
699
 
679
- async function parameterExpansion(body, state) {
700
+ async function parameterExpansion(body, state, quoted = false) {
680
701
  if (/^[!%][A-Za-z_][A-Za-z0-9_]*$/.test(body)) {
681
702
  const value = parameterValue(body.slice(1), state);
682
703
  return body[0] === "!" ? parameterValue(value, state) : String([...value].length);
683
704
  }
684
- 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)))
685
706
  return String(parameterValue(body.slice(1), state).length);
686
707
  const trim = /^([A-Za-z_][A-Za-z0-9_]*|[?$#@*]|[0-9]+)(%%|%|##|#)(.*)$/s.exec(body);
687
708
  if (trim) {
688
709
  const value = parameterValue(trim[1], state);
689
- const pattern = stripExpansionMarkers(await expandText(trim[3], state));
690
- 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);
691
712
  if (trim[2] === "%") {
692
713
  for (let i = value.length; i >= 0; i--) if (matches(value.slice(i))) return value.slice(0, i);
693
714
  } else if (trim[2] === "%%") {
@@ -718,13 +739,14 @@ async function parameterExpansion(body, state) {
718
739
  if (!match) throw new ShellSyntaxError(`unsupported parameter expansion: \${${body}}`);
719
740
  const [, name, operator, operand = ""] = match;
720
741
  const value = parameterValue(name, state);
721
- 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);
722
744
  const empty = value === "";
723
- if (!operator) return value;
745
+ if (!operator) return checkedParameter(name,state);
724
746
  const useEmpty = operator.startsWith(":");
725
747
  const op = operator.at(-1);
726
748
  const missing = unset || (useEmpty && empty);
727
- const expandedOperand = async () => stripExpansionMarkers(await expandText(operand, state));
749
+ const expandedOperand = () => expandParameterOperand(operand, state, quoted);
728
750
  if (op === "-") return missing ? expandedOperand() : value;
729
751
  if (op === "+") return missing ? "" : expandedOperand();
730
752
  if (op === "=") {
@@ -732,7 +754,7 @@ async function parameterExpansion(body, state) {
732
754
  if (!/^[A-Za-z_]/.test(name)) throw new ShellSyntaxError("cannot assign positional parameter");
733
755
  if (state.readonly?.has(name)) throw new ShellSyntaxError(`${name}: is read only`);
734
756
  const expanded = await expandedOperand();
735
- state.env[name] = expanded;
757
+ state.env[name] = stripExpansionMarkers(expanded);
736
758
  return expanded;
737
759
  }
738
760
  return value;
@@ -742,11 +764,48 @@ async function parameterExpansion(body, state) {
742
764
  }
743
765
 
744
766
  function markedExpansion(value) {
745
- return `\u0001${value}\u0002`;
767
+ return `\ufdd0${value}\ufdd1`;
746
768
  }
747
769
 
748
770
  function stripExpansionMarkers(value) {
749
- 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;
750
809
  }
751
810
 
752
811
  async function commandSubstitution(source, state) {
@@ -755,17 +814,24 @@ async function commandSubstitution(source, state) {
755
814
  env: { ...state.env },
756
815
  aliases: { ...state.aliases },
757
816
  readonly: new Set(state.readonly),
817
+ exported: new Set(state.exported),
758
818
  directoryHistory: [...state.directoryHistory],
759
819
  tabs: [...state.tabs],
760
820
  exitRequested: false,
761
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(),
762
825
  };
826
+ // Command substitution installs its own stdout pipe even inside >file.
827
+ delete child.fdBindings[1];
763
828
  const output = await execute(source, child, { capture: true });
764
- state.expansionStatus = output.status;
765
- 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+$/, "");
766
832
  }
767
833
 
768
- async function expandText(text, state) {
834
+ async function expandText(text, state, quoted = false) {
769
835
  let output = "";
770
836
  for (let i = 0; i < text.length; ) {
771
837
  if (text[i] === "\u0000") {
@@ -775,7 +841,11 @@ async function expandText(text, state) {
775
841
  }
776
842
  if (text[i] === "`") {
777
843
  const end = substitutionEnd(text, i);
778
- 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));
779
849
  i = end;
780
850
  continue;
781
851
  }
@@ -785,7 +855,8 @@ async function expandText(text, state) {
785
855
  }
786
856
  if (text.startsWith("$((", i)) {
787
857
  const end = substitutionEnd(text, i);
788
- 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));
789
860
  i = end;
790
861
  continue;
791
862
  }
@@ -797,20 +868,22 @@ async function expandText(text, state) {
797
868
  }
798
869
  if (text[i + 1] === "{") {
799
870
  const end = parameterEnd(text, i);
800
- output += markedExpansion(await parameterExpansion(text.slice(i + 2, end - 1), state));
871
+ output += markedExpansion(await parameterExpansion(text.slice(i + 2, end - 1), state, quoted));
801
872
  i = end;
802
873
  continue;
803
874
  }
804
875
  const next = text[i + 1];
805
876
  if (["?", "$", "#", "@", "*", "-"].includes(next) || /[0-9]/.test(next ?? "")) {
806
- 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));
807
880
  i += 2;
808
881
  continue;
809
882
  }
810
883
  if (isNameStart(next)) {
811
884
  let end = i + 2;
812
885
  while (isNameChar(text[end])) end++;
813
- output += markedExpansion(parameterValue(text.slice(i + 1, end), state));
886
+ output += markedExpansion(checkedParameter(text.slice(i + 1, end), state));
814
887
  i = end;
815
888
  continue;
816
889
  }
@@ -832,6 +905,7 @@ function splitFields(segments, state) {
832
905
  forced = false;
833
906
  };
834
907
  for (const segment of segments) {
908
+ if (segment.boundary) { finish(true); continue; }
835
909
  if (segment.quoted && segment.text === "") forced = true;
836
910
  for (const ch of segment.text) {
837
911
  if (!segment.quoted && segment.splittable && ifs.includes(ch)) {
@@ -896,42 +970,41 @@ function braceFields(field) {
896
970
  }
897
971
 
898
972
  export async function expandWord(word, state, options = {}) {
899
- if (!options.single && word.fragments.length === 1 &&
900
- word.fragments[0].quote === "double" && word.fragments[0].text === "$@")
901
- return state.args.slice(1);
902
973
  const segments = [];
903
- for (let index = 0; index < word.fragments.length; index++) {
904
- const fragment = word.fragments[index];
974
+ for (const fragment of word.fragments) {
905
975
  const source = fragment.quote === "none" && !options.noTilde
906
- ? expandTildes(fragment.text, state, Boolean(options.assignment))
907
- : fragment.text;
908
- let text = fragment.quote === "single"
909
- ? source.replaceAll("\u0000", "")
910
- : await expandText(source, state);
911
- if (fragment.quote !== "none") {
912
- segments.push({ text: stripExpansionMarkers(text.replaceAll("\u0000", "")), quoted: true, splittable: false });
913
- } else {
914
- let plain = "";
915
- let splittable = false;
916
- const flush = () => {
917
- if (plain) segments.push({ text: plain, quoted: false, splittable });
918
- plain = "";
919
- };
920
- for (let i = 0; i < text.length; i++) {
921
- if (text[i] === "\u0001") { flush(); splittable = true; continue; }
922
- if (text[i] === "\u0002") { flush(); splittable = false; continue; }
923
- if (text[i] !== "\u0000") { plain += text[i]; continue; }
924
- flush();
925
- segments.push({ text: text[++i] ?? "", quoted: true, splittable: false });
926
- }
927
- 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;
928
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();
929
1002
  }
930
1003
  if (options.single) return [segments.map(({ text }) => text).join("")];
931
1004
  const fields = splitFields(segments, state);
932
1005
  const output = [];
933
1006
  for (const field of fields)
934
- 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)));
935
1008
  return output;
936
1009
  }
937
1010
 
@@ -1057,10 +1130,16 @@ async function sourceFile(argv, state) {
1057
1130
  try {
1058
1131
  const source = await Bun.file(path).text();
1059
1132
  const previousArgs = state.args;
1133
+ const previousLoopDepth = state.loopDepth;
1134
+ state.loopDepth = 0;
1060
1135
  if (argv.length > 2) state.args = [previousArgs[0], ...argv.slice(2)];
1136
+ state.returnDepth++;
1061
1137
  try {
1062
1138
  return await execute(source, state, { capture: true });
1063
1139
  } finally {
1140
+ state.returnDepth--;
1141
+ state.loopDepth = previousLoopDepth;
1142
+ if (state.flow?.kind === "return") state.flow = null;
1064
1143
  state.args = previousArgs;
1065
1144
  }
1066
1145
  } catch (error) {
@@ -1125,7 +1204,112 @@ function evaluateTest(argv, state) {
1125
1204
  throw new Error("unsupported expression");
1126
1205
  }
1127
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
+
1128
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),
1129
1313
  ":": async () => result(),
1130
1314
  true: async () => result(),
1131
1315
  false: async () => result(1),
@@ -1272,11 +1456,13 @@ const builtins = {
1272
1456
  fd = Number(arg.slice(2));
1273
1457
  continue;
1274
1458
  }
1275
- if (arg === "-r" || arg === "-R") raw = true;
1276
- else if (arg === "-n") newline = false;
1277
- else if (arg === "-l") separator = "\n";
1278
- else if (arg === "-N") separator = terminator = "\0";
1279
- 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
+ }
1280
1466
  }
1281
1467
  let output = argv.slice(i).join(separator);
1282
1468
  if (!raw) {
@@ -1340,6 +1526,25 @@ const builtins = {
1340
1526
  if (state.readLines) {
1341
1527
  gotInput = state.readLines.length > 0;
1342
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));
1343
1548
  } else {
1344
1549
  const reader = fallbackInput(input).getReader();
1345
1550
  const chunks = [];
@@ -1461,8 +1666,9 @@ const builtins = {
1461
1666
  "..": async (_argv, state) => changeDirectory(state, ".."),
1462
1667
  "//": async (_argv, state) => previousChildDirectory(state),
1463
1668
  export: async (argv, state) => {
1464
- if (argv.length === 1) {
1669
+ if (argv.length === 1 || argv.length === 2 && argv[1] === "-p") {
1465
1670
  const listing = Object.entries(state.env)
1671
+ .filter(([name]) => !state.shellOptions.posix || state.exported.has(name))
1466
1672
  .sort(([a], [b]) => a.localeCompare(b))
1467
1673
  .map(([name, value]) => `export ${name}=${JSON.stringify(value)}\n`)
1468
1674
  .join("");
@@ -1477,6 +1683,7 @@ const builtins = {
1477
1683
  else if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(item))
1478
1684
  state.env[item] ??= "";
1479
1685
  else return result(1, "", `bunmsh: export: ${item}: invalid name\n`);
1686
+ state.exported.add(assignment?.name ?? item);
1480
1687
  }
1481
1688
  return result();
1482
1689
  },
@@ -1488,6 +1695,7 @@ const builtins = {
1488
1695
  return result(1, "", `bunmsh: unset: ${name}: invalid name\n`);
1489
1696
  if (state.readonly.has(name)) return readonlyError(name);
1490
1697
  delete state.env[name];
1698
+ state.exported.delete(name);
1491
1699
  }
1492
1700
  return result();
1493
1701
  },
@@ -1513,7 +1721,7 @@ const builtins = {
1513
1721
  return result();
1514
1722
  },
1515
1723
  env: async (argv, state, input, context = {}) => {
1516
- let env = { ...state.env };
1724
+ let env = { ...externalEnvironment(state) };
1517
1725
  let i = 1;
1518
1726
  for (; i < argv.length; i++) {
1519
1727
  const item = argv[i];
@@ -1535,6 +1743,7 @@ const builtins = {
1535
1743
  const childState = {
1536
1744
  ...state,
1537
1745
  env,
1746
+ exported: new Set(Object.keys(env)),
1538
1747
  directoryHistory: [...state.directoryHistory],
1539
1748
  tabs: [...state.tabs],
1540
1749
  readonly: new Set(state.readonly),
@@ -1677,14 +1886,37 @@ const builtins = {
1677
1886
  }
1678
1887
  return result(status, "", stderr);
1679
1888
  },
1889
+ trap: async (argv,state) => setTrap(argv,state),
1680
1890
  set: async (argv, state) => {
1681
1891
  if (argv.length === 1)
1682
1892
  return result(0, Object.entries(state.env).sort().map(([k, v]) => `${k}=${JSON.stringify(v)}\n`).join(""));
1683
- if (argv[1] === "--") {
1684
- state.args = [state.args[0], ...argv.slice(2)];
1685
- 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
+ }
1686
1917
  }
1687
- 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();
1688
1920
  },
1689
1921
  };
1690
1922
 
@@ -1702,6 +1934,13 @@ export function bunShellFallbackArgv(argv) {
1702
1934
  let commandArgv = argv;
1703
1935
  if (argv[0] === "ls")
1704
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
+ }
1705
1944
  if (argv[0] === "cp")
1706
1945
  commandArgv = commandArgv.map((value, index) =>
1707
1946
  index > 0 && /^-[^-]/.test(value) ? `-${value.slice(1).replaceAll("r", "R")}` : value);
@@ -1709,9 +1948,36 @@ export function bunShellFallbackArgv(argv) {
1709
1948
  }
1710
1949
 
1711
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();
1712
1978
  const shell = Bun.$`${bunShellFallbackArgv(argv)}`
1713
1979
  .cwd(nativePath(state.cwd))
1714
- .env(state.env)
1980
+ .env(externalEnvironment(state))
1715
1981
  .nothrow();
1716
1982
  if (state.pipelineChild) {
1717
1983
  const output = await shell;
@@ -1727,6 +1993,42 @@ async function runFancyLsFallback(argv, state) {
1727
1993
  }
1728
1994
 
1729
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
+ }
1730
2032
  const cmd = IS_COMPILED
1731
2033
  ? [...EXECUTABLE_COMMAND, "--bun-shell-fallback", ...bunShellFallbackArgv(argv)]
1732
2034
  : [...BUN_RUNTIME_COMMAND, BUNMSH_ENTRY, "--bun-shell-fallback", ...bunShellFallbackArgv(argv)];
@@ -1734,7 +2036,7 @@ async function runBunShellCpFallback(argv, state) {
1734
2036
  cmd,
1735
2037
  cwd: nativePath(state.cwd),
1736
2038
  env: {
1737
- ...state.env,
2039
+ ...externalEnvironment(state),
1738
2040
  BUN_ENABLE_EXPERIMENTAL_SHELL_BUILTINS: "1",
1739
2041
  },
1740
2042
  stdin: "inherit",
@@ -1756,7 +2058,7 @@ async function runReflectedProcess(cmd, state) {
1756
2058
  const proc = Bun.spawn({
1757
2059
  cmd,
1758
2060
  cwd: nativePath(state.cwd),
1759
- env: state.env,
2061
+ env: externalEnvironment(state),
1760
2062
  stdin: "inherit",
1761
2063
  stdout: inherit ? "inherit" : "pipe",
1762
2064
  stderr: inherit ? "inherit" : "pipe",
@@ -1802,7 +2104,10 @@ async function runCatFallback(argv, state, input) {
1802
2104
  const chunks = [];
1803
2105
  let status = 0;
1804
2106
  let stderr = "";
1805
- 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;
1806
2111
  const emit = async (chunk) => {
1807
2112
  if (writer) {
1808
2113
  writer.write(chunk);
@@ -1933,6 +2238,44 @@ async function runHeadFallback(argv, state, input) {
1933
2238
  return result(0, concatBytes(output));
1934
2239
  }
1935
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
+
1936
2279
  async function runTextFilter(argv, state, input, kind) {
1937
2280
  try {
1938
2281
  let args = argv.slice(1), reverse = false, numeric = false, unique = false;
@@ -2049,17 +2392,29 @@ function expandTrSet(value) {
2049
2392
  }
2050
2393
 
2051
2394
  async function runTrFallback(argv, _state, input) {
2052
- let del = false, i = 1;
2053
- if (argv[i] === "-d") del = true, i++;
2054
- if (!argv[i] || (!del && !argv[i + 1])) return result(1, "", "bunmsh: tr: missing operand\n");
2055
- const from = expandTrSet(argv[i++]);
2056
- 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);
2057
2410
  const text = decoder.decode(await readFallbackInput(input));
2058
- let output = "";
2411
+ let output = "", previous;
2059
2412
  for (const ch of text) {
2060
2413
  const index = from.indexOf(ch);
2061
- if (index < 0) output += ch;
2062
- 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;
2063
2418
  }
2064
2419
  return result(0, output);
2065
2420
  }
@@ -2076,7 +2431,7 @@ async function runCurlFallback(argv, state, input, context = {}) {
2076
2431
  const stderr = [];
2077
2432
  const io = {
2078
2433
  cwd: nativePath(state.cwd),
2079
- env: state.env,
2434
+ env: externalEnvironment(state),
2080
2435
  stdoutIsTTY: streamStdout && Boolean(process.stdout.isTTY),
2081
2436
  readStdin: () => readFallbackInput(input),
2082
2437
  async writeStdout(chunk) {
@@ -2206,17 +2561,24 @@ function grepFiles(operands, state, recursive) {
2206
2561
  }
2207
2562
 
2208
2563
  function grepBasicRegex(pattern) {
2209
- let output = "", inClass = false;
2564
+ let output = "", inClass = false, branchStart = true;
2210
2565
  for (let i = 0; i < pattern.length; i++) {
2211
2566
  const ch = pattern[i];
2212
- if (ch === "[") inClass = true;
2213
- if (ch === "]" && inClass) inClass = false;
2214
- if (!inClass && ch === "\\" && i + 1 < pattern.length && "+?|(){}".includes(pattern[i + 1])) {
2215
- 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 === "|";
2216
2571
  continue;
2217
2572
  }
2218
- 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}`;
2219
2578
  else output += ch;
2579
+ if (ch === "[") inClass = true;
2580
+ else if (ch === "]" && inClass) inClass = false;
2581
+ branchStart=false;
2220
2582
  }
2221
2583
  return output;
2222
2584
  }
@@ -2233,6 +2595,7 @@ function grepPosixClasses(pattern) {
2233
2595
 
2234
2596
  async function runGrepFallback(argv, state, input, context = {}) {
2235
2597
  let flags = "", colorMode = "never", i = 1;
2598
+ const patterns=[];
2236
2599
  while (argv[i]?.startsWith("-") && argv[i] !== "-") {
2237
2600
  if (argv[i] === "--") { i++; break; }
2238
2601
  const colorOption = /^--colou?r(?:=(always|auto|never))?$/.exec(argv[i]);
@@ -2241,16 +2604,24 @@ async function runGrepFallback(argv, state, input, context = {}) {
2241
2604
  i++;
2242
2605
  continue;
2243
2606
  }
2244
- 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]))
2245
2613
  return result(2, "", `bunmsh: grep: unsupported option: ${argv[i]}\n`);
2246
2614
  flags += argv[i++].slice(1);
2247
2615
  }
2248
- const pattern = argv[i++];
2249
- 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
+ }
2250
2621
  let regex;
2251
- const regexSource = flags.includes("F")
2252
- ? pattern.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&")
2253
- : 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("|");
2254
2625
  try { regex = new RegExp(flags.includes("x") ? `^(?:${regexSource})$` : regexSource, `${flags.includes("i") ? "i" : ""}${flags.includes("o") ? "g" : ""}`); }
2255
2626
  catch (error) { return result(2, "", `bunmsh: grep: ${error.message}\n`); }
2256
2627
  const invert = flags.includes("v"), quiet = flags.includes("q"), numbers = flags.includes("n"), only = flags.includes("o");
@@ -2261,15 +2632,16 @@ async function runGrepFallback(argv, state, input, context = {}) {
2261
2632
  const highlightRegex = color && !only
2262
2633
  ? new RegExp(regex.source, regex.flags.includes("g") ? regex.flags : `${regex.flags}g`)
2263
2634
  : null;
2264
- let matched = false;
2635
+ let matched = false, count = 0;
2265
2636
  const output = [];
2266
2637
  const processLine = (line, lineNumber, label, showLabel) => {
2267
2638
  regex.lastIndex = 0;
2268
2639
  const isMatch = regex.test(line);
2269
2640
  regex.lastIndex = 0;
2270
2641
  if (isMatch === invert) return false;
2271
- matched = true;
2642
+ matched = true; count++;
2272
2643
  if (quiet) return true;
2644
+ if (flags.includes("c")) return false;
2273
2645
  const prefix = `${showLabel ? `${label}:` : ""}${numbers ? `${lineNumber}:` : ""}`;
2274
2646
  if (only) {
2275
2647
  if (invert) return false;
@@ -2292,11 +2664,13 @@ async function runGrepFallback(argv, state, input, context = {}) {
2292
2664
  const files = grepFiles(operands, state, flags.includes("r"));
2293
2665
  const showLabel = files.length > 1 || flags.includes("r");
2294
2666
  for (const file of files) {
2667
+ count=0;
2295
2668
  const lines = (await Bun.file(file.path).text()).split("\n");
2296
2669
  if (lines.at(-1) === "") lines.pop();
2297
2670
  for (let line = 0; line < lines.length; line++)
2298
2671
  if (processLine(lines[line], line + 1,
2299
2672
  implicitCwd ? file.shown.replace(/^\.\//, "") : file.shown, showLabel)) return result(0);
2673
+ if (flags.includes("c")) output.push(`${showLabel ? `${file.shown}:` : ""}${count}\n`);
2300
2674
  }
2301
2675
  } catch (error) { return result(2, "", `bunmsh: grep: ${error.message}\n`); }
2302
2676
  } else {
@@ -2316,93 +2690,10 @@ async function runGrepFallback(argv, state, input, context = {}) {
2316
2690
  }
2317
2691
  } finally { try { reader.releaseLock(); } catch {} }
2318
2692
  }
2693
+ if (!operands.length && flags.includes("c") && !quiet) output.push(`${count}\n`);
2319
2694
  return result(matched ? 0 : 1, output.join(""));
2320
2695
  }
2321
2696
 
2322
- function splitSedCommands(script) {
2323
- const commands = [];
2324
- let start = 0, escaped = false;
2325
- for (let i = 0; i < script.length; i++) {
2326
- if (escaped) { escaped = false; continue; }
2327
- if (script[i] === "\\") { escaped = true; continue; }
2328
- if (script[i] === ";") {
2329
- commands.push(script.slice(start, i).trim());
2330
- start = i + 1;
2331
- }
2332
- }
2333
- commands.push(script.slice(start).trim());
2334
- return commands.filter(Boolean);
2335
- }
2336
-
2337
- function readSedField(command, start, delimiter) {
2338
- let value = "";
2339
- for (let i = start; i < command.length; i++) {
2340
- if (command[i] === "\\" && i + 1 < command.length) {
2341
- if (command[i + 1] === delimiter) value += command[++i];
2342
- else value += command[i] + command[++i];
2343
- } else if (command[i] === delimiter) return [value, i + 1];
2344
- else value += command[i];
2345
- }
2346
- throw new Error("unterminated substitute command");
2347
- }
2348
-
2349
- function sedReplacement(value) {
2350
- let output = "";
2351
- for (let i = 0; i < value.length; i++) {
2352
- if (value[i] === "\\" && /[1-9]/.test(value[i + 1] ?? "")) output += `$${value[++i]}`;
2353
- else if (value[i] === "\\" && i + 1 < value.length) output += value[++i];
2354
- else if (value[i] === "&") output += "$&";
2355
- else if (value[i] === "$") output += "$$";
2356
- else output += value[i];
2357
- }
2358
- return output;
2359
- }
2360
-
2361
- function compileSedCommand(source, extended) {
2362
- const numberedPrint = /^(\d+)p$/.exec(source);
2363
- if (numberedPrint) return { type: "print", line: Number(numberedPrint[1]) };
2364
- if (!source.startsWith("s") || source.length < 2)
2365
- throw new Error(`unsupported command: ${source}`);
2366
- const delimiter = source[1];
2367
- const [pattern, replacementStart] = readSedField(source, 2, delimiter);
2368
- const [replacement, flagsStart] = readSedField(source, replacementStart, delimiter);
2369
- const flags = source.slice(flagsStart);
2370
- if (!/^[gp]*$/.test(flags)) throw new Error(`unsupported substitute flags: ${flags}`);
2371
- const regexSource = grepPosixClasses(extended ? pattern : grepBasicRegex(pattern));
2372
- return {
2373
- type: "substitute",
2374
- regex: new RegExp(regexSource, flags.includes("g") ? "g" : ""),
2375
- replacement: sedReplacement(replacement),
2376
- print: flags.includes("p"),
2377
- };
2378
- }
2379
-
2380
- function applySed(text, commands, quiet) {
2381
- const trailingNewline = text.endsWith("\n");
2382
- const lines = text.split("\n");
2383
- if (trailingNewline) lines.pop();
2384
- const output = [];
2385
- for (let index = 0; index < lines.length; index++) {
2386
- let line = lines[index];
2387
- for (const command of commands) {
2388
- if (command.type === "print") {
2389
- if (command.line === index + 1) output.push(line);
2390
- continue;
2391
- }
2392
- command.regex.lastIndex = 0;
2393
- const matched = command.regex.test(line);
2394
- command.regex.lastIndex = 0;
2395
- if (matched) {
2396
- line = line.replace(command.regex, command.replacement);
2397
- if (command.print) output.push(line);
2398
- }
2399
- }
2400
- if (!quiet) output.push(line);
2401
- }
2402
- if (!output.length) return "";
2403
- return output.join("\n") + (trailingNewline ? "\n" : "");
2404
- }
2405
-
2406
2697
  async function runSedFallback(argv, state, input) {
2407
2698
  let quiet = false, extended = false, inPlace = false, i = 1;
2408
2699
  const scripts = [];
@@ -2415,6 +2706,13 @@ async function runSedFallback(argv, state, input) {
2415
2706
  continue;
2416
2707
  }
2417
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
+ }
2418
2716
  if (!/^-[nEri]+$/.test(option))
2419
2717
  return result(1, "", `bunmsh: sed: unsupported option: ${option}\n`);
2420
2718
  quiet ||= option.includes("n");
@@ -2424,19 +2722,23 @@ async function runSedFallback(argv, state, input) {
2424
2722
  if (!scripts.length && i < argv.length) scripts.push(argv[i++]);
2425
2723
  if (!scripts.length) return result(1, "", "bunmsh: sed: missing script\n");
2426
2724
  let commands;
2427
- try { commands = scripts.flatMap(splitSedCommands).map((script) => compileSedCommand(script, extended)); }
2725
+ try { commands = compileSed(scripts.join("\n"), pattern => grepPosixClasses(extended ? pattern : grepBasicRegex(pattern))); }
2428
2726
  catch (error) { return result(1, "", `bunmsh: sed: ${error.message}\n`); }
2429
2727
  const operands = argv.slice(i);
2430
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
+ };
2431
2733
  try {
2432
2734
  if (!operands.length) {
2433
2735
  const text = decoder.decode(await readFallbackInput(input));
2434
- return result(0, applySed(text, commands, quiet));
2736
+ return result(0, applySed(text, commands, quiet, readExtra));
2435
2737
  }
2436
2738
  let stdout = "";
2437
2739
  for (const operand of operands) {
2438
2740
  const file = isAbsolute(nativePath(operand)) ? nativePath(operand) : resolvePath(nativePath(state.cwd), nativePath(operand));
2439
- const changed = applySed(await Bun.file(file).text(), commands, quiet);
2741
+ const changed = applySed(await Bun.file(file).text(), commands, quiet, readExtra);
2440
2742
  if (inPlace) await Bun.write(file, changed);
2441
2743
  else stdout += changed;
2442
2744
  }
@@ -2510,16 +2812,47 @@ function runLnFallback(argv, state) {
2510
2812
  return result(status, "", stderr);
2511
2813
  }
2512
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
+
2513
2846
  function runChmodFallback(argv, state) {
2514
- if (argv.length < 3) return result(1, "", "bunmsh: chmod: usage: chmod MODE FILE...\n");
2515
- 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");
2516
2851
  let status = 0, stderr = "";
2517
- for (const operand of argv.slice(2)) {
2852
+ for (const operand of argv.slice(i)) {
2518
2853
  const path = isAbsolute(nativePath(operand)) ? nativePath(operand) : resolvePath(nativePath(state.cwd), nativePath(operand));
2519
2854
  try {
2520
- if (/^[0-7]{3,4}$/.test(mode)) chmodSync(path, Number.parseInt(mode, 8));
2521
- else if (mode === "+x" || mode === "a+x") chmodSync(path, statSync(path).mode | 0o111);
2522
- else throw new Error(`unsupported mode: ${mode}`);
2855
+ chmodSync(path, /^[0-7]{1,4}$/.test(mode) ? Number.parseInt(mode, 8) : symbolicFileMode(mode, statSync(path)));
2523
2856
  } catch (error) { status = 1; stderr += `bunmsh: chmod: ${operand}: ${error.message}\n`; }
2524
2857
  }
2525
2858
  return result(status, "", stderr);
@@ -2640,7 +2973,7 @@ async function queryProcessList(name, state) {
2640
2973
  proc = Bun.spawn({
2641
2974
  cmd: command,
2642
2975
  cwd: nativePath(state.cwd),
2643
- env: state.env,
2976
+ env: externalEnvironment(state),
2644
2977
  stdin: "ignore",
2645
2978
  stdout: "pipe",
2646
2979
  stderr: "pipe",
@@ -2822,12 +3155,157 @@ async function runPspacFallback(argv, state) {
2822
3155
  return result(0, colorProcessTable(rows), query.stderr);
2823
3156
  }
2824
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(pipelineCompoundArgv(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
+
2825
3299
  const fallbackBuiltins = {
3300
+ awk: runAwkFallback,
3301
+ diff: runDiffFallback,
3302
+ cmp: runCmpFallback,
3303
+ expr: runExprFallback,
2826
3304
  basename: async (argv) => {
2827
3305
  const i = argv[1] === "--" ? 2 : 1;
2828
3306
  if (i >= argv.length || argv.length - i > 2)
2829
3307
  return result(1, "", "bunmsh: basename: usage: basename string [suffix]\n");
2830
- let name = pathBasename(argv[i]);
3308
+ let name = /^\/+$/.test(argv[i]) ? "/" : pathBasename(argv[i]);
2831
3309
  const suffix = argv[i + 1];
2832
3310
  if (suffix && suffix !== name && name.endsWith(suffix)) name = name.slice(0, -suffix.length);
2833
3311
  return result(0, `${name}\n`);
@@ -2867,11 +3345,14 @@ const fallbackBuiltins = {
2867
3345
  clear: async () => result(0, "\x1b[2J\x1b[H"),
2868
3346
  rmdir: async (argv, state) => runRmdirFallback(argv, state),
2869
3347
  mktemp: async (argv, state) => runMktempFallback(argv, state),
3348
+ uniq: runUniqFallback,
2870
3349
  sort: (argv, state, input) => runTextFilter(argv, state, input, "sort"),
2871
3350
  date: async (argv) => runDateFallback(argv),
2872
3351
  md5sum: (argv, state, input) => runHashFallback(argv, state, input, "md5"),
2873
3352
  sha256sum: (argv, state, input) => runHashFallback(argv, state, input, "sha256"),
2874
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),
2875
3356
  sed: runSedFallback,
2876
3357
  cut: runCutFallback,
2877
3358
  ln: async (argv, state) => runLnFallback(argv, state),
@@ -2921,8 +3402,14 @@ function formatMilliseconds(milliseconds, color = Boolean(process.stderr.isTTY))
2921
3402
  return output;
2922
3403
  }
2923
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
+
2924
3411
  export function createState(options = {}) {
2925
- const env = canonicalEnvironment({ ...process.env, ...(options.env ?? {}) });
3412
+ const env = canonicalEnvironment({ ...(options.inheritEnvironment === false ? {} : process.env), ...(options.env ?? {}) });
2926
3413
  const cwd = shellPath(options.cwd ?? process.cwd());
2927
3414
  env.PWD = cwd;
2928
3415
  return {
@@ -2937,17 +3424,29 @@ export function createState(options = {}) {
2937
3424
  mouseTracking: options.mouseTracking ?? /^(?:1|true|on|yes)$/i.test(env.BUNMSH_MOUSE ?? ""),
2938
3425
  pathSearch: options.pathSearch ?? true,
2939
3426
  readonly: new Set(options.readonly ?? []),
3427
+ exported: new Set(options.exported ?? Object.keys(env)),
2940
3428
  getoptsOffset: 1,
2941
3429
  args: options.args ?? ["bunmsh"],
2942
3430
  lastStatus: 0,
2943
3431
  exitRequested: false,
2944
3432
  exitStatus: 0,
2945
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,
2946
3445
  };
2947
3446
  }
2948
3447
 
2949
3448
  function hasCompoundSyntax(source) {
2950
- 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;
2951
3450
  let commandStart = true;
2952
3451
  for (const token of tokenize(source)) {
2953
3452
  if (token.type === "op") {
@@ -2956,14 +3455,15 @@ function hasCompoundSyntax(source) {
2956
3455
  }
2957
3456
  const word = token.fragments.length === 1 && token.fragments[0].quote === "none"
2958
3457
  ? token.fragments[0].text : null;
2959
- if (commandStart && ["if", "while", "until", "for", "case"].includes(word)) return true;
3458
+ if (commandStart && ["if", "while", "until", "for", "case", "{"].includes(word)) return true;
2960
3459
  if (!(commandStart && /^[A-Za-z_][A-Za-z0-9_]*=/.test(word ?? ""))) commandStart = false;
2961
3460
  }
2962
3461
  return false;
2963
3462
  }
2964
3463
 
2965
- function compoundLogicalSource(source) {
2966
- const tokens = tokenize(source);
3464
+ function compoundLogicalSource(source, firstLine = 1, sourceLines = null) {
3465
+ const continuations = new Map();
3466
+ const tokens = tokenize(source, { continuations });
2967
3467
  const breaks = new Set();
2968
3468
  let commandStart = true;
2969
3469
  const literal = (token) => token.type === "word" && token.fragments.length === 1 &&
@@ -2975,6 +3475,24 @@ function compoundLogicalSource(source) {
2975
3475
  continue;
2976
3476
  }
2977
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
+ }
2978
3496
  if (!commandStart) continue;
2979
3497
  if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word ?? "")) continue;
2980
3498
  // Like mksh's KEYWORD lexer mode, these words are special only where a
@@ -2990,11 +3508,15 @@ function compoundLogicalSource(source) {
2990
3508
  breaks.add(tokens[index + 1].offset);
2991
3509
  commandStart = ["if", "while", "until", "elif", "then", "do", "else"].includes(word);
2992
3510
  }
2993
- let logical = source;
2994
- for (const offset of [...breaks].sort((a, b) => b - a))
2995
- if (offset > 0 && source[offset - 1] !== "\n")
2996
- logical = `${logical.slice(0, offset)}\n${logical.slice(offset)}`;
2997
- 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};
2998
3520
  }
2999
3521
 
3000
3522
  // A leaf command inside a compound body may continue across physical lines
@@ -3007,11 +3529,20 @@ function growLogicalCommand(lines, index) {
3007
3529
  let candidate = line;
3008
3530
  while (true) {
3009
3531
  try {
3010
- 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
+ }
3011
3542
  return { source: candidate, next: end + 1 };
3012
3543
  } catch (error) {
3013
3544
  if (!(error instanceof ShellSyntaxError) ||
3014
- !/^(?:unterminated (?:here-document|single quote|double quote|command substitution|substitution))/.test(error.message))
3545
+ !/^(?:unterminated (?:here-document|single quote|double quote|command substitution|parameter expansion|substitution))/.test(error.message))
3015
3546
  throw error;
3016
3547
  end++;
3017
3548
  if (end >= lines.length) throw error;
@@ -3020,16 +3551,75 @@ function growLogicalCommand(lines, index) {
3020
3551
  }
3021
3552
  }
3022
3553
 
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
+
3023
3572
  function makeCompoundPostfix(compound, suffix, source, nextLine) {
3024
- const match = /^\s*(\|\||&&|\||;)\s*(.*)$/.exec(suffix ?? "");
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 ?? "");
3025
3588
  if (!match) return { node: compound, nextLine };
3026
3589
  let command = match[2].trim();
3590
+ let commandLine = null;
3591
+ if (match[1] === ";" && (!command || command.startsWith("#"))) return { node: compound, nextLine };
3027
3592
  while (!command && nextLine < source.length) {
3593
+ commandLine = nextLine;
3028
3594
  command = source[nextLine++].trim();
3029
3595
  if (!command || command.startsWith("#")) command = "";
3030
3596
  }
3031
3597
  if (!command)
3032
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
+ }
3033
3623
  return {
3034
3624
  node: {
3035
3625
  type: "compoundPostfix",
@@ -3111,7 +3701,35 @@ function splitCasePatterns(source) {
3111
3701
  // Scan case clauses in the same lexical spirit as mksh's CASEPAT/KEYWORD
3112
3702
  // modes. In particular, clause boundaries are shell tokens, not line
3113
3703
  // boundaries: Autoconf commonly writes an entire clause on one line.
3114
- function parseCaseArms(source) {
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) {
3115
3733
  const arms = [];
3116
3734
  let i = 0;
3117
3735
  const skipSpace = () => {
@@ -3153,43 +3771,23 @@ function parseCaseArms(source) {
3153
3771
  i++;
3154
3772
 
3155
3773
  const bodyStart = i;
3156
- quote = null;
3157
3774
  let nestedCases = 0;
3158
- let word = "";
3159
3775
  let terminator = null;
3160
- const finishWord = () => {
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);
3161
3782
  if (word === "case") nestedCases++;
3162
3783
  else if (word === "esac" && nestedCases) nestedCases--;
3163
- word = "";
3164
- };
3165
- for (; i < source.length; i++) {
3166
- const ch = source[i];
3167
- if (ch === "#" && !word && (i === bodyStart || /[\s;|&]/.test(source[i - 1] ?? ""))) {
3168
- while (i < source.length && source[i] !== "\n") i++;
3169
- continue;
3170
- }
3171
- if (ch === "\\") { word = ""; i++; continue; }
3172
- if (quote) {
3173
- if (ch === quote) quote = null;
3174
- continue;
3175
- }
3176
- if (ch === "'" || ch === '"') { finishWord(); quote = ch; continue; }
3177
- if (ch === "$" && (source[i + 1] === "(" || source[i + 1] === "{")) {
3178
- finishWord();
3179
- i = (source[i + 1] === "(" ? substitutionEnd(source, i) : parameterEnd(source, i)) - 1;
3180
- continue;
3181
- }
3182
- if (ch === "`") {
3183
- finishWord();
3184
- try { i = substitutionEnd(source, i) - 1; }
3185
- catch (error) { error.message += ` near ${JSON.stringify(source.slice(Math.max(0, i - 100), i + 180))}`; throw error; }
3186
- continue;
3187
- }
3188
- if (/[A-Za-z_]/.test(ch)) { word += ch; continue; }
3189
- finishWord();
3190
- if (!nestedCases && ch === ";" && [";", "&", "|"].includes(source[i + 1])) {
3191
- terminator = source.slice(i, i + 2);
3192
- break;
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
+ }
3193
3791
  }
3194
3792
  }
3195
3793
  // POSIX allows the final clause to omit `;;` immediately before esac.
@@ -3197,10 +3795,11 @@ function parseCaseArms(source) {
3197
3795
  if (source.slice(bodyStart, i).trim() || arms.length) terminator = ";;";
3198
3796
  else throw new ShellSyntaxError("unterminated case clause");
3199
3797
  }
3200
- const bodySource = source.slice(bodyStart, i).trim();
3798
+ const bodySource = source.slice(bodyStart, i);
3201
3799
  let body;
3202
3800
  try {
3203
- body = parseCompoundScript(bodySource);
3801
+ const first = source.slice(0,bodyStart).split("\n").length - 1;
3802
+ body = parseCompoundScript(bodySource, sourceLines?.[first] ?? 1, sourceLines?.slice(first));
3204
3803
  } catch (error) {
3205
3804
  if (error instanceof ShellSyntaxError)
3206
3805
  error.message += ` in case arm ${patterns.join("|")}`;
@@ -3212,8 +3811,41 @@ function parseCaseArms(source) {
3212
3811
  return arms;
3213
3812
  }
3214
3813
 
3215
- export function parseCompoundScript(source) {
3216
- const lines = compoundLogicalSource(source.replace(/\\\r?\n/g, "")).split(/\r?\n/);
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/);
3217
3849
  const parseSequence = (start, stops = []) => {
3218
3850
  const nodes = [];
3219
3851
  let i = start;
@@ -3223,51 +3855,63 @@ export function parseCompoundScript(source) {
3223
3855
  if (stops.some((stop) => stop.test(trimmed))) break;
3224
3856
  if (!trimmed || trimmed.startsWith("#")) { i++; continue; }
3225
3857
 
3226
- 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);
3227
3859
  if (functionMatch) {
3228
- 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|;|$)/]);
3229
3865
  if (body.index >= lines.length) throw new ShellSyntaxError(`unterminated function ${functionMatch[1]}`);
3230
3866
  nodes.push({ type: "function", name: functionMatch[1], body: body.nodes });
3231
3867
  i = body.index + 1;
3232
3868
  continue;
3233
3869
  }
3234
3870
 
3235
- const subshellMatch = /^\((.*)\)\s*;?$/.exec(trimmed);
3236
- if (subshellMatch) {
3237
- nodes.push({ type: "subshell", source: subshellMatch[1] });
3238
- 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;
3239
3878
  continue;
3240
3879
  }
3241
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
+
3242
3894
  if (/^if(?:\s|$)/.test(trimmed)) {
3243
3895
  const compoundStart = i;
3244
3896
  const branches = [];
3245
- let header = trimmed;
3246
- while (!/(?:;\s*|\s+)then(?:\s+.*)?$/.test(header) && ++i < lines.length)
3247
- header += ` ${lines[i].trim()}`;
3248
- let then = /(?:;\s*|\s+)then(?:\s+(.*))?$/.exec(header);
3249
- if (!then) throw new ShellSyntaxError("if requires then");
3250
- let condition = header.slice(0, then.index).replace(/^if\s*/, "").trim();
3251
- let thenCommand = then[1]?.trim() ?? "";
3252
- i++;
3897
+ let header = readCompoundHeader(lines, i, "then");
3898
+ let condition = header.condition;
3899
+ let thenCommand = header.tail;
3900
+ i = header.end + 1;
3253
3901
  while (true) {
3254
- const body = parseSequence(i, [/^elif(?:\s|$)/, /^else\s*;?$/, /^fi\b/]);
3902
+ const body = parseSequence(i, [/^elif(?:\s|$)/, /^else(?:\s*;\s*|\s+|$)(?:#.*)?$/, /^fi\b/]);
3255
3903
  branches.push({ condition, body: body.nodes, thenCommand });
3256
3904
  i = body.index;
3257
3905
  const stop = lines[i]?.trim() ?? "";
3258
3906
  if (/^elif(?:\s|$)/.test(stop)) {
3259
- header = stop;
3260
- while (!/(?:;\s*|\s+)then(?:\s+.*)?$/.test(header) && ++i < lines.length)
3261
- header += ` ${lines[i].trim()}`;
3262
- then = /(?:;\s*|\s+)then(?:\s+(.*))?$/.exec(header);
3263
- if (!then) throw new ShellSyntaxError("elif requires then");
3264
- condition = header.slice(0, then.index).replace(/^elif\s*/, "").trim();
3265
- thenCommand = then[1]?.trim() ?? "";
3266
- i++;
3907
+ header = readCompoundHeader(lines, i, "then");
3908
+ condition = header.condition;
3909
+ thenCommand = header.tail;
3910
+ i = header.end + 1;
3267
3911
  continue;
3268
3912
  }
3269
3913
  let otherwise = [];
3270
- if (/^else\s*;?$/.test(stop)) {
3914
+ if (/^else(?:\s*;\s*|\s+|$)(?:#.*)?$/.test(stop)) {
3271
3915
  const body = parseSequence(i + 1, [/^fi\b/]);
3272
3916
  otherwise = body.nodes;
3273
3917
  i = body.index;
@@ -3297,7 +3941,7 @@ export function parseCompoundScript(source) {
3297
3941
  if (!/(?:;\s*|\s+)do\s*$/.test(header)) throw new ShellSyntaxError(`${kind} requires do`);
3298
3942
  const condition = header.replace(new RegExp(`^${kind}\\s*`), "").replace(/(?:;\s*|\s+)do\s*$/, "");
3299
3943
  const body = parseSequence(i + 1, [/^done\b/]);
3300
- const done = /^done(?:\s*<\s*(.+?))?\s*;?\s*((?:\|\||&&|\||;).*)?$/.exec(lines[body.index]?.trim() ?? "");
3944
+ const done = /^done(?=$|[\s;<|&])(?:\s*<\s*(.+?))?\s*;?\s*((?:\|\||&&|\||;|#).*)?$/.exec(lines[body.index]?.trim() ?? "");
3301
3945
  if (!done)
3302
3946
  throw new ShellSyntaxError(`unterminated ${kind}`);
3303
3947
  const compound = {
@@ -3344,62 +3988,44 @@ export function parseCompoundScript(source) {
3344
3988
  continue;
3345
3989
  }
3346
3990
  if (/^case(?:\s|$)/.test(trimmed)) {
3347
- const compoundStart = i;
3348
- let header = trimmed;
3349
- while (!/\s+in(?:\s|$)/.test(header) && ++i < lines.length)
3350
- header += ` ${lines[i].trim()}`;
3351
- const match = /^case\s+(.+?)\s+in(?:\s+(.*))?$/.exec(header);
3352
- if (!match) throw new ShellSyntaxError("case requires in");
3353
- const inlineEsac = findShellKeyword(match[2] ?? "", "esac");
3354
- if (inlineEsac >= 0) {
3355
- const inlineArms = match[2].slice(0, inlineEsac);
3356
- const suffix = match[2].slice(inlineEsac + "esac".length);
3357
- const compound = {
3358
- type: "case",
3359
- word: match[1],
3360
- arms: parseCaseArms(inlineArms),
3361
- source: `case ${match[1]} in\n${inlineArms}\nesac`,
3362
- };
3363
- const result = makeCompoundPostfix(compound, suffix, lines, i + 1);
3364
- nodes.push(result.node);
3365
- i = result.nextLine;
3366
- continue;
3367
- }
3368
- const bodyLines = match[2] ? [match[2]] : [];
3369
- i++;
3370
- let nestedCases = 0;
3371
- while (i < lines.length) {
3372
- const current = lines[i].trim();
3373
- const nestedCase = findShellKeyword(current, "case");
3374
- if (nestedCase >= 0 && /\s/.test(current[nestedCase + "case".length] ?? ""))
3375
- nestedCases++;
3376
- // In an enclosing case arm, mksh tokenises `esac;;` as two
3377
- // independent tokens: ESAC followed by the enclosing arm's BREAK.
3378
- // Count that as the end of the nested case while retaining the
3379
- // whole line for parseCaseArms to assign the `;;` to its parent.
3380
- const closingCase = findShellKeyword(current, "esac");
3381
- if (closingCase >= 0) {
3382
- if (!nestedCases) break;
3383
- nestedCases--;
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--;
3384
4003
  }
3385
- bodyLines.push(lines[i++]);
3386
4004
  }
3387
- if (!/^esac\b/.test(lines[i]?.trim() ?? ""))
3388
- throw new ShellSyntaxError(`unterminated case: ${trimmed} (at ${lines[i]?.trim() ?? "EOF"})`);
3389
- const compound = {
3390
- type: "case", word: match[1], arms: parseCaseArms(bodyLines.join("\n")),
3391
- source: [...lines.slice(compoundStart, i), "esac"].join("\n"),
3392
- };
3393
- const close = /^esac\b(.*)$/.exec(lines[i].trim());
3394
- const result = makeCompoundPostfix(compound, close?.[1] ?? "", lines, i + 1);
3395
- nodes.push(result.node);
3396
- i = result.nextLine;
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;
3397
4012
  continue;
3398
4013
  }
3399
4014
  const grown = growLogicalCommand(lines, i);
3400
- nodes.push({ type: "command", source: grown.source });
4015
+ nodes.push({ type: "command", source: grown.source, lineNumber:lineNumbers[i], sourceLines:lineNumbers.slice(i,grown.next) });
3401
4016
  i = grown.next;
3402
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
+ }
3403
4029
  return { nodes, index: i };
3404
4030
  };
3405
4031
  const parsed = parseSequence(0);
@@ -3436,22 +4062,53 @@ export function needsMoreInput(source) {
3436
4062
  return false;
3437
4063
  }
3438
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
+
3439
4080
  async function executeNodeList(nodes, state, options = {}) {
3440
4081
  const capture = options.capture ?? true;
3441
4082
  const stdout = [], stderr = [];
3442
- let execution = result(state.lastStatus);
4083
+ let execution = result();
3443
4084
  const append = (value) => {
3444
4085
  if (!capture) return;
3445
4086
  if (value.stdout.byteLength) stdout.push(value.stdout);
3446
4087
  if (value.stderr.byteLength) stderr.push(value.stderr);
3447
4088
  };
3448
4089
  for (const node of nodes) {
4090
+ if (state.exitRequested || state.flow) break;
4091
+ append(await runPendingTraps(state,{capture}));
3449
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
+ }
3450
4107
  if (node.type === "compoundPostfix") {
3451
4108
  if (node.operator === "|") {
3452
4109
  const link = new TransformStream();
3453
4110
  const leftPromise = runExternal(
3454
- pipelineCompoundArgv(node.compound.source),
4111
+ pipelineCompoundArgv(compoundSource(node.compound)),
3455
4112
  pipelineBuiltinState(state),
3456
4113
  options.input ?? null,
3457
4114
  false,
@@ -3465,30 +4122,34 @@ async function executeNodeList(nodes, state, options = {}) {
3465
4122
  aliases: { ...state.aliases },
3466
4123
  functions: { ...state.functions },
3467
4124
  readonly: new Set(state.readonly),
4125
+ exported: new Set(state.exported),
3468
4126
  directoryHistory: [...state.directoryHistory],
3469
4127
  tabs: [...state.tabs],
3470
4128
  exitRequested: false,
3471
4129
  exitStatus: 0,
3472
4130
  };
3473
4131
  const right = await runExternal(
3474
- pipelineCompoundArgv(node.command),
4132
+ pipelineCompoundArgv(node.right ? compoundSource(node.right) : node.command),
3475
4133
  pipelineBuiltinState(rightState),
3476
4134
  link.readable,
3477
4135
  true,
3478
4136
  true,
3479
4137
  );
3480
4138
  const left = await leftPromise;
3481
- execution = result(right.status, right.stdout, concatBytes([left.stderr, right.stderr]));
4139
+ execution = routeCommandOutput(result(right.status, right.stdout, concatBytes([left.stderr, right.stderr])), state.fdBindings ?? {});
3482
4140
  } else {
3483
- execution = await executeNodeList([node.compound], state, { capture });
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; }
3484
4145
  append(execution);
3485
4146
  const shouldRun = node.operator === "&&"
3486
4147
  ? execution.status === 0
3487
4148
  : node.operator === "||"
3488
4149
  ? execution.status !== 0
3489
4150
  : true;
3490
- if (shouldRun) {
3491
- execution = await execute(node.command, state, { capture });
4151
+ if (shouldRun && !state.flow && !state.exitRequested) {
4152
+ execution = node.right ? await executeNodeList([node.right],state,{capture}) : await execute(node.command, state, { capture });
3492
4153
  append(execution);
3493
4154
  }
3494
4155
  state.lastStatus = execution.status;
@@ -3504,26 +4165,16 @@ async function executeNodeList(nodes, state, options = {}) {
3504
4165
  }
3505
4166
  if (node.type === "function") {
3506
4167
  state.functions[node.name] = node.body;
4168
+ state.lastStatus = 0; execution = result();
3507
4169
  continue;
3508
4170
  }
3509
4171
  if (node.type === "command") {
3510
- 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 });
3511
4173
  append(execution);
3512
4174
  continue;
3513
4175
  }
3514
4176
  if (node.type === "subshell") {
3515
- const child = {
3516
- ...state,
3517
- env: { ...state.env },
3518
- aliases: { ...state.aliases },
3519
- functions: { ...state.functions },
3520
- readonly: new Set(state.readonly),
3521
- directoryHistory: [...state.directoryHistory],
3522
- tabs: [...state.tabs],
3523
- exitRequested: false,
3524
- exitStatus: 0,
3525
- };
3526
- execution = await execute(node.source, child, { capture });
4177
+ execution = await execute(`(${node.source})`,state,{capture,input:options.input});
3527
4178
  append(execution);
3528
4179
  state.lastStatus = execution.status;
3529
4180
  continue;
@@ -3531,6 +4182,7 @@ async function executeNodeList(nodes, state, options = {}) {
3531
4182
  if (node.type === "loop") {
3532
4183
  const previousReadLines = state.readLines;
3533
4184
  let ranBody = false;
4185
+ state.loopDepth++;
3534
4186
  try {
3535
4187
  if (node.input !== null) {
3536
4188
  const parsed = parse(`__input ${node.input}`)[0]?.pipeline[0];
@@ -3541,7 +4193,7 @@ async function executeNodeList(nodes, state, options = {}) {
3541
4193
  if (state.readLines.at(-1) === "") state.readLines.pop();
3542
4194
  }
3543
4195
  while (true) {
3544
- const checked = await execute(node.condition, state, { capture, simple: true });
4196
+ const checked = await execute(node.condition, state, { capture, errexitIgnored:true });
3545
4197
  append(checked);
3546
4198
  const proceed = node.kind === "until" ? checked.status !== 0 : checked.status === 0;
3547
4199
  if (!proceed || state.exitRequested) {
@@ -3551,12 +4203,13 @@ async function executeNodeList(nodes, state, options = {}) {
3551
4203
  execution = await executeNodeList(node.body, state, { capture });
3552
4204
  ranBody = true;
3553
4205
  append(execution);
3554
- if (state.exitRequested) break;
4206
+ if (state.exitRequested || consumeLoopFlow(state)) break;
3555
4207
  }
3556
4208
  } catch (error) {
3557
4209
  execution = result(1, "", `bunmsh: ${error.message}\n`);
3558
4210
  append(execution);
3559
4211
  } finally {
4212
+ state.loopDepth--;
3560
4213
  state.readLines = previousReadLines;
3561
4214
  }
3562
4215
  state.lastStatus = execution.status;
@@ -3571,13 +4224,14 @@ async function executeNodeList(nodes, state, options = {}) {
3571
4224
  for (const word of parsed?.words.slice(1) ?? []) values.push(...await expandWord(word, state));
3572
4225
  }
3573
4226
  execution = result();
3574
- for (const value of values) {
4227
+ state.loopDepth++;
4228
+ try { for (const value of values) {
3575
4229
  if (state.readonly.has(node.name)) { execution = readonlyError(node.name); break; }
3576
4230
  state.env[node.name] = value;
3577
4231
  execution = await executeNodeList(node.body, state, { capture });
3578
4232
  append(execution);
3579
- if (state.exitRequested) break;
3580
- }
4233
+ if (state.exitRequested || consumeLoopFlow(state)) break;
4234
+ } } finally { state.loopDepth--; }
3581
4235
  state.lastStatus = execution.status;
3582
4236
  continue;
3583
4237
  }
@@ -3605,11 +4259,9 @@ async function executeNodeList(nodes, state, options = {}) {
3605
4259
  let selectedThen = "";
3606
4260
  for (const branch of node.branches) {
3607
4261
  let condition = branch.condition.trim();
3608
- let negate = false;
3609
- while (/^!\s+/.test(condition)) { negate = !negate; condition = condition.replace(/^!\s+/, ""); }
3610
- const checked = await execute(condition, state, { capture, simple: true });
4262
+ const checked = await execute(condition, state, { capture, errexitIgnored:true });
3611
4263
  append(checked);
3612
- if ((checked.status === 0) !== negate) {
4264
+ if (checked.status === 0) {
3613
4265
  selected = branch.body;
3614
4266
  selectedThen = branch.thenCommand ?? "";
3615
4267
  break;
@@ -3630,9 +4282,17 @@ async function executeNodeList(nodes, state, options = {}) {
3630
4282
 
3631
4283
  async function executeFunction(name, argv, state) {
3632
4284
  const previousArgs = state.args;
4285
+ const previousLoopDepth = state.loopDepth;
4286
+ state.loopDepth = 0;
3633
4287
  state.args = [previousArgs[0], ...argv.slice(1)];
4288
+ state.returnDepth++;
3634
4289
  try { return await executeNodeList(state.functions[name], state); }
3635
- 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
+ }
3636
4296
  }
3637
4297
 
3638
4298
  async function runExternal(
@@ -3652,7 +4312,7 @@ async function runExternal(
3652
4312
  const options = {
3653
4313
  cmd: spawnArgv,
3654
4314
  cwd: nativePath(state.cwd),
3655
- env: state.env,
4315
+ env: externalEnvironment(state),
3656
4316
  stdin: runtimeOptions.background ? "ignore" : input === null ? "inherit" : input,
3657
4317
  stdout: runtimeOptions.background ? "inherit" : captureStdout || stdoutSink ? "pipe" : "inherit",
3658
4318
  stderr: runtimeOptions.background ? "inherit" : captureStderr || runtimeOptions.stderrSink ? "pipe" : "inherit",
@@ -3660,7 +4320,30 @@ async function runExternal(
3660
4320
  runtimeOptions.onExit?.(proc, exitCode, signalCode, error);
3661
4321
  },
3662
4322
  };
4323
+ let stateDirectory;
3663
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
+ }
3664
4347
  const proc = Bun.spawn(options);
3665
4348
  if (runtimeOptions.background) {
3666
4349
  if (!Array.isArray(Bun.sha.procs)) Bun.sha.procs = [];
@@ -3685,10 +4368,21 @@ async function runExternal(
3685
4368
  const abandonStreaming = new Promise((resolve) => {
3686
4369
  runtimeOptions.onSpawn?.(proc, pipelineKillSignal, resolve);
3687
4370
  });
3688
- 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
3689
4383
  ? new Response(proc.stdout).arrayBuffer()
3690
4384
  : Promise.resolve(new ArrayBuffer(0));
3691
- const streamPromise = stdoutSink
4385
+ const streamPromise = stdoutSink && !sharedSink
3692
4386
  ? Promise.race([
3693
4387
  proc.stdout.pipeTo(stdoutSink).catch(() => {
3694
4388
  try { proc.kill(pipelineKillSignal); } catch {}
@@ -3696,13 +4390,13 @@ async function runExternal(
3696
4390
  abandonStreaming,
3697
4391
  ])
3698
4392
  : Promise.resolve();
3699
- const errorStreamPromise = runtimeOptions.stderrSink
4393
+ const errorStreamPromise = runtimeOptions.stderrSink && !sharedSink
3700
4394
  ? Promise.race([
3701
4395
  proc.stderr.pipeTo(runtimeOptions.stderrSink).catch(() => {}),
3702
4396
  abandonStreaming,
3703
4397
  ])
3704
4398
  : Promise.resolve();
3705
- const errorPromise = captureStderr
4399
+ const errorPromise = captureStderr && !runtimeOptions.stderrSink
3706
4400
  ? new Response(proc.stderr).arrayBuffer()
3707
4401
  : Promise.resolve(new ArrayBuffer(0));
3708
4402
  const [status, output, error] = await Promise.all([
@@ -3711,13 +4405,18 @@ async function runExternal(
3711
4405
  errorPromise,
3712
4406
  streamPromise,
3713
4407
  errorStreamPromise,
4408
+ sharedStreamPromise,
3714
4409
  ]);
3715
4410
  const execution = result(status, new Uint8Array(output), new Uint8Array(error));
3716
- execution.stdoutStreamed = Boolean(stdoutSink);
4411
+ execution.stdoutStreamed = Boolean(stdoutSink || runtimeOptions.stderrSink);
3717
4412
  execution.stderrStreamed = Boolean(runtimeOptions.stderrSink);
3718
4413
  return execution;
3719
4414
  } catch (error) {
3720
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
+ }
3721
4420
  }
3722
4421
  }
3723
4422
 
@@ -3742,11 +4441,39 @@ function pipelineCompoundArgv(source) {
3742
4441
  : [...BUN_RUNTIME_COMMAND, BUNMSH_ENTRY, "-c", source];
3743
4442
  }
3744
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
+
3745
4465
  function leadingSubshell(source) {
3746
4466
  const leading = source.length - source.trimStart().length;
3747
4467
  if (source[leading] !== "(") return null;
3748
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 = [];
3749
4475
  for (let i = leading; i < source.length; i++) {
4476
+ if (ranges.has(i)) { i=ranges.get(i)-1; continue; }
3750
4477
  const ch = source[i];
3751
4478
  if (quote === "'") { if (ch === "'") quote = null; continue; }
3752
4479
  if (quote === '"') {
@@ -3754,6 +4481,14 @@ function leadingSubshell(source) {
3754
4481
  if (ch === '"') quote = null;
3755
4482
  continue;
3756
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";
3757
4492
  if (ch === "\\") { i++; continue; }
3758
4493
  if (ch === "'" || ch === '"') { quote = ch; continue; }
3759
4494
  if (ch === "`") {
@@ -3764,6 +4499,10 @@ function leadingSubshell(source) {
3764
4499
  try { i = substitutionEnd(source, i) - 1; } catch { return null; }
3765
4500
  continue;
3766
4501
  }
4502
+ if (cases.at(-1) === "pattern" && (ch === "(" || ch === ")")) {
4503
+ if (ch === ")") cases[cases.length-1]="body";
4504
+ continue;
4505
+ }
3767
4506
  if (ch === "(") depth++;
3768
4507
  else if (ch === ")" && --depth === 0)
3769
4508
  return { body: source.slice(leading + 1, i), suffix: source.slice(i + 1) };
@@ -3773,7 +4512,9 @@ function leadingSubshell(source) {
3773
4512
 
3774
4513
  function rewriteSubshells(source, state) {
3775
4514
  let output = "", quote = null;
4515
+ const ranges=heredocRanges(source);
3776
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; }
3777
4518
  const ch = source[i];
3778
4519
  if (quote === "'") {
3779
4520
  output += ch;
@@ -3803,8 +4544,9 @@ function rewriteSubshells(source, state) {
3803
4544
  const group = leadingSubshell(source.slice(i));
3804
4545
  if (group) {
3805
4546
  const argv = pipelineCompoundArgv(group.body);
3806
- const serialized = pipelineBuiltinState(state).env[PIPELINE_STATE_ENV];
3807
- output += `BUNMSH_INTERNAL_PIPELINE_STATE=${quoteShellWord(serialized)} ${argv.map(quoteShellWord).join(" ")}`;
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(" ")}`;
3808
4550
  i += group.body.length + 2;
3809
4551
  continue;
3810
4552
  }
@@ -3821,11 +4563,16 @@ function pipelineBuiltinState(state) {
3821
4563
  env: {
3822
4564
  ...state.env,
3823
4565
  [PIPELINE_STATE_ENV]: JSON.stringify({
4566
+ env: Object.fromEntries(Object.entries(state.env).filter(([name]) => name !== PIPELINE_STATE_ENV)),
4567
+ exported: [...state.exported],
3824
4568
  aliases: state.aliases,
3825
4569
  functions: state.functions,
3826
4570
  readonly: [...state.readonly],
3827
4571
  args: state.args,
3828
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)}])),
3829
4576
  }),
3830
4577
  },
3831
4578
  };
@@ -3919,7 +4666,12 @@ async function runCommandArgv(
3919
4666
  )) return result(2, "", "bunmsh: &: only a single external command is supported\n");
3920
4667
 
3921
4668
  let commandState = state;
3922
- 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))
3923
4675
  return runExternal(
3924
4676
  pipelineBuiltinArgv(commandArgv),
3925
4677
  pipelineBuiltinState(commandState),
@@ -3929,6 +4681,8 @@ async function runCommandArgv(
3929
4681
  options.stdoutSink,
3930
4682
  { ...options, pipelineKillSignal: "SIGTERM" },
3931
4683
  );
4684
+ if (typeof input === "number" && ioCommand !== "read" && runsInPipelineSubprocess(commandArgv,commandState))
4685
+ input = Readable.toWeb(createReadStream("",{fd:input,autoClose:false}));
3932
4686
  while (["command", "builtin", "__builtin"].includes(commandArgv[0])) {
3933
4687
  if (requestsBuiltinHelp(commandArgv) && hasBuiltinHelp(commandArgv[0]))
3934
4688
  return builtinHelp(commandArgv[0]);
@@ -4034,16 +4788,6 @@ async function runCommandArgv(
4034
4788
  );
4035
4789
  }
4036
4790
 
4037
- function redirectInput(path) {
4038
- const fd = openSync(path, "r");
4039
- return Readable.toWeb(createReadStream(path, { fd, autoClose: true }));
4040
- }
4041
-
4042
- function redirectOutput(path, append) {
4043
- const fd = openSync(path, append ? "a" : "w");
4044
- return Writable.toWeb(createWriteStream(path, { fd, autoClose: true }));
4045
- }
4046
-
4047
4791
  // Mirrors the double-quote scanning in tokenize(): an unquoted heredoc body
4048
4792
  // only lets backslash escape $, `, \, and a trailing newline before the rest
4049
4793
  // goes through the normal $/`` expansion pipeline.
@@ -4076,6 +4820,7 @@ function redirectHeredoc(content) {
4076
4820
 
4077
4821
  async function runCommand(command, state, options = {}) {
4078
4822
  state.expansionStatus = null;
4823
+ if (command.lineNumber != null) state.lineNumber = command.lineNumber;
4079
4824
  const standaloneTilde =
4080
4825
  command.words.length === 1 &&
4081
4826
  command.words[0].fragments.length === 1 &&
@@ -4104,129 +4849,132 @@ async function runCommand(command, state, options = {}) {
4104
4849
  localEnv[assignment.name] = assignment.value;
4105
4850
  expanded.shift();
4106
4851
  }
4107
- if (expanded.length === 0) {
4852
+ const assignmentOnly = expanded.length === 0;
4853
+ if (assignmentOnly) {
4108
4854
  for (const name of Object.keys(localEnv))
4109
4855
  if (state.readonly.has(name)) return readonlyError(name);
4110
4856
  Object.assign(state.env, localEnv);
4111
- return result(state.expansionStatus ?? 0);
4857
+ if (!command.redirects.length) return result(state.expansionStatus ?? 0);
4858
+ expanded.push(":");
4112
4859
  }
4113
4860
  const alias = aliasEligible ? state.aliases[expanded[0]] : null;
4114
4861
  if (alias) expanded.splice(0, 1, ...alias);
4115
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();
4116
4872
  let input = options.input ?? null;
4117
- let stdoutPath = null;
4118
- let stdoutAppend = false;
4119
- let stderrPath = null;
4120
- let stderrAppend = false;
4121
- let stdoutToStderr = false;
4122
- let stderrToStdout = false;
4123
- for (const redirect of command.redirects) {
4124
- if (redirect.op === "1>&2") { stdoutToStderr = true; continue; }
4125
- if (redirect.op === "2>&1") { stderrToStdout = true; continue; }
4126
- if (/^\d+>&\d+$/.test(redirect.op)) continue;
4127
- if (redirect.op === "<<" || redirect.op === "<<-") {
4128
- const heredoc = redirect.heredoc ?? { body: "", expand: false };
4129
- const content = heredoc.expand
4130
- ? stripExpansionMarkers(
4131
- (await expandText(heredocLiteralPass(heredoc.body), state)).replaceAll("\u0000", ""),
4132
- )
4133
- : heredoc.body;
4134
- if (input instanceof ReadableStream) {
4135
- try { await input.cancel(); } catch {}
4136
- }
4137
- input = redirectHeredoc(content);
4138
- continue;
4139
- }
4140
- if (redirect.op === "<<<") {
4141
- const [word] = await expandWord(redirect.target, state, { single: true, noTilde: true });
4142
- if (input instanceof ReadableStream) {
4143
- 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;
4883
+ }
4884
+ bindings[0] = {content};
4885
+ continue;
4144
4886
  }
4145
- input = redirectHeredoc(`${word}\n`);
4146
- continue;
4147
- }
4148
- const targets = await expandWord(redirect.target, state, { single: true });
4149
- const target = targets[0];
4150
- const path = target.startsWith("/") ? target : `${state.cwd}/${target}`;
4151
- if (redirect.op === "<") {
4152
- try {
4153
- if (input instanceof ReadableStream) {
4154
- try { await input.cancel(); } 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;
4155
4902
  }
4156
- input = redirectInput(path);
4157
- } catch (error) {
4158
- return result(1, "", `bunmsh: ${target}: ${error.message}\n`);
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};
4159
4908
  }
4160
- } else if (redirect.op === ">" || redirect.op === ">>") {
4161
- stdoutPath = path;
4162
- stdoutAppend = redirect.op === ">>";
4163
- } else {
4164
- stderrPath = path;
4165
- stderrAppend = redirect.op === "2>>";
4166
4909
  }
4167
- }
4168
-
4169
-
4170
- let stdoutRedirect = null;
4171
- let stderrRedirect = null;
4172
- try {
4173
- if (stdoutPath !== null) {
4174
- // A command-level redirect replaces the pipeline's stdout connection.
4175
- // Close that unused connection now so the next stage observes EOF.
4176
- if (options.stdoutSink) {
4177
- try { await options.stdoutSink.getWriter().close(); } catch {}
4178
- }
4179
- stdoutRedirect = redirectOutput(stdoutPath, stdoutAppend);
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();
4180
4924
  }
4181
- if (stderrPath !== null)
4182
- stderrRedirect = redirectOutput(stderrPath, stderrAppend);
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;
4183
4951
  } catch (error) {
4184
- return result(1, "", `bunmsh: ${stdoutPath ?? stderrPath}: ${error.message}\n`);
4185
- }
4186
-
4187
- const commandState = options.subshell
4188
- ? {
4189
- ...state,
4190
- env: { ...state.env, ...localEnv },
4191
- directoryHistory: [...state.directoryHistory],
4192
- tabs: [...state.tabs],
4193
- 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;
4194
4970
  }
4195
- : state;
4196
- if (!options.subshell) {
4197
- for (const name of Object.keys(localEnv))
4198
- if (commandState.readonly.has(name)) return readonlyError(name);
4199
- Object.assign(commandState.env, localEnv);
4200
- }
4201
-
4202
- const captureStdout = (options.captureStdout || stdoutToStderr) && stdoutRedirect === null;
4203
- const captureStderr = (options.captureStderr || stderrToStdout) && stderrRedirect === null;
4204
- const execution = await runCommandArgv(
4205
- expanded,
4206
- commandState,
4207
- input,
4208
- captureStdout,
4209
- captureStderr,
4210
- {
4211
- pipelineStage: Boolean(options.pipelineStage || stdoutRedirect || stderrRedirect),
4212
- stdoutSink: stdoutRedirect ?? options.stdoutSink ?? null,
4213
- stderrSink: stderrRedirect,
4214
- onSpawn: options.onSpawn,
4215
- onExit: options.onExit,
4216
- background: Boolean(options.background),
4217
- },
4218
- );
4219
-
4220
- if (stdoutToStderr && execution.stdout.byteLength) {
4221
- execution.stderr = concatBytes([execution.stderr, execution.stdout]);
4222
- execution.stdout = bytes();
4223
- }
4224
- if (stderrToStdout && execution.stderr.byteLength) {
4225
- execution.stdout = concatBytes([execution.stdout, execution.stderr]);
4226
- 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
+ }
4227
4976
  }
4228
4977
 
4229
- return execution;
4230
4978
  }
4231
4979
 
4232
4980
  async function runPipeline(pipeline, state, options = {}) {
@@ -4247,9 +4995,9 @@ async function runPipeline(pipeline, state, options = {}) {
4247
4995
  );
4248
4996
  const linksConnected = links.map((_, i) => {
4249
4997
  const stdoutRedirected = pipeline[i].redirects.some((redirect) =>
4250
- redirect.op === ">" || redirect.op === ">>");
4998
+ /^(?:1)?>(?:>|&.*)?$/.test(redirect.op));
4251
4999
  const stdinRedirected = pipeline[i + 1].redirects.some((redirect) =>
4252
- ["<", "<<", "<<-", "<<<"].includes(redirect.op));
5000
+ /^(?:0)?<(?:<<?-?|&.*|>)?$/.test(redirect.op));
4253
5001
  return !stdoutRedirected && !stdinRedirected;
4254
5002
  });
4255
5003
  const processes = Array(pipeline.length).fill(null);
@@ -4311,6 +5059,18 @@ async function runPipeline(pipeline, state, options = {}) {
4311
5059
  }
4312
5060
 
4313
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) {
4314
5074
  source = normalizeSource(source);
4315
5075
  if (source === "?") {
4316
5076
  const execution = result(0, `${state.lastStatus}\n`);
@@ -4321,44 +5081,12 @@ export async function execute(source, state = createState(), io = {}) {
4321
5081
  const rawSource = source.trimStart();
4322
5082
  const group = leadingSubshell(source);
4323
5083
  if (group) {
4324
- let suffix = group.suffix;
4325
- let discardStdout = false, discardStderr = false;
4326
- while (true) {
4327
- const redirect = /^\s*(?:(\d+)?(>>?|<)\s*([^\s;&|]+)|(\d+>&\d+))/.exec(suffix);
4328
- if (!redirect) break;
4329
- if (redirect[2] === ">" || redirect[2] === ">>") {
4330
- if (redirect[1] === "2") discardStderr ||= redirect[3] === "/dev/null";
4331
- else discardStdout ||= redirect[3] === "/dev/null";
4332
- }
4333
- suffix = suffix.slice(redirect[0].length);
4334
- }
4335
- const child = await runExternal(
4336
- pipelineCompoundArgv(group.body),
4337
- pipelineBuiltinState(state),
4338
- io.input ?? null,
4339
- true,
4340
- true,
4341
- );
4342
- let execution = result(child.status,
4343
- discardStdout ? "" : child.stdout,
4344
- discardStderr ? "" : child.stderr);
4345
- const connector = /^\s*(\|\||&&|;)\s*([\s\S]*)$/.exec(suffix);
4346
- if (connector) {
4347
- const shouldRun = connector[1] === ";" ||
4348
- (connector[1] === "&&" ? execution.status === 0 : execution.status !== 0);
4349
- if (shouldRun) {
4350
- const tail = await execute(connector[2], state, io);
4351
- execution = result(tail.status,
4352
- concatBytes([execution.stdout, tail.stdout]),
4353
- concatBytes([execution.stderr, tail.stderr]));
4354
- }
4355
- }
4356
- state.lastStatus = execution.status;
4357
- if (!io.capture) {
4358
- if (execution.stdout.byteLength) await writeStream(process.stdout, execution.stdout);
4359
- if (execution.stderr.byteLength) await writeStream(process.stderr, execution.stderr);
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;
4360
5089
  }
4361
- return execution;
4362
5090
  }
4363
5091
  if (isJavaScriptMode(rawSource)) {
4364
5092
  let execution;
@@ -4416,7 +5144,7 @@ export async function execute(source, state = createState(), io = {}) {
4416
5144
  if (execution.stdout.byteLength) stdout.push(execution.stdout);
4417
5145
  if (execution.stderr.byteLength) stderr.push(execution.stderr);
4418
5146
  }
4419
- if (state.exitRequested) break;
5147
+ if (state.exitRequested || state.flow) break;
4420
5148
  }
4421
5149
  return io.capture
4422
5150
  ? result(execution.status, concatBytes(stdout), concatBytes(stderr))
@@ -4428,7 +5156,7 @@ export async function execute(source, state = createState(), io = {}) {
4428
5156
  if (compoundSyntax) {
4429
5157
  let execution;
4430
5158
  try {
4431
- execution = await executeNodeList(parseCompoundScript(source), state, {
5159
+ execution = await executeNodeList(parseCompoundScript(source, io.firstLine ?? 1, io.sourceLines), state, {
4432
5160
  capture: Boolean(io.capture),
4433
5161
  input: io.input ?? null,
4434
5162
  });
@@ -4454,6 +5182,7 @@ export async function execute(source, state = createState(), io = {}) {
4454
5182
  let jobs;
4455
5183
  try {
4456
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;
4457
5186
  } catch (error) {
4458
5187
  const message = error instanceof ShellSyntaxError ? error.message : String(error);
4459
5188
  const output = `bunmsh: syntax error: ${message}\n`;
@@ -4466,15 +5195,24 @@ export async function execute(source, state = createState(), io = {}) {
4466
5195
  let execution = result(state.lastStatus);
4467
5196
  const capturedStdout = [];
4468
5197
  const capturedStderr = [];
4469
- 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;
4470
5202
  if (job.connector === "&&" && execution.status !== 0) continue;
4471
5203
  if (job.connector === "||" && execution.status === 0) continue;
4472
- 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, {
4473
5208
  capture: io.capture,
4474
5209
  background: job.background,
4475
5210
  input: io.input ?? null,
4476
5211
  stdoutSink: io.stdoutSink ?? null,
4477
- });
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; }
4478
5216
  if (job.negate) execution.status = execution.status === 0 ? 1 : 0;
4479
5217
  state.lastStatus = execution.status;
4480
5218
  if (execution.stdout.byteLength) {
@@ -4485,7 +5223,7 @@ export async function execute(source, state = createState(), io = {}) {
4485
5223
  if (io.capture) capturedStderr.push(execution.stderr);
4486
5224
  else await writeStream(process.stderr, execution.stderr);
4487
5225
  }
4488
- if (state.exitRequested) break;
5226
+ if (state.exitRequested || state.flow) break;
4489
5227
  }
4490
5228
  return io.capture
4491
5229
  ? result(execution.status, concatBytes(capturedStdout), concatBytes(capturedStderr))
@@ -4515,11 +5253,13 @@ export function pipelineChildState() {
4515
5253
  const serialized = process.env[PIPELINE_STATE_ENV];
4516
5254
  if (!serialized) return createState();
4517
5255
  try {
4518
- const inherited = JSON.parse(serialized);
4519
- 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 ?? {}) };
4520
5258
  delete env[PIPELINE_STATE_ENV];
4521
5259
  return createState({
4522
5260
  env,
5261
+ inheritEnvironment: false,
5262
+ exported: inherited.exported,
4523
5263
  cwd: process.cwd(),
4524
5264
  aliases: inherited.aliases,
4525
5265
  functions: inherited.functions,
@@ -4527,6 +5267,9 @@ export function pipelineChildState() {
4527
5267
  args: inherited.args,
4528
5268
  pathSearch: inherited.pathSearch,
4529
5269
  pipelineChild: true,
5270
+ fdBindings: inherited.fdBindings,
5271
+ shellOptions:inherited.shellOptions,
5272
+ ignoreErrexit:inherited.ignoreErrexit,
4530
5273
  });
4531
5274
  } catch {
4532
5275
  return createState();