fauxnix-cli 0.9.2 → 0.11.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>&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;
@@ -242,6 +249,64 @@ function readArithParts(input) {
242
249
  parts.push({ kind: 'Text', text: buf });
243
250
  return parts;
244
251
  }
252
+ /** Integer or `$name` operand of `${name:offset:length}`. */
253
+ function readSliceNum(s, i) {
254
+ while (i < s.length && (s[i] === ' ' || s[i] === '\t'))
255
+ i++;
256
+ if (i >= s.length)
257
+ return null;
258
+ if (s[i] === '$') {
259
+ const j = i + 1;
260
+ if (j < s.length && isNameStart(s[j])) {
261
+ let k = j + 1;
262
+ while (k < s.length && isNameChar(s[k]))
263
+ k++;
264
+ return { val: s.slice(i, k), next: k };
265
+ }
266
+ if (j < s.length && (/[0-9]/.test(s[j]) || s[j] === '?' || s[j] === '$')) {
267
+ return { val: s.slice(i, j + 1), next: j + 1 };
268
+ }
269
+ return null;
270
+ }
271
+ let sign = '';
272
+ if (s[i] === '-' || s[i] === '+') {
273
+ sign = s[i];
274
+ i++;
275
+ }
276
+ if (i >= s.length || !/[0-9]/.test(s[i]))
277
+ return null;
278
+ let k = i;
279
+ while (k < s.length && /[0-9]/.test(s[k]))
280
+ k++;
281
+ return { val: sign + s.slice(i, k), next: k };
282
+ }
283
+ /** Parse `offset` / `offset:length` after `${name:`. Null if not a slice. */
284
+ function parseSliceSpec(after) {
285
+ const off = readSliceNum(after, 0);
286
+ if (!off)
287
+ return null;
288
+ let i = off.next;
289
+ while (i < after.length && (after[i] === ' ' || after[i] === '\t'))
290
+ i++;
291
+ if (i >= after.length)
292
+ return { offset: off.val };
293
+ if (after[i] !== ':')
294
+ return null;
295
+ i++;
296
+ while (i < after.length && (after[i] === ' ' || after[i] === '\t'))
297
+ i++;
298
+ if (i >= after.length)
299
+ return { offset: off.val, length: '0' };
300
+ const len = readSliceNum(after, i);
301
+ if (!len)
302
+ return null;
303
+ i = len.next;
304
+ while (i < after.length && (after[i] === ' ' || after[i] === '\t'))
305
+ i++;
306
+ if (i !== after.length)
307
+ return null;
308
+ return { offset: off.val, length: len.val };
309
+ }
245
310
  /** Parse $VAR, ${VAR}, $(cmd substitution), $((arith)). Returns null when not a valid dollar construct. */
246
311
  function readDollar(input, i) {
247
312
  const n = input.length;
@@ -255,31 +320,76 @@ function readDollar(input, i) {
255
320
  const end = input.indexOf('}', j);
256
321
  if (end === -1)
257
322
  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]+|@|\*)\])?$/);
323
+ const inner = input.slice(j + 1, end);
324
+ const nextPos = end + 1;
325
+ const hash = inner.match(/^#([A-Za-z_][A-Za-z0-9_]*)(\[([0-9]+|@|\*)\])?$/);
272
326
  if (hash) {
273
327
  return {
274
328
  part: { kind: 'Var', name: hash[1], index: hash[3], length: true },
275
- next: end + 1,
329
+ next: nextPos,
276
330
  };
277
331
  }
278
- if (!name || !isNameStart(name[0]) || !name.split('').every(isNameChar)) {
332
+ // ${1} ${#} ${@} ${*} — positional / special params (not ${#name} length)
333
+ if (inner === '#' || inner === '@' || inner === '*' || /^[0-9]+$/.test(inner)) {
334
+ return { part: { kind: 'Var', name: inner }, next: nextPos };
335
+ }
336
+ const sub = inner.match(/^([A-Za-z_][A-Za-z0-9_]*)\[([0-9]+|@|\*)\]$/);
337
+ if (sub) {
338
+ return { part: { kind: 'Var', name: sub[1], index: sub[2] }, next: nextPos };
339
+ }
340
+ if (/^[A-Za-z_][A-Za-z0-9_]*\[([0-9]+|@|\*)\]/.test(inner)) {
341
+ throw new FauxnixParseError('fauxnix: ${name[@]:offset:length} subarray slice is not supported; use ${name:offset:length} on a scalar or ${name[i]} per element');
342
+ }
343
+ const ident = inner.match(/^([A-Za-z_][A-Za-z0-9_]*)/);
344
+ if (ident) {
345
+ const nm = ident[1];
346
+ const rest = inner.slice(nm.length);
347
+ if (rest.startsWith('/#') || rest.startsWith('/%')) {
348
+ throw new FauxnixParseError('fauxnix: ${name/#pat/str} and ${name/%pat/str} are not supported; use ${name//pat/str} instead');
349
+ }
350
+ if (rest.startsWith('/')) {
351
+ const global = rest.startsWith('//');
352
+ const body = rest.slice(global ? 2 : 1);
353
+ const slash = body.indexOf('/');
354
+ const pat = slash === -1 ? body : body.slice(0, slash);
355
+ const repl = slash === -1 ? '' : body.slice(slash + 1);
356
+ return {
357
+ part: { kind: 'Var', name: nm, replace: { global, pat, repl } },
358
+ next: nextPos,
359
+ };
360
+ }
361
+ if (rest.startsWith(':')) {
362
+ const after = rest.slice(1);
363
+ let t = 0;
364
+ while (t < after.length && (after[t] === ' ' || after[t] === '\t'))
365
+ t++;
366
+ const lead = t < after.length ? after[t] : '';
367
+ const hadSpace = t > 0;
368
+ const paramLead = lead === '+' || lead === '?' || lead === '=' || (lead === '-' && !hadSpace);
369
+ if (!paramLead && lead !== '') {
370
+ const sl = parseSliceSpec(after);
371
+ if (sl) {
372
+ return { part: { kind: 'Var', name: nm, slice: sl }, next: nextPos };
373
+ }
374
+ }
375
+ }
376
+ const pm = inner.match(/^([A-Za-z_][A-Za-z0-9_]*)(:?[-+?])(.*)$/);
377
+ if (pm) {
378
+ const op = pm[2];
379
+ return {
380
+ part: { kind: 'Var', name: pm[1], param: { op, word: pm[3] } },
381
+ next: nextPos,
382
+ };
383
+ }
384
+ if (rest === '') {
385
+ return { part: { kind: 'Var', name: nm }, next: nextPos };
386
+ }
387
+ }
388
+ if (!inner || !isNameStart(inner[0]) || !inner.split('').every(isNameChar)) {
279
389
  // ${VAR:=default} etc. still raw text
280
- return { part: { kind: 'Text', text: input.slice(i, end + 1) }, next: end + 1 };
390
+ return { part: { kind: 'Text', text: input.slice(i, end + 1) }, next: nextPos };
281
391
  }
282
- return { part: { kind: 'Var', name }, next: end + 1 };
392
+ return { part: { kind: 'Var', name: inner }, next: nextPos };
283
393
  }
284
394
  // $((...)) arithmetic expansion — distinct from `$( (cmd) )` (space after
285
395
  // the first paren is command substitution of a grouped body).
@@ -350,8 +460,8 @@ function readDollar(input, i) {
350
460
  len++;
351
461
  return { part: { kind: 'Var', name: input.slice(j, j + len) }, next: j + len };
352
462
  }
353
- // special: $? $$ $0-$9 — kept as symbolic Var; the translator maps them
354
- if ('?$_'.includes(input[j]) || /[0-9]/.test(input[j])) {
463
+ // special: $? $$ $0-$9 $# $@ $* — kept as symbolic Var; the translator maps them
464
+ if ('?$_#@*'.includes(input[j]) || /[0-9]/.test(input[j])) {
355
465
  return { part: { kind: 'Var', name: input[j] }, next: j + 1 };
356
466
  }
357
467
  return null;
@@ -578,6 +688,76 @@ function pendingPatternOp(args) {
578
688
  }
579
689
  return null;
580
690
  }
691
+ /** Unquoted `(` / `)` net depth in a word (quoted / escaped parens ignored). */
692
+ function unquotedParenDelta(w) {
693
+ let d = 0;
694
+ for (const p of w) {
695
+ if (p.kind !== 'Text' || p.escaped)
696
+ continue;
697
+ for (const c of p.text) {
698
+ if (c === '(')
699
+ d++;
700
+ else if (c === ')')
701
+ d--;
702
+ }
703
+ }
704
+ return d;
705
+ }
706
+ function startsWithUnquotedOpenParen(w) {
707
+ if (w.length === 0)
708
+ return false;
709
+ const p = w[0];
710
+ return p.kind === 'Text' && !p.escaped && p.text.startsWith('(');
711
+ }
712
+ /** `NAME+=` (scalar or array append) — out of scope for C-2. */
713
+ function isAppendAssignment(w) {
714
+ let s = '';
715
+ for (const p of w) {
716
+ if (p.kind === 'Text' && !p.escaped)
717
+ s += p.text;
718
+ else
719
+ break;
720
+ if (/^[A-Za-z_][A-Za-z0-9_]*\+=/.test(s))
721
+ return true;
722
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(s))
723
+ return false;
724
+ }
725
+ return /^[A-Za-z_][A-Za-z0-9_]*\+=/.test(s);
726
+ }
727
+ function cloneWord(w) {
728
+ return w.map((p) => {
729
+ if (p.kind === 'Text' || p.kind === 'SingleQuoted')
730
+ return { ...p };
731
+ if (p.kind === 'DoubleQuoted')
732
+ return { kind: 'DoubleQuoted', parts: p.parts.slice() };
733
+ return p;
734
+ });
735
+ }
736
+ /** Strip the opening `(` and closing `)` that wrap an array assignment value. */
737
+ function stripArrayParens(words) {
738
+ if (words.length === 0)
739
+ return [];
740
+ const ws = words.map(cloneWord);
741
+ const first = ws[0];
742
+ if (first.length > 0 && first[0].kind === 'Text' && first[0].text.startsWith('(')) {
743
+ first[0] = { kind: 'Text', text: first[0].text.slice(1), escaped: first[0].escaped };
744
+ if (first[0].text === '')
745
+ first.shift();
746
+ }
747
+ const last = ws[ws.length - 1];
748
+ for (let i = last.length - 1; i >= 0; i--) {
749
+ const p = last[i];
750
+ if (p.kind === 'Text' && !p.escaped && p.text.endsWith(')')) {
751
+ const trimmed = p.text.slice(0, -1);
752
+ if (trimmed === '')
753
+ last.splice(i, 1);
754
+ else
755
+ last[i] = { kind: 'Text', text: trimmed, escaped: p.escaped };
756
+ break;
757
+ }
758
+ }
759
+ return ws.filter((w) => w.length > 0);
760
+ }
581
761
  /* ------------------------------------------------------------------ */
582
762
  /* Parser */
583
763
  /* ------------------------------------------------------------------ */
@@ -587,17 +767,44 @@ export function parseCommand(input) {
587
767
  const peek = () => tokens[pos];
588
768
  const next = () => tokens[pos++];
589
769
  const isListSep = (o) => o === ';' || o === '\n';
770
+ const isAmpWord = (t) => (!!t && t.type === 'OP' && t.op === '&') ||
771
+ (!!t && t.type === 'WORD' && !!t.parts && isUnquotedLiteral(t.parts, '&'));
772
+ const isCaseFallthrough = () => {
773
+ const t = peek();
774
+ if (t.type !== 'OP')
775
+ return false;
776
+ return (t.op === ';' || t.op === ';;') && isAmpWord(tokens[pos + 1]);
777
+ };
778
+ const throwUnexpectedDsemi = () => {
779
+ throw new FauxnixParseError("fauxnix: syntax error near unexpected token `;;'");
780
+ };
781
+ const throwCaseFallthrough = () => {
782
+ throw new FauxnixParseError('fauxnix: case fallthrough (;& / ;;&) is not supported; use ;; (no fallthrough) or duplicate the body');
783
+ };
590
784
  /** Consume `;` / newline / `&&` / `||`. Trailing `&&`/`||` and `;;` fail loud (bash). */
591
785
  const consumeListOp = (stops) => {
592
786
  const t = peek();
593
787
  if (t.type !== 'OP')
594
788
  return null;
789
+ if (t.op === '&') {
790
+ // inside a case arm, `;&` / `;;&` is fallthrough — report that, not background
791
+ if (stops && stops.has(';;'))
792
+ throwCaseFallthrough();
793
+ throw new FauxnixParseError(BACKGROUND_MSG);
794
+ }
795
+ if (t.op === ';;') {
796
+ if (stops && stops.has(';;'))
797
+ return null;
798
+ throwUnexpectedDsemi();
799
+ }
595
800
  if (!(t.op === '&&' || t.op === '||' || isListSep(t.op)))
596
801
  return null;
597
802
  if (t.op === ';') {
803
+ if (stops && stops.has(';;') && isAmpWord(tokens[pos + 1]))
804
+ return null;
598
805
  next();
599
- if (peek().type === 'OP' && peek().op === ';') {
600
- throw new FauxnixParseError("fauxnix: syntax error near unexpected token `;;'");
806
+ if (peek().type === 'OP' && (peek().op === ';' || peek().op === ';;')) {
807
+ throwUnexpectedDsemi();
601
808
  }
602
809
  return ';';
603
810
  }
@@ -627,16 +834,20 @@ export function parseCommand(input) {
627
834
  const segments = [];
628
835
  let op = ';';
629
836
  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 `;;'");
837
+ if (peek().op === ';' && tokens[pos + 1]?.type === 'OP' && (tokens[pos + 1]?.op === ';' || tokens[pos + 1]?.op === ';;')) {
838
+ throwUnexpectedDsemi();
632
839
  }
633
840
  next();
634
841
  }
635
842
  while (peek().type !== 'EOF') {
843
+ if (peek().type === 'OP' && peek().op === ';;')
844
+ throwUnexpectedDsemi();
636
845
  const pipeline = parsePipeline();
637
846
  segments.push({ pipeline, op });
638
847
  const nextOp = consumeListOp();
639
848
  if (nextOp === null) {
849
+ if (peek().type === 'OP' && peek().op === ';;')
850
+ throwUnexpectedDsemi();
640
851
  if (peek().type === 'EOF')
641
852
  break;
642
853
  throw new FauxnixParseError('fauxnix: unexpected token after pipeline');
@@ -653,18 +864,43 @@ export function parseCommand(input) {
653
864
  const kw = peekKw();
654
865
  if (kw === 'if') {
655
866
  if (commands.length > 0) {
656
- throw new FauxnixParseError('fauxnix: if in a pipeline is not supported');
867
+ throw new FauxnixParseError(IF_IN_PIPELINE_MSG);
657
868
  }
658
869
  commands.push(parseIf());
659
870
  }
660
871
  else if (kw === 'for') {
661
872
  if (commands.length > 0) {
662
- throw new FauxnixParseError('fauxnix: for in a pipeline is not supported');
873
+ throw new FauxnixParseError(FOR_IN_PIPELINE_MSG);
663
874
  }
664
875
  commands.push(parseFor());
665
876
  }
877
+ else if (kw === 'while') {
878
+ if (commands.length > 0) {
879
+ throw new FauxnixParseError('fauxnix: while in a pipeline is not supported');
880
+ }
881
+ commands.push(parseWhile());
882
+ }
883
+ else if (kw === 'until') {
884
+ if (commands.length > 0) {
885
+ throw new FauxnixParseError('fauxnix: until in a pipeline is not supported');
886
+ }
887
+ commands.push(parseUntil());
888
+ }
889
+ else if (kw === 'case') {
890
+ if (commands.length > 0) {
891
+ throw new FauxnixParseError('fauxnix: case in a pipeline is not supported');
892
+ }
893
+ commands.push(parseCase());
894
+ }
895
+ else if (kw === 'function') {
896
+ throw new FauxnixParseError(FUNCTION_MSG);
897
+ }
666
898
  else {
667
- commands.push(parseSimple());
899
+ const cmd = parseSimple();
900
+ if (cmd.kind === 'SimpleCommand' && cmd.name && isFunctionDef(cmd)) {
901
+ throw new FauxnixParseError(FUNCTION_MSG);
902
+ }
903
+ commands.push(cmd);
668
904
  }
669
905
  const t = peek();
670
906
  if (t.type === 'OP' && t.op === '|') {
@@ -691,7 +927,11 @@ export function parseCommand(input) {
691
927
  s === 'in' ||
692
928
  s === 'do' ||
693
929
  s === 'done' ||
694
- s === 'while') {
930
+ s === 'while' ||
931
+ s === 'until' ||
932
+ s === 'case' ||
933
+ s === 'esac' ||
934
+ s === 'function') {
695
935
  return s;
696
936
  }
697
937
  return null;
@@ -707,23 +947,34 @@ export function parseCommand(input) {
707
947
  const segments = [];
708
948
  let op = ';';
709
949
  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 `;;'");
950
+ if (peek().op === ';' && tokens[pos + 1]?.type === 'OP' && (tokens[pos + 1]?.op === ';' || tokens[pos + 1]?.op === ';;')) {
951
+ throwUnexpectedDsemi();
712
952
  }
713
953
  next();
714
954
  }
715
955
  while (peek().type !== 'EOF') {
956
+ if (stop.has(';;') && isCaseFallthrough())
957
+ break;
716
958
  const kw = peekKw();
717
959
  if (kw && stop.has(kw))
718
960
  break;
961
+ const stopOp = peek();
962
+ if (stopOp.type === 'OP' && stopOp.op && stop.has(stopOp.op))
963
+ break;
964
+ if (stopOp.type === 'OP' && stopOp.op === ';;')
965
+ throwUnexpectedDsemi();
719
966
  const pipeline = parsePipeline();
720
967
  segments.push({ pipeline, op });
968
+ if (stop.has(';;') && isCaseFallthrough())
969
+ break;
721
970
  const nextOp = consumeListOp(stop);
722
971
  if (nextOp === null)
723
972
  break;
724
973
  op = nextOp;
725
974
  }
726
975
  if (segments.length === 0) {
976
+ if (stop.has(';;'))
977
+ return { kind: 'CommandList', segments: [] };
727
978
  throw new FauxnixParseError('fauxnix: empty command');
728
979
  }
729
980
  return { kind: 'CommandList', segments };
@@ -761,6 +1012,9 @@ export function parseCommand(input) {
761
1012
  throw new FauxnixParseError('fauxnix: `for` expected a name');
762
1013
  }
763
1014
  const name = wordToString(nt.parts);
1015
+ if (name.startsWith('((')) {
1016
+ throw new FauxnixParseError(CSTYLE_FOR_MSG);
1017
+ }
764
1018
  if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || !isUnquotedLiteral(nt.parts, name)) {
765
1019
  throw new FauxnixParseError('fauxnix: `for` name must be an identifier');
766
1020
  }
@@ -784,6 +1038,92 @@ export function parseCommand(input) {
784
1038
  expectKw('done');
785
1039
  return { kind: 'For', name, words, body, redirects: [] };
786
1040
  };
1041
+ const parseWhile = () => parseWhileLoop(false);
1042
+ const parseUntil = () => parseWhileLoop(true);
1043
+ const parseWhileLoop = (until) => {
1044
+ expectKw(until ? 'until' : 'while');
1045
+ const test = parseListUntil(['do']);
1046
+ expectKw('do');
1047
+ const body = parseListUntil(['done']);
1048
+ expectKw('done');
1049
+ return { kind: 'While', until, test, body, redirects: [] };
1050
+ };
1051
+ const skipCaseSeps = () => {
1052
+ while (peek().type === 'OP' && isListSep(peek().op)) {
1053
+ if (peek().op === ';' && isAmpWord(tokens[pos + 1]))
1054
+ break;
1055
+ next();
1056
+ }
1057
+ };
1058
+ /** Strip a trailing unquoted `)` that closes a case pattern list. */
1059
+ const stripTrailingUnquotedParen = (w) => {
1060
+ if (w.length === 0)
1061
+ return null;
1062
+ const last = w[w.length - 1];
1063
+ if (last.kind !== 'Text' || last.escaped || !last.text.endsWith(')'))
1064
+ return null;
1065
+ const rest = last.text.slice(0, -1);
1066
+ if (rest.length === 0)
1067
+ return w.slice(0, -1);
1068
+ return [...w.slice(0, -1), { kind: 'Text', text: rest, escaped: last.escaped }];
1069
+ };
1070
+ const parseCasePatterns = () => {
1071
+ const patterns = [];
1072
+ for (;;) {
1073
+ while (peek().type === 'OP' && (peek().op === '|' || peek().op === '\n'))
1074
+ next();
1075
+ if (peekKw() === 'esac') {
1076
+ throw new FauxnixParseError("fauxnix: expected `)'");
1077
+ }
1078
+ const t = peek();
1079
+ if (t.type !== 'WORD' || !t.parts) {
1080
+ throw new FauxnixParseError('fauxnix: `case` expected a pattern');
1081
+ }
1082
+ const stripped = stripTrailingUnquotedParen(t.parts);
1083
+ if (stripped !== null) {
1084
+ next();
1085
+ if (stripped.length > 0)
1086
+ patterns.push(stripped);
1087
+ if (patterns.length === 0) {
1088
+ throw new FauxnixParseError('fauxnix: `case` expected a pattern');
1089
+ }
1090
+ return patterns;
1091
+ }
1092
+ patterns.push(t.parts);
1093
+ next();
1094
+ }
1095
+ };
1096
+ const parseCase = () => {
1097
+ expectKw('case');
1098
+ skipCaseSeps();
1099
+ const wt = peek();
1100
+ if (wt.type !== 'WORD' || !wt.parts) {
1101
+ throw new FauxnixParseError('fauxnix: `case` expected a word');
1102
+ }
1103
+ const word = wt.parts;
1104
+ next();
1105
+ skipCaseSeps();
1106
+ expectKw('in');
1107
+ const arms = [];
1108
+ skipCaseSeps();
1109
+ while (peek().type !== 'EOF' && peekKw() !== 'esac') {
1110
+ if (isCaseFallthrough())
1111
+ throwCaseFallthrough();
1112
+ const patterns = parseCasePatterns();
1113
+ const body = parseListUntil(['esac', ';;']);
1114
+ if (isCaseFallthrough())
1115
+ throwCaseFallthrough();
1116
+ if (peek().type === 'OP' && peek().op === ';;') {
1117
+ next();
1118
+ if (isAmpWord(peek()))
1119
+ throwCaseFallthrough();
1120
+ }
1121
+ arms.push({ patterns, body });
1122
+ skipCaseSeps();
1123
+ }
1124
+ expectKw('esac');
1125
+ return { kind: 'Case', word, arms, redirects: [] };
1126
+ };
787
1127
  const parseSimple = () => {
788
1128
  const assignments = [];
789
1129
  let redirects = [];
@@ -804,6 +1144,7 @@ export function parseCommand(input) {
804
1144
  (t.op === '&&' ||
805
1145
  t.op === '||' ||
806
1146
  t.op === '|' ||
1147
+ t.op === '&' ||
807
1148
  t.op === '>' ||
808
1149
  t.op === '<' ||
809
1150
  t.op === '>>' ||
@@ -817,7 +1158,8 @@ export function parseCommand(input) {
817
1158
  // Glue `|` onto the surrounding words so `=~ ^a|z$`,
818
1159
  // `=~ (a | b)c`, and `== @(x | y)` stay one operand. A
819
1160
  // spaced `|` outside an open regex / extglob group is a
820
- // syntax error (bash).
1161
+ // syntax error (bash). Tight `&` inside an open `=~ (…)`
1162
+ // group stays in the regex, not a background job.
821
1163
  const last = args.length ? args[args.length - 1] : null;
822
1164
  const lastIsEqTilde = last !== null && isUnquotedLiteral(last, '=~');
823
1165
  const prevIsEqTilde = args.length >= 2 && isUnquotedLiteral(args[args.length - 2], '=~');
@@ -828,7 +1170,18 @@ export function parseCommand(input) {
828
1170
  pendingPatternOp(args) === '==' &&
829
1171
  unmatchedExtglob(last);
830
1172
  const inRe = lastIsEqTilde || prevIsEqTilde || openRe;
831
- if (t.op === '|' || ((inRe || openExt) && t.tightLeft && t.op === '||')) {
1173
+ if (t.op === '&') {
1174
+ if (!(openRe && last && t.tightLeft)) {
1175
+ throw new FauxnixParseError("fauxnix: [[: syntax error near unexpected token `&'");
1176
+ }
1177
+ args[args.length - 1] = [...last, { kind: 'Text', text: '&' }];
1178
+ const n = peek();
1179
+ if (n.type === 'WORD' && n.tightLeft && n.parts) {
1180
+ next();
1181
+ args[args.length - 1] = [...args[args.length - 1], ...n.parts];
1182
+ }
1183
+ }
1184
+ else if (t.op === '|' || ((inRe || openExt) && t.tightLeft && t.op === '||')) {
832
1185
  if (t.op === '|' &&
833
1186
  !lastIsEqTilde &&
834
1187
  !(prevIsEqTilde && t.tightLeft) &&
@@ -895,10 +1248,33 @@ export function parseCommand(input) {
895
1248
  break;
896
1249
  const word = t.parts;
897
1250
  // assignment prefix before command name?
1251
+ if (name === null && isAppendAssignment(word)) {
1252
+ throw new FauxnixParseError('fauxnix: `+=` append is not supported; use `A=(${A[@]} x)` or `A=(x y)` instead');
1253
+ }
898
1254
  if (name === null && isAssignment(word)) {
899
1255
  next();
900
1256
  const split = splitAssignment(word);
901
1257
  if (split) {
1258
+ if (startsWithUnquotedOpenParen(split.value)) {
1259
+ const elems = [split.value];
1260
+ let depth = unquotedParenDelta(split.value);
1261
+ while (depth > 0 && peek().type === 'WORD' && peek().parts) {
1262
+ const nxt = peek().parts;
1263
+ elems.push(nxt);
1264
+ depth += unquotedParenDelta(nxt);
1265
+ next();
1266
+ }
1267
+ if (depth > 0) {
1268
+ throw new FauxnixParseError('fauxnix: unclosed array assignment; close with `)` or use A=value for a scalar');
1269
+ }
1270
+ const values = stripArrayParens(elems);
1271
+ assignments.push({
1272
+ name: split.name,
1273
+ value: values.length > 0 ? values[0] : [],
1274
+ values,
1275
+ });
1276
+ continue;
1277
+ }
902
1278
  assignments.push(split);
903
1279
  continue;
904
1280
  }
@@ -965,6 +1341,9 @@ export function parseCommand(input) {
965
1341
  if (assignments.length > 0) {
966
1342
  return { kind: 'SimpleCommand', assignments, name: null, args: [], redirects };
967
1343
  }
1344
+ if (peek().type === 'OP' && peek().op === '&') {
1345
+ throw new FauxnixParseError(BACKGROUND_MSG);
1346
+ }
968
1347
  throw new FauxnixParseError('fauxnix: expected a command');
969
1348
  }
970
1349
  if (isUnquotedLiteral(name, '[[')) {
@@ -1061,6 +1440,16 @@ export function parseCommand(input) {
1061
1440
  }
1062
1441
  return parseList();
1063
1442
  }
1443
+ function isFunctionDef(cmd) {
1444
+ if (!cmd.name)
1445
+ return false;
1446
+ const n = wordToString(cmd.name);
1447
+ if (!isUnquotedLiteral(cmd.name, n))
1448
+ return false;
1449
+ if (/^[A-Za-z_][A-Za-z0-9_]*\(\)$/.test(n))
1450
+ return true;
1451
+ return cmd.args.length > 0 && isUnquotedLiteral(cmd.args[0], '()');
1452
+ }
1064
1453
  function isRedirectOp(op) {
1065
1454
  if (!op)
1066
1455
  return false;
@@ -99,6 +99,8 @@ export interface CommandSpec {
99
99
  dispatch?: 'translated' | 'native' | 'dynamic';
100
100
  /** GNU usage/syntax exit (grep uses 2; cp/mv/rm use 1). */
101
101
  usageExit?: number;
102
+ /** First non-option operand ends option scanning (echo/printf). */
103
+ leadingOptions?: boolean;
102
104
  handler: Handler;
103
105
  }
104
106
  /** Register a spec'd command. Unknown/unsupported options become GNU-style usage errors. */
package/dist/registry.js CHANGED
@@ -197,7 +197,8 @@ export function specsMarkdown() {
197
197
  for (const spec of registeredSpecs()) {
198
198
  lines.push('## `' + spec.names.join('` / `') + '`');
199
199
  lines.push('');
200
- lines.push('Effects: ' + spec.effects.map((e) => '`' + e + '`').join(', '));
200
+ lines.push('Effects: ' +
201
+ (spec.effects.length ? spec.effects.map((e) => '`' + e + '`').join(', ') : 'none'));
201
202
  lines.push('');
202
203
  if (!spec.options.length) {
203
204
  lines.push('No options declared.');
@@ -319,6 +320,8 @@ export function specOptionError(spec, args, cmdName) {
319
320
  i++;
320
321
  continue;
321
322
  }
323
+ if (spec.leadingOptions)
324
+ onlyOperands = true;
322
325
  i++;
323
326
  }
324
327
  return null;