fauxnix-cli 0.9.3 → 0.12.0

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.
package/dist/parser.js CHANGED
@@ -1,7 +1,14 @@
1
1
  import { FauxnixParseError, isUnquotedLiteral, wordToString, } from './ast.js';
2
2
  const OPERATORS = [
3
- '&&', '||', '>>', '<<', '2>&1', '1>&2', '2>', '&>>', '&>', '>', '<', '|', ';',
3
+ '&&', '||', '2>>', '>>', '<<', '2>&1', '1>&2', '2>', '&>>', '&>', '>', '<', '|', ';;', ';', '&',
4
4
  ];
5
+ const BACKGROUND_MSG = 'fauxnix: background & is not supported yet. Run the command in the foreground instead.';
6
+ const WHILE_UNTIL_MSG = 'fauxnix: while/until loops are not supported yet. Use `for x in ...; do ...; done` over a known list instead.';
7
+ const CASE_MSG = 'fauxnix: case is not supported yet. Use if/elif/else instead.';
8
+ const FUNCTION_MSG = 'fauxnix: functions are not supported yet. Inline the body or repeat the command instead.';
9
+ const IF_IN_PIPELINE_MSG = 'fauxnix: if in a pipeline is not supported. Run the if as its own list segment instead of piping into it.';
10
+ const FOR_IN_PIPELINE_MSG = 'fauxnix: for in a pipeline is not supported. Run the for as its own list segment instead of piping into it.';
11
+ const CSTYLE_FOR_MSG = 'fauxnix: C-style for ((...)) is not supported yet. Use `for x in ...; do ...; done` instead.';
5
12
  export function tokenize(input) {
6
13
  const tokens = [];
7
14
  let i = 0;
@@ -54,6 +61,12 @@ export function tokenize(input) {
54
61
  // try operators (longest first — list above is ordered)
55
62
  let matched;
56
63
  for (const op of OPERATORS) {
64
+ // A digit-leading fd operator is only a direct token at a word boundary.
65
+ // Otherwise consume the digit normally: `file2>>out` is word `file2`
66
+ // plus stdout `>>out`, while `12>>out` stays one unsupported fd token
67
+ // and fails loud instead of being stolen by `2>>`.
68
+ if (cur.length > 0 && /^\d/.test(op))
69
+ continue;
57
70
  if (input.startsWith(op, i)) {
58
71
  matched = op;
59
72
  break;
@@ -177,8 +190,10 @@ export function tokenize(input) {
177
190
  i += 2;
178
191
  continue;
179
192
  }
180
- // track leading digits (potential fd number for redirects)
181
- if (/[0-9]/.test(ch) && cur.length === 0 && fdDigits.length < 2) {
193
+ // Track an all-digit word as a potential fd number. Keep consuming every
194
+ // leading digit so unsupported multi-digit fds stay intact and fail loud
195
+ // instead of becoming argv plus a different redirect.
196
+ if (/[0-9]/.test(ch) && (cur.length === 0 || fdDigits.length === cur.length)) {
182
197
  beginWordPart();
183
198
  fdDigits += ch;
184
199
  cur.push({ kind: 'Text', text: ch });
@@ -242,6 +257,64 @@ function readArithParts(input) {
242
257
  parts.push({ kind: 'Text', text: buf });
243
258
  return parts;
244
259
  }
260
+ /** Integer or `$name` operand of `${name:offset:length}`. */
261
+ function readSliceNum(s, i) {
262
+ while (i < s.length && (s[i] === ' ' || s[i] === '\t'))
263
+ i++;
264
+ if (i >= s.length)
265
+ return null;
266
+ if (s[i] === '$') {
267
+ const j = i + 1;
268
+ if (j < s.length && isNameStart(s[j])) {
269
+ let k = j + 1;
270
+ while (k < s.length && isNameChar(s[k]))
271
+ k++;
272
+ return { val: s.slice(i, k), next: k };
273
+ }
274
+ if (j < s.length && (/[0-9]/.test(s[j]) || s[j] === '?' || s[j] === '$')) {
275
+ return { val: s.slice(i, j + 1), next: j + 1 };
276
+ }
277
+ return null;
278
+ }
279
+ let sign = '';
280
+ if (s[i] === '-' || s[i] === '+') {
281
+ sign = s[i];
282
+ i++;
283
+ }
284
+ if (i >= s.length || !/[0-9]/.test(s[i]))
285
+ return null;
286
+ let k = i;
287
+ while (k < s.length && /[0-9]/.test(s[k]))
288
+ k++;
289
+ return { val: sign + s.slice(i, k), next: k };
290
+ }
291
+ /** Parse `offset` / `offset:length` after `${name:`. Null if not a slice. */
292
+ function parseSliceSpec(after) {
293
+ const off = readSliceNum(after, 0);
294
+ if (!off)
295
+ return null;
296
+ let i = off.next;
297
+ while (i < after.length && (after[i] === ' ' || after[i] === '\t'))
298
+ i++;
299
+ if (i >= after.length)
300
+ return { offset: off.val };
301
+ if (after[i] !== ':')
302
+ return null;
303
+ i++;
304
+ while (i < after.length && (after[i] === ' ' || after[i] === '\t'))
305
+ i++;
306
+ if (i >= after.length)
307
+ return { offset: off.val, length: '0' };
308
+ const len = readSliceNum(after, i);
309
+ if (!len)
310
+ return null;
311
+ i = len.next;
312
+ while (i < after.length && (after[i] === ' ' || after[i] === '\t'))
313
+ i++;
314
+ if (i !== after.length)
315
+ return null;
316
+ return { offset: off.val, length: len.val };
317
+ }
245
318
  /** Parse $VAR, ${VAR}, $(cmd substitution), $((arith)). Returns null when not a valid dollar construct. */
246
319
  function readDollar(input, i) {
247
320
  const n = input.length;
@@ -255,31 +328,76 @@ function readDollar(input, i) {
255
328
  const end = input.indexOf('}', j);
256
329
  if (end === -1)
257
330
  throw new FauxnixParseError('fauxnix: unclosed ${');
258
- const name = input.slice(j + 1, end);
259
- const sub = name.match(/^([A-Za-z_][A-Za-z0-9_]*)\[([0-9]+|@|\*)\]$/);
260
- if (sub) {
261
- return { part: { kind: 'Var', name: sub[1], index: sub[2] }, next: end + 1 };
262
- }
263
- const pm = name.match(/^([A-Za-z_][A-Za-z0-9_]*)(:?[-+?])(.*)$/);
264
- if (pm) {
265
- const op = pm[2];
266
- return {
267
- part: { kind: 'Var', name: pm[1], param: { op, word: pm[3] } },
268
- next: end + 1,
269
- };
270
- }
271
- const hash = name.match(/^#([A-Za-z_][A-Za-z0-9_]*)(\[([0-9]+|@|\*)\])?$/);
331
+ const inner = input.slice(j + 1, end);
332
+ const nextPos = end + 1;
333
+ const hash = inner.match(/^#([A-Za-z_][A-Za-z0-9_]*)(\[([0-9]+|@|\*)\])?$/);
272
334
  if (hash) {
273
335
  return {
274
336
  part: { kind: 'Var', name: hash[1], index: hash[3], length: true },
275
- next: end + 1,
337
+ next: nextPos,
276
338
  };
277
339
  }
278
- if (!name || !isNameStart(name[0]) || !name.split('').every(isNameChar)) {
340
+ // ${1} ${#} ${@} ${*} — positional / special params (not ${#name} length)
341
+ if (inner === '#' || inner === '@' || inner === '*' || /^[0-9]+$/.test(inner)) {
342
+ return { part: { kind: 'Var', name: inner }, next: nextPos };
343
+ }
344
+ const sub = inner.match(/^([A-Za-z_][A-Za-z0-9_]*)\[([0-9]+|@|\*)\]$/);
345
+ if (sub) {
346
+ return { part: { kind: 'Var', name: sub[1], index: sub[2] }, next: nextPos };
347
+ }
348
+ if (/^[A-Za-z_][A-Za-z0-9_]*\[([0-9]+|@|\*)\]/.test(inner)) {
349
+ throw new FauxnixParseError('fauxnix: ${name[@]:offset:length} subarray slice is not supported; use ${name:offset:length} on a scalar or ${name[i]} per element');
350
+ }
351
+ const ident = inner.match(/^([A-Za-z_][A-Za-z0-9_]*)/);
352
+ if (ident) {
353
+ const nm = ident[1];
354
+ const rest = inner.slice(nm.length);
355
+ if (rest.startsWith('/#') || rest.startsWith('/%')) {
356
+ throw new FauxnixParseError('fauxnix: ${name/#pat/str} and ${name/%pat/str} are not supported; use ${name//pat/str} instead');
357
+ }
358
+ if (rest.startsWith('/')) {
359
+ const global = rest.startsWith('//');
360
+ const body = rest.slice(global ? 2 : 1);
361
+ const slash = body.indexOf('/');
362
+ const pat = slash === -1 ? body : body.slice(0, slash);
363
+ const repl = slash === -1 ? '' : body.slice(slash + 1);
364
+ return {
365
+ part: { kind: 'Var', name: nm, replace: { global, pat, repl } },
366
+ next: nextPos,
367
+ };
368
+ }
369
+ if (rest.startsWith(':')) {
370
+ const after = rest.slice(1);
371
+ let t = 0;
372
+ while (t < after.length && (after[t] === ' ' || after[t] === '\t'))
373
+ t++;
374
+ const lead = t < after.length ? after[t] : '';
375
+ const hadSpace = t > 0;
376
+ const paramLead = lead === '+' || lead === '?' || lead === '=' || (lead === '-' && !hadSpace);
377
+ if (!paramLead && lead !== '') {
378
+ const sl = parseSliceSpec(after);
379
+ if (sl) {
380
+ return { part: { kind: 'Var', name: nm, slice: sl }, next: nextPos };
381
+ }
382
+ }
383
+ }
384
+ const pm = inner.match(/^([A-Za-z_][A-Za-z0-9_]*)(:?[-+?])(.*)$/);
385
+ if (pm) {
386
+ const op = pm[2];
387
+ return {
388
+ part: { kind: 'Var', name: pm[1], param: { op, word: pm[3] } },
389
+ next: nextPos,
390
+ };
391
+ }
392
+ if (rest === '') {
393
+ return { part: { kind: 'Var', name: nm }, next: nextPos };
394
+ }
395
+ }
396
+ if (!inner || !isNameStart(inner[0]) || !inner.split('').every(isNameChar)) {
279
397
  // ${VAR:=default} etc. still raw text
280
- return { part: { kind: 'Text', text: input.slice(i, end + 1) }, next: end + 1 };
398
+ return { part: { kind: 'Text', text: input.slice(i, end + 1) }, next: nextPos };
281
399
  }
282
- return { part: { kind: 'Var', name }, next: end + 1 };
400
+ return { part: { kind: 'Var', name: inner }, next: nextPos };
283
401
  }
284
402
  // $((...)) arithmetic expansion — distinct from `$( (cmd) )` (space after
285
403
  // the first paren is command substitution of a grouped body).
@@ -350,8 +468,8 @@ function readDollar(input, i) {
350
468
  len++;
351
469
  return { part: { kind: 'Var', name: input.slice(j, j + len) }, next: j + len };
352
470
  }
353
- // special: $? $$ $0-$9 — kept as symbolic Var; the translator maps them
354
- if ('?$_'.includes(input[j]) || /[0-9]/.test(input[j])) {
471
+ // special: $? $$ $0-$9 $# $@ $* — kept as symbolic Var; the translator maps them
472
+ if ('?$_#@*'.includes(input[j]) || /[0-9]/.test(input[j])) {
355
473
  return { part: { kind: 'Var', name: input[j] }, next: j + 1 };
356
474
  }
357
475
  return null;
@@ -578,6 +696,76 @@ function pendingPatternOp(args) {
578
696
  }
579
697
  return null;
580
698
  }
699
+ /** Unquoted `(` / `)` net depth in a word (quoted / escaped parens ignored). */
700
+ function unquotedParenDelta(w) {
701
+ let d = 0;
702
+ for (const p of w) {
703
+ if (p.kind !== 'Text' || p.escaped)
704
+ continue;
705
+ for (const c of p.text) {
706
+ if (c === '(')
707
+ d++;
708
+ else if (c === ')')
709
+ d--;
710
+ }
711
+ }
712
+ return d;
713
+ }
714
+ function startsWithUnquotedOpenParen(w) {
715
+ if (w.length === 0)
716
+ return false;
717
+ const p = w[0];
718
+ return p.kind === 'Text' && !p.escaped && p.text.startsWith('(');
719
+ }
720
+ /** `NAME+=` (scalar or array append) — out of scope for C-2. */
721
+ function isAppendAssignment(w) {
722
+ let s = '';
723
+ for (const p of w) {
724
+ if (p.kind === 'Text' && !p.escaped)
725
+ s += p.text;
726
+ else
727
+ break;
728
+ if (/^[A-Za-z_][A-Za-z0-9_]*\+=/.test(s))
729
+ return true;
730
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(s))
731
+ return false;
732
+ }
733
+ return /^[A-Za-z_][A-Za-z0-9_]*\+=/.test(s);
734
+ }
735
+ function cloneWord(w) {
736
+ return w.map((p) => {
737
+ if (p.kind === 'Text' || p.kind === 'SingleQuoted')
738
+ return { ...p };
739
+ if (p.kind === 'DoubleQuoted')
740
+ return { kind: 'DoubleQuoted', parts: p.parts.slice() };
741
+ return p;
742
+ });
743
+ }
744
+ /** Strip the opening `(` and closing `)` that wrap an array assignment value. */
745
+ function stripArrayParens(words) {
746
+ if (words.length === 0)
747
+ return [];
748
+ const ws = words.map(cloneWord);
749
+ const first = ws[0];
750
+ if (first.length > 0 && first[0].kind === 'Text' && first[0].text.startsWith('(')) {
751
+ first[0] = { kind: 'Text', text: first[0].text.slice(1), escaped: first[0].escaped };
752
+ if (first[0].text === '')
753
+ first.shift();
754
+ }
755
+ const last = ws[ws.length - 1];
756
+ for (let i = last.length - 1; i >= 0; i--) {
757
+ const p = last[i];
758
+ if (p.kind === 'Text' && !p.escaped && p.text.endsWith(')')) {
759
+ const trimmed = p.text.slice(0, -1);
760
+ if (trimmed === '')
761
+ last.splice(i, 1);
762
+ else
763
+ last[i] = { kind: 'Text', text: trimmed, escaped: p.escaped };
764
+ break;
765
+ }
766
+ }
767
+ return ws.filter((w) => w.length > 0);
768
+ }
581
769
  /* ------------------------------------------------------------------ */
582
770
  /* Parser */
583
771
  /* ------------------------------------------------------------------ */
@@ -587,17 +775,44 @@ export function parseCommand(input) {
587
775
  const peek = () => tokens[pos];
588
776
  const next = () => tokens[pos++];
589
777
  const isListSep = (o) => o === ';' || o === '\n';
778
+ const isAmpWord = (t) => (!!t && t.type === 'OP' && t.op === '&') ||
779
+ (!!t && t.type === 'WORD' && !!t.parts && isUnquotedLiteral(t.parts, '&'));
780
+ const isCaseFallthrough = () => {
781
+ const t = peek();
782
+ if (t.type !== 'OP')
783
+ return false;
784
+ return (t.op === ';' || t.op === ';;') && isAmpWord(tokens[pos + 1]);
785
+ };
786
+ const throwUnexpectedDsemi = () => {
787
+ throw new FauxnixParseError("fauxnix: syntax error near unexpected token `;;'");
788
+ };
789
+ const throwCaseFallthrough = () => {
790
+ throw new FauxnixParseError('fauxnix: case fallthrough (;& / ;;&) is not supported; use ;; (no fallthrough) or duplicate the body');
791
+ };
590
792
  /** Consume `;` / newline / `&&` / `||`. Trailing `&&`/`||` and `;;` fail loud (bash). */
591
793
  const consumeListOp = (stops) => {
592
794
  const t = peek();
593
795
  if (t.type !== 'OP')
594
796
  return null;
797
+ if (t.op === '&') {
798
+ // inside a case arm, `;&` / `;;&` is fallthrough — report that, not background
799
+ if (stops && stops.has(';;'))
800
+ throwCaseFallthrough();
801
+ throw new FauxnixParseError(BACKGROUND_MSG);
802
+ }
803
+ if (t.op === ';;') {
804
+ if (stops && stops.has(';;'))
805
+ return null;
806
+ throwUnexpectedDsemi();
807
+ }
595
808
  if (!(t.op === '&&' || t.op === '||' || isListSep(t.op)))
596
809
  return null;
597
810
  if (t.op === ';') {
811
+ if (stops && stops.has(';;') && isAmpWord(tokens[pos + 1]))
812
+ return null;
598
813
  next();
599
- if (peek().type === 'OP' && peek().op === ';') {
600
- throw new FauxnixParseError("fauxnix: syntax error near unexpected token `;;'");
814
+ if (peek().type === 'OP' && (peek().op === ';' || peek().op === ';;')) {
815
+ throwUnexpectedDsemi();
601
816
  }
602
817
  return ';';
603
818
  }
@@ -627,16 +842,20 @@ export function parseCommand(input) {
627
842
  const segments = [];
628
843
  let op = ';';
629
844
  while (peek().type === 'OP' && isListSep(peek().op)) {
630
- if (peek().op === ';' && tokens[pos + 1]?.type === 'OP' && tokens[pos + 1]?.op === ';') {
631
- throw new FauxnixParseError("fauxnix: syntax error near unexpected token `;;'");
845
+ if (peek().op === ';' && tokens[pos + 1]?.type === 'OP' && (tokens[pos + 1]?.op === ';' || tokens[pos + 1]?.op === ';;')) {
846
+ throwUnexpectedDsemi();
632
847
  }
633
848
  next();
634
849
  }
635
850
  while (peek().type !== 'EOF') {
851
+ if (peek().type === 'OP' && peek().op === ';;')
852
+ throwUnexpectedDsemi();
636
853
  const pipeline = parsePipeline();
637
854
  segments.push({ pipeline, op });
638
855
  const nextOp = consumeListOp();
639
856
  if (nextOp === null) {
857
+ if (peek().type === 'OP' && peek().op === ';;')
858
+ throwUnexpectedDsemi();
640
859
  if (peek().type === 'EOF')
641
860
  break;
642
861
  throw new FauxnixParseError('fauxnix: unexpected token after pipeline');
@@ -653,18 +872,43 @@ export function parseCommand(input) {
653
872
  const kw = peekKw();
654
873
  if (kw === 'if') {
655
874
  if (commands.length > 0) {
656
- throw new FauxnixParseError('fauxnix: if in a pipeline is not supported');
875
+ throw new FauxnixParseError(IF_IN_PIPELINE_MSG);
657
876
  }
658
877
  commands.push(parseIf());
659
878
  }
660
879
  else if (kw === 'for') {
661
880
  if (commands.length > 0) {
662
- throw new FauxnixParseError('fauxnix: for in a pipeline is not supported');
881
+ throw new FauxnixParseError(FOR_IN_PIPELINE_MSG);
663
882
  }
664
883
  commands.push(parseFor());
665
884
  }
885
+ else if (kw === 'while') {
886
+ if (commands.length > 0) {
887
+ throw new FauxnixParseError('fauxnix: while in a pipeline is not supported');
888
+ }
889
+ commands.push(parseWhile());
890
+ }
891
+ else if (kw === 'until') {
892
+ if (commands.length > 0) {
893
+ throw new FauxnixParseError('fauxnix: until in a pipeline is not supported');
894
+ }
895
+ commands.push(parseUntil());
896
+ }
897
+ else if (kw === 'case') {
898
+ if (commands.length > 0) {
899
+ throw new FauxnixParseError('fauxnix: case in a pipeline is not supported');
900
+ }
901
+ commands.push(parseCase());
902
+ }
903
+ else if (kw === 'function') {
904
+ throw new FauxnixParseError(FUNCTION_MSG);
905
+ }
666
906
  else {
667
- commands.push(parseSimple());
907
+ const cmd = parseSimple();
908
+ if (cmd.kind === 'SimpleCommand' && cmd.name && isFunctionDef(cmd)) {
909
+ throw new FauxnixParseError(FUNCTION_MSG);
910
+ }
911
+ commands.push(cmd);
668
912
  }
669
913
  const t = peek();
670
914
  if (t.type === 'OP' && t.op === '|') {
@@ -691,7 +935,11 @@ export function parseCommand(input) {
691
935
  s === 'in' ||
692
936
  s === 'do' ||
693
937
  s === 'done' ||
694
- s === 'while') {
938
+ s === 'while' ||
939
+ s === 'until' ||
940
+ s === 'case' ||
941
+ s === 'esac' ||
942
+ s === 'function') {
695
943
  return s;
696
944
  }
697
945
  return null;
@@ -707,23 +955,34 @@ export function parseCommand(input) {
707
955
  const segments = [];
708
956
  let op = ';';
709
957
  while (peek().type === 'OP' && isListSep(peek().op)) {
710
- if (peek().op === ';' && tokens[pos + 1]?.type === 'OP' && tokens[pos + 1]?.op === ';') {
711
- throw new FauxnixParseError("fauxnix: syntax error near unexpected token `;;'");
958
+ if (peek().op === ';' && tokens[pos + 1]?.type === 'OP' && (tokens[pos + 1]?.op === ';' || tokens[pos + 1]?.op === ';;')) {
959
+ throwUnexpectedDsemi();
712
960
  }
713
961
  next();
714
962
  }
715
963
  while (peek().type !== 'EOF') {
964
+ if (stop.has(';;') && isCaseFallthrough())
965
+ break;
716
966
  const kw = peekKw();
717
967
  if (kw && stop.has(kw))
718
968
  break;
969
+ const stopOp = peek();
970
+ if (stopOp.type === 'OP' && stopOp.op && stop.has(stopOp.op))
971
+ break;
972
+ if (stopOp.type === 'OP' && stopOp.op === ';;')
973
+ throwUnexpectedDsemi();
719
974
  const pipeline = parsePipeline();
720
975
  segments.push({ pipeline, op });
976
+ if (stop.has(';;') && isCaseFallthrough())
977
+ break;
721
978
  const nextOp = consumeListOp(stop);
722
979
  if (nextOp === null)
723
980
  break;
724
981
  op = nextOp;
725
982
  }
726
983
  if (segments.length === 0) {
984
+ if (stop.has(';;'))
985
+ return { kind: 'CommandList', segments: [] };
727
986
  throw new FauxnixParseError('fauxnix: empty command');
728
987
  }
729
988
  return { kind: 'CommandList', segments };
@@ -761,6 +1020,9 @@ export function parseCommand(input) {
761
1020
  throw new FauxnixParseError('fauxnix: `for` expected a name');
762
1021
  }
763
1022
  const name = wordToString(nt.parts);
1023
+ if (name.startsWith('((')) {
1024
+ throw new FauxnixParseError(CSTYLE_FOR_MSG);
1025
+ }
764
1026
  if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || !isUnquotedLiteral(nt.parts, name)) {
765
1027
  throw new FauxnixParseError('fauxnix: `for` name must be an identifier');
766
1028
  }
@@ -784,6 +1046,92 @@ export function parseCommand(input) {
784
1046
  expectKw('done');
785
1047
  return { kind: 'For', name, words, body, redirects: [] };
786
1048
  };
1049
+ const parseWhile = () => parseWhileLoop(false);
1050
+ const parseUntil = () => parseWhileLoop(true);
1051
+ const parseWhileLoop = (until) => {
1052
+ expectKw(until ? 'until' : 'while');
1053
+ const test = parseListUntil(['do']);
1054
+ expectKw('do');
1055
+ const body = parseListUntil(['done']);
1056
+ expectKw('done');
1057
+ return { kind: 'While', until, test, body, redirects: [] };
1058
+ };
1059
+ const skipCaseSeps = () => {
1060
+ while (peek().type === 'OP' && isListSep(peek().op)) {
1061
+ if (peek().op === ';' && isAmpWord(tokens[pos + 1]))
1062
+ break;
1063
+ next();
1064
+ }
1065
+ };
1066
+ /** Strip a trailing unquoted `)` that closes a case pattern list. */
1067
+ const stripTrailingUnquotedParen = (w) => {
1068
+ if (w.length === 0)
1069
+ return null;
1070
+ const last = w[w.length - 1];
1071
+ if (last.kind !== 'Text' || last.escaped || !last.text.endsWith(')'))
1072
+ return null;
1073
+ const rest = last.text.slice(0, -1);
1074
+ if (rest.length === 0)
1075
+ return w.slice(0, -1);
1076
+ return [...w.slice(0, -1), { kind: 'Text', text: rest, escaped: last.escaped }];
1077
+ };
1078
+ const parseCasePatterns = () => {
1079
+ const patterns = [];
1080
+ for (;;) {
1081
+ while (peek().type === 'OP' && (peek().op === '|' || peek().op === '\n'))
1082
+ next();
1083
+ if (peekKw() === 'esac') {
1084
+ throw new FauxnixParseError("fauxnix: expected `)'");
1085
+ }
1086
+ const t = peek();
1087
+ if (t.type !== 'WORD' || !t.parts) {
1088
+ throw new FauxnixParseError('fauxnix: `case` expected a pattern');
1089
+ }
1090
+ const stripped = stripTrailingUnquotedParen(t.parts);
1091
+ if (stripped !== null) {
1092
+ next();
1093
+ if (stripped.length > 0)
1094
+ patterns.push(stripped);
1095
+ if (patterns.length === 0) {
1096
+ throw new FauxnixParseError('fauxnix: `case` expected a pattern');
1097
+ }
1098
+ return patterns;
1099
+ }
1100
+ patterns.push(t.parts);
1101
+ next();
1102
+ }
1103
+ };
1104
+ const parseCase = () => {
1105
+ expectKw('case');
1106
+ skipCaseSeps();
1107
+ const wt = peek();
1108
+ if (wt.type !== 'WORD' || !wt.parts) {
1109
+ throw new FauxnixParseError('fauxnix: `case` expected a word');
1110
+ }
1111
+ const word = wt.parts;
1112
+ next();
1113
+ skipCaseSeps();
1114
+ expectKw('in');
1115
+ const arms = [];
1116
+ skipCaseSeps();
1117
+ while (peek().type !== 'EOF' && peekKw() !== 'esac') {
1118
+ if (isCaseFallthrough())
1119
+ throwCaseFallthrough();
1120
+ const patterns = parseCasePatterns();
1121
+ const body = parseListUntil(['esac', ';;']);
1122
+ if (isCaseFallthrough())
1123
+ throwCaseFallthrough();
1124
+ if (peek().type === 'OP' && peek().op === ';;') {
1125
+ next();
1126
+ if (isAmpWord(peek()))
1127
+ throwCaseFallthrough();
1128
+ }
1129
+ arms.push({ patterns, body });
1130
+ skipCaseSeps();
1131
+ }
1132
+ expectKw('esac');
1133
+ return { kind: 'Case', word, arms, redirects: [] };
1134
+ };
787
1135
  const parseSimple = () => {
788
1136
  const assignments = [];
789
1137
  let redirects = [];
@@ -804,6 +1152,7 @@ export function parseCommand(input) {
804
1152
  (t.op === '&&' ||
805
1153
  t.op === '||' ||
806
1154
  t.op === '|' ||
1155
+ t.op === '&' ||
807
1156
  t.op === '>' ||
808
1157
  t.op === '<' ||
809
1158
  t.op === '>>' ||
@@ -817,7 +1166,8 @@ export function parseCommand(input) {
817
1166
  // Glue `|` onto the surrounding words so `=~ ^a|z$`,
818
1167
  // `=~ (a | b)c`, and `== @(x | y)` stay one operand. A
819
1168
  // spaced `|` outside an open regex / extglob group is a
820
- // syntax error (bash).
1169
+ // syntax error (bash). Tight `&` inside an open `=~ (…)`
1170
+ // group stays in the regex, not a background job.
821
1171
  const last = args.length ? args[args.length - 1] : null;
822
1172
  const lastIsEqTilde = last !== null && isUnquotedLiteral(last, '=~');
823
1173
  const prevIsEqTilde = args.length >= 2 && isUnquotedLiteral(args[args.length - 2], '=~');
@@ -828,7 +1178,18 @@ export function parseCommand(input) {
828
1178
  pendingPatternOp(args) === '==' &&
829
1179
  unmatchedExtglob(last);
830
1180
  const inRe = lastIsEqTilde || prevIsEqTilde || openRe;
831
- if (t.op === '|' || ((inRe || openExt) && t.tightLeft && t.op === '||')) {
1181
+ if (t.op === '&') {
1182
+ if (!(openRe && last && t.tightLeft)) {
1183
+ throw new FauxnixParseError("fauxnix: [[: syntax error near unexpected token `&'");
1184
+ }
1185
+ args[args.length - 1] = [...last, { kind: 'Text', text: '&' }];
1186
+ const n = peek();
1187
+ if (n.type === 'WORD' && n.tightLeft && n.parts) {
1188
+ next();
1189
+ args[args.length - 1] = [...args[args.length - 1], ...n.parts];
1190
+ }
1191
+ }
1192
+ else if (t.op === '|' || ((inRe || openExt) && t.tightLeft && t.op === '||')) {
832
1193
  if (t.op === '|' &&
833
1194
  !lastIsEqTilde &&
834
1195
  !(prevIsEqTilde && t.tightLeft) &&
@@ -895,10 +1256,33 @@ export function parseCommand(input) {
895
1256
  break;
896
1257
  const word = t.parts;
897
1258
  // assignment prefix before command name?
1259
+ if (name === null && isAppendAssignment(word)) {
1260
+ throw new FauxnixParseError('fauxnix: `+=` append is not supported; use `A=(${A[@]} x)` or `A=(x y)` instead');
1261
+ }
898
1262
  if (name === null && isAssignment(word)) {
899
1263
  next();
900
1264
  const split = splitAssignment(word);
901
1265
  if (split) {
1266
+ if (startsWithUnquotedOpenParen(split.value)) {
1267
+ const elems = [split.value];
1268
+ let depth = unquotedParenDelta(split.value);
1269
+ while (depth > 0 && peek().type === 'WORD' && peek().parts) {
1270
+ const nxt = peek().parts;
1271
+ elems.push(nxt);
1272
+ depth += unquotedParenDelta(nxt);
1273
+ next();
1274
+ }
1275
+ if (depth > 0) {
1276
+ throw new FauxnixParseError('fauxnix: unclosed array assignment; close with `)` or use A=value for a scalar');
1277
+ }
1278
+ const values = stripArrayParens(elems);
1279
+ assignments.push({
1280
+ name: split.name,
1281
+ value: values.length > 0 ? values[0] : [],
1282
+ values,
1283
+ });
1284
+ continue;
1285
+ }
902
1286
  assignments.push(split);
903
1287
  continue;
904
1288
  }
@@ -965,6 +1349,9 @@ export function parseCommand(input) {
965
1349
  if (assignments.length > 0) {
966
1350
  return { kind: 'SimpleCommand', assignments, name: null, args: [], redirects };
967
1351
  }
1352
+ if (peek().type === 'OP' && peek().op === '&') {
1353
+ throw new FauxnixParseError(BACKGROUND_MSG);
1354
+ }
968
1355
  throw new FauxnixParseError('fauxnix: expected a command');
969
1356
  }
970
1357
  if (isUnquotedLiteral(name, '[[')) {
@@ -1061,6 +1448,16 @@ export function parseCommand(input) {
1061
1448
  }
1062
1449
  return parseList();
1063
1450
  }
1451
+ function isFunctionDef(cmd) {
1452
+ if (!cmd.name)
1453
+ return false;
1454
+ const n = wordToString(cmd.name);
1455
+ if (!isUnquotedLiteral(cmd.name, n))
1456
+ return false;
1457
+ if (/^[A-Za-z_][A-Za-z0-9_]*\(\)$/.test(n))
1458
+ return true;
1459
+ return cmd.args.length > 0 && isUnquotedLiteral(cmd.args[0], '()');
1460
+ }
1064
1461
  function isRedirectOp(op) {
1065
1462
  if (!op)
1066
1463
  return false;
@@ -0,0 +1,22 @@
1
+ export declare const POWERSHELL_ARGS: readonly ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass"];
2
+ export type PowerShellEdition = 'Desktop' | 'Core';
3
+ export interface PowerShellSelection {
4
+ /** Absolute executable path resolved once for this check/session. */
5
+ readonly executable: string;
6
+ readonly expectedEdition: PowerShellEdition;
7
+ readonly configured: boolean;
8
+ readonly requested?: string;
9
+ readonly error?: string;
10
+ }
11
+ export interface PowerShellResolveOptions {
12
+ cwd?: string;
13
+ exists?: (candidate: string) => boolean;
14
+ }
15
+ /**
16
+ * Select the process-wide PowerShell host. FAUXNIX_PS is intentionally a
17
+ * small enum, not a command line: spawn() receives one executable and the
18
+ * fixed fauxnix arguments separately.
19
+ */
20
+ export declare function resolvePowerShell(env?: NodeJS.ProcessEnv, options?: PowerShellResolveOptions): PowerShellSelection;
21
+ export declare function powerShellDisplay(selection: PowerShellSelection): string;
22
+ export declare function powerShellMissingMessage(selection: PowerShellSelection): string;