fauxnix-cli 0.9.3 → 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.
@@ -1,12 +1,35 @@
1
- import { Assignment, CommandList, Redirect, SimpleCommand, IfCommand, ForCommand, Word, WordPart } from './ast.js';
1
+ import { Assignment, CommandList, Redirect, SimpleCommand, IfCommand, ForCommand, WhileCommand, CaseCommand, Word, WordPart } from './ast.js';
2
2
  import { PipelineCtx } from './registry.js';
3
3
  /** `${name:-word}` and friends using case-exact fx-scalar0. */
4
4
  export declare function paramExpr(name: string, op: ':-' | ':=' | ':+' | ':?' | '-' | '+' | '?', word: string): string;
5
+ export declare function varExtraOf(p: WordPart): {
6
+ replace?: {
7
+ global: boolean;
8
+ pat: string;
9
+ repl: string;
10
+ };
11
+ slice?: {
12
+ offset: string;
13
+ length?: string;
14
+ };
15
+ } | undefined;
5
16
  /** Map a bash $VAR name to a PowerShell expression (usable inside $(...)). */
6
17
  export declare function varExpr(name: string, index?: string, param?: {
7
18
  op: ':-' | ':=' | ':+' | ':?' | '-' | '+' | '?';
8
19
  word: string;
9
- }, length?: boolean): string;
20
+ }, length?: boolean, extra?: {
21
+ replace?: {
22
+ global: boolean;
23
+ pat: string;
24
+ repl: string;
25
+ };
26
+ slice?: {
27
+ offset: string;
28
+ length?: string;
29
+ };
30
+ }): string;
31
+ /** `$?` `$$` `$0`–`$n` `$#` `$@` `$*` — not ordinary `$env:` names. */
32
+ export declare function isSpecialShellVar(name: string): boolean;
10
33
  /** Escape text destined for the inside of a PS double-quoted string. */
11
34
  export declare function escapeDq(s: string): string;
12
35
  /** Normalize a literal POSIX-ish path to its Windows equivalent. */
@@ -39,6 +62,7 @@ export declare function operandExpr(w: Word): string;
39
62
  /**
40
63
  * `${name[@]}` / `"pre${name[@]}post"` — one argv per element.
41
64
  * Unquoted `${name[*]}` also splats (bash); quoted `"${name[*]}"` stays one join.
65
+ * `$@` / unquoted `$*` splat like `${arr[@]}`; quoted `"$@"` still splats.
42
66
  */
43
67
  export declare function splatSpec(w: Word): {
44
68
  name: string;
@@ -53,6 +77,8 @@ export declare function argListExpr(words: Word[], fn?: (w: Word) => string): st
53
77
  * Unquoted command words join non-empty lines with a space (IFS
54
78
  * word-split approximation). Handlers often emit one string object, so
55
79
  * a bare `$(…)` interpolation would keep those newlines.
80
+ * Lists (`;` `&&` `||`) reuse translateListInline inside the fx-csub
81
+ * scriptblock so the newline contract is unchanged.
56
82
  */
57
83
  export declare function translateCmdSub(cmdText: string, keepNl?: boolean): string;
58
84
  export declare function translateSimple(cmd: SimpleCommand, position: PipelineCtx['position'], hasStdin: boolean): string;
@@ -76,7 +102,7 @@ export interface PipelineParts {
76
102
  call: string;
77
103
  }
78
104
  export declare function translatePipelineBody(p: {
79
- commands: Array<SimpleCommand | IfCommand | ForCommand>;
105
+ commands: Array<SimpleCommand | IfCommand | ForCommand | WhileCommand | CaseCommand>;
80
106
  }): PipelineParts;
81
107
  export interface SegmentPlan {
82
108
  op: ';' | '&&' | '||';
@@ -39,8 +39,43 @@ export function paramExpr(name, op, word) {
39
39
  psStr('bash: ' + msg) +
40
40
  '); $script:fx_exit = 1; \'\' } else { $fx_pv } )');
41
41
  }
42
+ function sliceArgExpr(s) {
43
+ if (s.length > 0 && s[0] === '$') {
44
+ return '(fx-scalar0 ' + psStr(s.slice(1)) + ')';
45
+ }
46
+ return psStr(s);
47
+ }
48
+ export function varExtraOf(p) {
49
+ if (p.kind !== 'Var')
50
+ return undefined;
51
+ if (!p.replace && !p.slice)
52
+ return undefined;
53
+ const extra = {};
54
+ if (p.replace)
55
+ extra.replace = p.replace;
56
+ if (p.slice)
57
+ extra.slice = p.slice;
58
+ return extra;
59
+ }
42
60
  /** Map a bash $VAR name to a PowerShell expression (usable inside $(...)). */
43
- export function varExpr(name, index, param, length = false) {
61
+ export function varExpr(name, index, param, length = false, extra) {
62
+ if (extra && extra.replace) {
63
+ const r = extra.replace;
64
+ return ('(fx-subst (fx-scalar0 ' +
65
+ psStr(name) +
66
+ ') ' +
67
+ psStr(r.pat) +
68
+ ' ' +
69
+ psStr(r.repl) +
70
+ ' ' +
71
+ (r.global ? '$true' : '$false') +
72
+ ')');
73
+ }
74
+ if (extra && extra.slice) {
75
+ const off = sliceArgExpr(extra.slice.offset);
76
+ const len = extra.slice.length !== undefined ? sliceArgExpr(extra.slice.length) : '$null';
77
+ return '(fx-slice (fx-scalar0 ' + psStr(name) + ') ' + off + ' ' + len + ')';
78
+ }
44
79
  if (param)
45
80
  return paramExpr(name, param.op, param.word);
46
81
  if (length) {
@@ -60,6 +95,12 @@ export function varExpr(name, index, param, length = false) {
60
95
  if (index !== undefined) {
61
96
  return '(fx-subget ' + psStr(name) + ' ' + psStr(index) + ')';
62
97
  }
98
+ if (/^[0-9]+$/.test(name)) {
99
+ if (name === '0') {
100
+ return "$(if ($env:FAUXNIX_ARG0) { [string]$env:FAUXNIX_ARG0 } else { 'fauxnix' })";
101
+ }
102
+ return '(fx-posget ' + name + ')';
103
+ }
63
104
  switch (name) {
64
105
  case 'HOME':
65
106
  return '$HOME';
@@ -82,10 +123,24 @@ export function varExpr(name, index, param, length = false) {
82
123
  return '[string]$PID';
83
124
  case 'HOSTNAME':
84
125
  return '$env:COMPUTERNAME';
126
+ case '#':
127
+ return '@(fx-posload).Count';
128
+ case '@':
129
+ case '*':
130
+ return '((@(fx-posload) -join (fx-ifs1)))';
85
131
  default:
86
132
  return '$env:' + name;
87
133
  }
88
134
  }
135
+ /** `$?` `$$` `$0`–`$n` `$#` `$@` `$*` — not ordinary `$env:` names. */
136
+ export function isSpecialShellVar(name) {
137
+ return (name === '?' ||
138
+ name === '$' ||
139
+ name === '#' ||
140
+ name === '@' ||
141
+ name === '*' ||
142
+ /^[0-9]+$/.test(name));
143
+ }
89
144
  /* ------------------------------------------------------------------ */
90
145
  /* Word → PowerShell expression */
91
146
  /* ------------------------------------------------------------------ */
@@ -155,7 +210,7 @@ export function exprOfWord(w, opts) {
155
210
  // single bare variable → bare expression
156
211
  if (expanded.length === 1 && expanded[0].kind === 'Var') {
157
212
  const v = expanded[0];
158
- return varExpr(v.name, v.index, v.param, v.length === true);
213
+ return varExpr(v.name, v.index, v.param, v.length === true, varExtraOf(v));
159
214
  }
160
215
  // Bare `$(...)` must not sit inside a PS expandable string: the
161
216
  // substitution body contains `"` / `$_` that would break interpolation.
@@ -185,7 +240,7 @@ export function exprOfWord(w, opts) {
185
240
  emitPart(q, true);
186
241
  break;
187
242
  case 'Var':
188
- out += '$(' + varExpr(p.name, p.index, p.param, p.length === true) + ')';
243
+ out += '$(' + varExpr(p.name, p.index, p.param, p.length === true, varExtraOf(p)) + ')';
189
244
  break;
190
245
  case 'CmdSub':
191
246
  out += '$(' + translateCmdSub(p.cmd, quoted || opts?.preserveCmdSub === true) + ')';
@@ -241,7 +296,7 @@ function arithSourceExpr(parts) {
241
296
  emit(q);
242
297
  break;
243
298
  case 'Var':
244
- out += '$(' + varExpr(p.name, p.index, p.param, p.length === true) + ')';
299
+ out += '$(' + varExpr(p.name, p.index, p.param, p.length === true, varExtraOf(p)) + ')';
245
300
  break;
246
301
  case 'CmdSub':
247
302
  out += '$(' + translateCmdSub(p.cmd, true) + ')';
@@ -292,6 +347,7 @@ function wordPartsForSplat(w) {
292
347
  /**
293
348
  * `${name[@]}` / `"pre${name[@]}post"` — one argv per element.
294
349
  * Unquoted `${name[*]}` also splats (bash); quoted `"${name[*]}"` stays one join.
350
+ * `$@` / unquoted `$*` splat like `${arr[@]}`; quoted `"$@"` still splats.
295
351
  */
296
352
  export function splatSpec(w) {
297
353
  const parts = wordPartsForSplat(w);
@@ -302,7 +358,10 @@ export function splatSpec(w) {
302
358
  for (const { part: p, quoted } of parts) {
303
359
  const splat = p.kind === 'Var' &&
304
360
  !p.length &&
305
- (p.index === '@' || (p.index === '*' && !quoted));
361
+ (p.name === '@' ||
362
+ (p.name === '*' && !quoted) ||
363
+ p.index === '@' ||
364
+ (p.index === '*' && !quoted));
306
365
  if (splat) {
307
366
  if (seen)
308
367
  return null;
@@ -319,6 +378,12 @@ export function splatSpec(w) {
319
378
  }
320
379
  return name ? { name, prefix, suffix } : null;
321
380
  }
381
+ /** Load the splat source: positionals (`@`/`*`) vs named arrays. */
382
+ function splatLoadCall(name) {
383
+ if (name === '@' || name === '*')
384
+ return 'fx-posload';
385
+ return 'fx-arrload ' + psStr(name);
386
+ }
322
387
  /** PS expression of a string[]: `@` words splat, others stay one element. */
323
388
  export function argListExpr(words, fn = exprOfWord) {
324
389
  if (words.length === 0)
@@ -330,9 +395,9 @@ export function argListExpr(words, fn = exprOfWord) {
330
395
  if (!s)
331
396
  return '@(' + fn(w) + ')';
332
397
  if (!s.prefix && !s.suffix)
333
- return '@(fx-arrload ' + psStr(s.name) + ')';
334
- return ('@($( $fx_sp = @(fx-arrload ' +
335
- psStr(s.name) +
398
+ return '@(' + splatLoadCall(s.name) + ')';
399
+ return ('@($( $fx_sp = @(' +
400
+ splatLoadCall(s.name) +
336
401
  '); if ($fx_sp.Count -eq 0) { $fx_sp = @(' +
337
402
  psStr(s.prefix + s.suffix) +
338
403
  ') } else { $fx_sp[0] = ' +
@@ -353,14 +418,11 @@ export function argListExpr(words, fn = exprOfWord) {
353
418
  * Unquoted command words join non-empty lines with a space (IFS
354
419
  * word-split approximation). Handlers often emit one string object, so
355
420
  * a bare `$(…)` interpolation would keep those newlines.
421
+ * Lists (`;` `&&` `||`) reuse translateListInline inside the fx-csub
422
+ * scriptblock so the newline contract is unchanged.
356
423
  */
357
424
  export function translateCmdSub(cmdText, keepNl = false) {
358
- const list = parseCommand(cmdText);
359
- if (list.segments.length !== 1) {
360
- throw new FauxnixParseError('fauxnix: command substitution with ; && || is not supported yet');
361
- }
362
- const { defs, call } = translatePipelineBody(list.segments[0].pipeline);
363
- const inner = defs ? defs + '\n' + call : call;
425
+ const inner = translateListInline(parseCommand(cmdText));
364
426
  const collected = '(fx-csub { ' + inner + ' })';
365
427
  if (keepNl)
366
428
  return collected;
@@ -384,11 +446,21 @@ export function translateSimple(cmd, position, hasStdin) {
384
446
  // deviation: shell var vs exported var are indistinguishable here).
385
447
  if (cmd.name === null) {
386
448
  const exportHandler = lookup('export');
387
- const words = cmd.assignments.map((a) => [
388
- { kind: 'Text', text: a.name + '=' },
389
- ...a.value,
390
- ]);
391
- return exportHandler ? exportHandler(words, { position, hasStdin }) : '';
449
+ const chunks = [];
450
+ for (const a of cmd.assignments) {
451
+ if (a.values) {
452
+ chunks.push('fx-arrput ' +
453
+ psStr(a.name) +
454
+ ' ' +
455
+ argListExpr(a.values, (w) => exprOfWord(w, { preserveCmdSub: true })));
456
+ }
457
+ else {
458
+ const words = [[{ kind: 'Text', text: a.name + '=' }, ...a.value]];
459
+ if (exportHandler)
460
+ chunks.push(exportHandler(words, { position, hasStdin }));
461
+ }
462
+ }
463
+ return chunks.join('\n');
392
464
  }
393
465
  const nameLit = literalOfWord(cmd.name);
394
466
  const nameSplat = splatSpec(cmd.name);
@@ -405,7 +477,7 @@ export function translateSimple(cmd, position, hasStdin) {
405
477
  redirects: cmd.redirects,
406
478
  }, position, hasStdin);
407
479
  const emptyCmdLines = [
408
- '$fx_cw = @(fx-arrload ' + psStr(nameSplat.name) + ')',
480
+ '$fx_cw = @(' + splatLoadCall(nameSplat.name) + ')',
409
481
  ];
410
482
  if (hasAffix) {
411
483
  emptyCmdLines.push('if ($fx_cw.Count -eq 0) { $fx_cw = @(' +
@@ -547,10 +619,20 @@ export function wrapTempEnv(sets, body, extra) {
547
619
  lines.push(arrSave + '[' + psStr(n) + '] = (fx-arrpackget ' + psStr(n) + ')');
548
620
  }
549
621
  const valVars = [];
622
+ const valIsArr = [];
550
623
  for (let i = 0; i < sets.length; i++) {
551
624
  const vn = '$fx_ev' + id + '_' + i;
552
625
  valVars.push(vn);
553
- lines.push(vn + ' = ' + exprOfWord(sets[i].value, { preserveCmdSub: true }));
626
+ if (sets[i].values) {
627
+ valIsArr.push(true);
628
+ lines.push(vn +
629
+ ' = ' +
630
+ argListExpr(sets[i].values, (w) => exprOfWord(w, { preserveCmdSub: true })));
631
+ }
632
+ else {
633
+ valIsArr.push(false);
634
+ lines.push(vn + ' = ' + exprOfWord(sets[i].value, { preserveCmdSub: true }));
635
+ }
554
636
  }
555
637
  lines.push('try {');
556
638
  for (const u of unsets) {
@@ -576,6 +658,10 @@ export function wrapTempEnv(sets, body, extra) {
576
658
  for (let i = 0; i < sets.length; i++) {
577
659
  const n = sets[i].name;
578
660
  const nq = n.replace(/'/g, "''");
661
+ if (valIsArr[i]) {
662
+ lines.push(' fx-arrput ' + psStr(n) + ' ' + valVars[i]);
663
+ continue;
664
+ }
579
665
  lines.push(' $env:' + n + ' = ' + valVars[i]);
580
666
  lines.push(' fx-arrdrop ' + psStr(n));
581
667
  lines.push(" $env:FAUXNIX_SETVARS = ((@($env:FAUXNIX_SETVARS -split ';' | Where-Object { $_ -ne '' -and $_ -cne '" +
@@ -753,6 +839,22 @@ function translateIf(cmd) {
753
839
  lines.push('}');
754
840
  return lines.join('\n');
755
841
  }
842
+ function translateCase(cmd) {
843
+ const lines = ['$script:fx_exit = 0', '$fx_cw = ' + exprOfWord(cmd.word)];
844
+ for (let i = 0; i < cmd.arms.length; i++) {
845
+ const arm = cmd.arms[i];
846
+ const pats = arm.patterns.map((w) => exprOfWord(w)).join(',');
847
+ const head = i === 0 ? 'if' : 'elseif';
848
+ lines.push(head + ' (fx-casematch $fx_cw @(' + pats + ')) {');
849
+ const body = translateListInline(arm.body);
850
+ if (body) {
851
+ for (const l of body.split('\n'))
852
+ lines.push(l ? ' ' + l : l);
853
+ }
854
+ lines.push('}');
855
+ }
856
+ return lines.join('\n');
857
+ }
756
858
  function translateFor(cmd) {
757
859
  const n = cmd.name.replace(/'/g, "''");
758
860
  const lines = [
@@ -780,6 +882,22 @@ function translateFor(cmd) {
780
882
  lines.push('}');
781
883
  return lines.join('\n');
782
884
  }
885
+ function translateWhile(cmd) {
886
+ // Bash: last executed body owns status; a test that ends the loop does not.
887
+ // Never-entered loops (`while false; do …; done`, `until true; do …; done`)
888
+ // exit 0. Save the body status and restore it on the failing test.
889
+ const fail = cmd.until ? '$script:fx_exit -eq 0' : '$script:fx_exit -ne 0';
890
+ const lines = ['$fx_wst = 0', 'do {'];
891
+ for (const l of translateListInline(cmd.test).split('\n'))
892
+ lines.push(l ? ' ' + l : l);
893
+ lines.push(' if (' + fail + ') { $script:fx_exit = $fx_wst; break }');
894
+ lines.push(' $script:fx_exit = 0');
895
+ for (const l of translateListInline(cmd.body).split('\n'))
896
+ lines.push(l ? ' ' + l : l);
897
+ lines.push(' $fx_wst = $script:fx_exit');
898
+ lines.push('} while ($true)');
899
+ return lines.join('\n');
900
+ }
783
901
  function stdinTarget(c) {
784
902
  let t = null;
785
903
  for (const r of c.redirects) {
@@ -794,7 +912,24 @@ function stdinReadExpr(target) {
794
912
  return '@()';
795
913
  return '@(fx-readlines ' + pathExpr(n) + ')';
796
914
  }
915
+ /** `>` `>>` `&>` `&>>` — Node last-stage apply cannot honor these on earlier stages. */
916
+ function isStdoutFileRedirect(op) {
917
+ return op === '>' || op === '>>' || op === '&>' || op === '&>>';
918
+ }
919
+ const NONLAST_STDOUT_REDIRECT_MSG = 'fauxnix: stdout redirect on a non-last pipeline stage is not supported yet; write the file in a previous list segment (cmd >f; cat f) or wait for per-stage fds (#157)';
920
+ function rejectNonLastStdoutRedirects(commands) {
921
+ if (commands.length < 2)
922
+ return;
923
+ for (let i = 0; i < commands.length - 1; i++) {
924
+ if (commands[i].redirects.some((r) => isStdoutFileRedirect(r.op))) {
925
+ throw new FauxnixParseError(NONLAST_STDOUT_REDIRECT_MSG);
926
+ }
927
+ }
928
+ }
797
929
  export function translatePipelineBody(p) {
930
+ // Last-stage `>` is Node apply; a non-last `>` would truncate the file and
931
+ // still feed the pipe. Fail loud until in-stage writes (#157).
932
+ rejectNonLastStdoutRedirects(p.commands);
798
933
  // Every pipeline stage needs its own status slot. Handlers deliberately use
799
934
  // `$script:fx_exit` because their helper functions run in child scopes; in a
800
935
  // pipeline that shared flag lets an earlier failure leak into a successful
@@ -810,6 +945,10 @@ export function translatePipelineBody(p) {
810
945
  bodies.push(translateIf(c));
811
946
  else if (c.kind === 'For')
812
947
  bodies.push(translateFor(c));
948
+ else if (c.kind === 'While')
949
+ bodies.push(translateWhile(c));
950
+ else if (c.kind === 'Case')
951
+ bodies.push(translateCase(c));
813
952
  else
814
953
  bodies.push(translateSimple(c, position, hasStdin));
815
954
  }
@@ -913,6 +1052,10 @@ const WRAP_HELPER_ORDER = [
913
1052
  'fx-csub',
914
1053
  'fx-svenc',
915
1054
  'fx-svdec',
1055
+ 'fx-posload',
1056
+ 'fx-posset',
1057
+ 'fx-posget',
1058
+ 'fx-posshift',
916
1059
  'fx-arrload',
917
1060
  'fx-scalar0',
918
1061
  'fx-ifs1',
@@ -923,6 +1066,9 @@ const WRAP_HELPER_ORDER = [
923
1066
  'fx-arrput',
924
1067
  'fx-arrclr',
925
1068
  'fx-subget',
1069
+ 'fx-casematch',
1070
+ 'fx-subst',
1071
+ 'fx-slice',
926
1072
  'fx-winargv',
927
1073
  'fx-native',
928
1074
  ];
@@ -931,6 +1077,10 @@ const WRAP_HELPER_DEPS = {
931
1077
  'fx-csub': [],
932
1078
  'fx-svenc': [],
933
1079
  'fx-svdec': [],
1080
+ 'fx-posload': ['fx-svdec'],
1081
+ 'fx-posset': ['fx-svenc'],
1082
+ 'fx-posget': ['fx-posload'],
1083
+ 'fx-posshift': ['fx-posload', 'fx-posset'],
934
1084
  'fx-arrload': ['fx-scalar0', 'fx-svdec'],
935
1085
  'fx-scalar0': ['fx-svdec'],
936
1086
  'fx-ifs1': ['fx-scalar0'],
@@ -941,6 +1091,9 @@ const WRAP_HELPER_DEPS = {
941
1091
  'fx-arrput': ['fx-arrdrop', 'fx-svenc'],
942
1092
  'fx-arrclr': ['fx-arrdrop'],
943
1093
  'fx-subget': ['fx-arrload', 'fx-ifs1'],
1094
+ 'fx-casematch': [],
1095
+ 'fx-subst': [],
1096
+ 'fx-slice': [],
944
1097
  'fx-winargv': [],
945
1098
  'fx-native': ['fx-winargv'],
946
1099
  };
@@ -1058,7 +1211,16 @@ export function wrapScript(body, opts = {}) {
1058
1211
  ' $script:fx_csub = $true',
1059
1212
  ' try { $fx_o = @(& $b | ForEach-Object { [string]$_ }) }',
1060
1213
  ' finally { $script:fx_csub = $fx_prevcs }',
1061
- ' $fx_s = ($fx_o -join [string][char]10)',
1214
+ // Line items (no trailing NL) get a NL between them. Chunks that
1215
+ // already end in NL concatenate, so $(echo a; echo b) is a\nb.
1216
+ " $fx_s = ''",
1217
+ ' foreach ($fx_x in $fx_o) {',
1218
+ ' $fx_t = [string]$fx_x',
1219
+ ' if ($fx_s.Length -gt 0 -and $fx_s[$fx_s.Length - 1] -ne [char]10) {',
1220
+ ' $fx_s += [string][char]10',
1221
+ ' }',
1222
+ ' $fx_s += $fx_t',
1223
+ ' }',
1062
1224
  ' while ($fx_s.Length -gt 0 -and $fx_s[$fx_s.Length - 1] -eq [char]10) {',
1063
1225
  ' $fx_s = $fx_s.Substring(0, $fx_s.Length - 1)',
1064
1226
  ' }',
@@ -1089,6 +1251,52 @@ export function wrapScript(body, opts = {}) {
1089
1251
  ' return [string]$sb',
1090
1252
  '}',
1091
1253
  ],
1254
+ 'fx-posload': [
1255
+ 'function fx-posload {',
1256
+ ' if ($null -eq $env:FAUXNIX_POS -or [string]$env:FAUXNIX_POS -eq \'\') { return @() }',
1257
+ ' $out = @()',
1258
+ ' foreach ($el in @($env:FAUXNIX_POS -split [string][char]30)) { $out += ,(fx-svdec $el) }',
1259
+ ' return $out',
1260
+ '}',
1261
+ ],
1262
+ 'fx-posset': [
1263
+ 'function fx-posset($vals) {',
1264
+ ' if ($null -eq $vals) { $vals = @() }',
1265
+ ' $vals = @($vals)',
1266
+ ' if ($vals.Count -eq 0) { $env:FAUXNIX_POS = \'\'; return }',
1267
+ ' $encs = @(); foreach ($v in $vals) { $encs += (fx-svenc $v) }',
1268
+ ' $env:FAUXNIX_POS = ($encs -join [string][char]30)',
1269
+ '}',
1270
+ ],
1271
+ 'fx-posget': [
1272
+ 'function fx-posget($i) {',
1273
+ ' $arr = @(fx-posload)',
1274
+ ' $n = 0',
1275
+ ' if (-not [int]::TryParse([string]$i, [ref]$n)) { return \'\' }',
1276
+ ' if ($n -lt 1 -or $n -gt $arr.Count) { return \'\' }',
1277
+ ' return [string]$arr[$n - 1]',
1278
+ '}',
1279
+ ],
1280
+ 'fx-posshift': [
1281
+ 'function fx-posshift($n) {',
1282
+ ' $arr = @(fx-posload)',
1283
+ ' $i = 1',
1284
+ ' if ($null -ne $n -and [string]$n -ne \'\') {',
1285
+ ' $parsed = 0',
1286
+ ' if (-not [int]::TryParse([string]$n, [ref]$parsed)) {',
1287
+ ' [Console]::Error.WriteLine(\'bash: shift: \' + [string]$n + \': numeric argument required\')',
1288
+ ' $script:fx_exit = 1',
1289
+ ' return',
1290
+ ' }',
1291
+ ' $i = $parsed',
1292
+ ' }',
1293
+ ' if ($i -lt 0 -or $i -gt $arr.Count) { $script:fx_exit = 1; return }',
1294
+ ' if ($i -eq 0) { return }',
1295
+ ' if ($i -eq $arr.Count) { fx-posset @(); return }',
1296
+ ' $new = @($arr[$i..($arr.Count - 1)])',
1297
+ ' fx-posset $new',
1298
+ '}',
1299
+ ],
1092
1300
  'fx-arrload': [
1093
1301
  'function fx-arrload($n) {',
1094
1302
  ' $n = [string]$n',
@@ -1096,8 +1304,10 @@ export function wrapScript(body, opts = {}) {
1096
1304
  ' $fx_eq = $fx_pair.IndexOf([char]61)',
1097
1305
  ' if ($fx_eq -lt 1) { continue }',
1098
1306
  ' if ($fx_pair.Substring(0, $fx_eq) -cne $n) { continue }',
1307
+ ' $pay = $fx_pair.Substring($fx_eq + 1)',
1308
+ ' if ($pay -eq [string][char]1) { return @() }',
1099
1309
  ' $out = @()',
1100
- ' foreach ($el in @($fx_pair.Substring($fx_eq + 1) -split [string][char]30)) { $out += ,(fx-svdec $el) }',
1310
+ ' foreach ($el in @($pay -split [string][char]30)) { $out += ,(fx-svdec $el) }',
1101
1311
  ' return $out',
1102
1312
  ' }',
1103
1313
  ' $s0 = fx-scalar0 $n',
@@ -1179,9 +1389,12 @@ export function wrapScript(body, opts = {}) {
1179
1389
  'fx-arrput': [
1180
1390
  'function fx-arrput($n, $vals) {',
1181
1391
  ' $n = [string]$n',
1182
- ' $vals = @($vals)',
1392
+ ' if ($null -eq $vals) { $vals = @() } else { $vals = @($vals) }',
1183
1393
  ' fx-arrdrop $n',
1184
- ' if ($vals.Count -eq 0) { } else {',
1394
+ ' if ($vals.Count -eq 0) {',
1395
+ // SOH payload: empty array, distinct from scalar '' and from A=('')
1396
+ " $env:FAUXNIX_ARRS = ((@($env:FAUXNIX_ARRS -split [string][char]10 | Where-Object { $_ -ne '' }) + ($n + [string][char]61 + [string][char]1)) -join [string][char]10)",
1397
+ ' } else {',
1185
1398
  ' $encs = @(); foreach ($v in $vals) { $encs += (fx-svenc $v) }',
1186
1399
  " $env:FAUXNIX_ARRS = ((@($env:FAUXNIX_ARRS -split [string][char]10 | Where-Object { $_ -ne '' }) + ($n + [string][char]61 + ($encs -join [string][char]30))) -join [string][char]10)",
1187
1400
  ' }',
@@ -1216,6 +1429,70 @@ export function wrapScript(body, opts = {}) {
1216
1429
  ' return [string]$arr[$i]',
1217
1430
  '}',
1218
1431
  ],
1432
+ 'fx-casematch': [
1433
+ 'function fx-casematch($w, $pats) {',
1434
+ ' $w = [string]$w',
1435
+ ' foreach ($p in @($pats)) {',
1436
+ ' $pat = [string]$p',
1437
+ ' try {',
1438
+ ' $wp = [WildcardPattern]::new($pat, [System.Management.Automation.WildcardOptions]::None)',
1439
+ ' if ($wp.IsMatch($w)) { return $true }',
1440
+ ' } catch {}',
1441
+ ' }',
1442
+ ' return $false',
1443
+ '}',
1444
+ ],
1445
+ 'fx-subst': [
1446
+ 'function fx-subst($s, $pat, $repl, $all) {',
1447
+ ' if ($null -eq $s) { return \'\' }',
1448
+ ' $s = [string]$s',
1449
+ ' $pat = [string]$pat',
1450
+ ' $repl = [string]$repl',
1451
+ ' if ($pat -eq \'\') { return $s }',
1452
+ ' $glob = $false',
1453
+ ' foreach ($fx_c in $pat.ToCharArray()) {',
1454
+ ' if ($fx_c -eq [char]42 -or $fx_c -eq [char]63) { $glob = $true; break }',
1455
+ ' }',
1456
+ ' if (-not $glob) {',
1457
+ ' if ($all) { return $s.Replace($pat, $repl) }',
1458
+ ' $i = $s.IndexOf($pat)',
1459
+ ' if ($i -lt 0) { return $s }',
1460
+ ' return $s.Substring(0, $i) + $repl + $s.Substring($i + $pat.Length)',
1461
+ ' }',
1462
+ ' $sb = New-Object System.Text.StringBuilder',
1463
+ ' foreach ($fx_c in $pat.ToCharArray()) {',
1464
+ ' if ($fx_c -eq [char]42) { [void]$sb.Append(\'.*\'); continue }',
1465
+ ' if ($fx_c -eq [char]63) { [void]$sb.Append(\'.\'); continue }',
1466
+ ' [void]$sb.Append([regex]::Escape([string]$fx_c))',
1467
+ ' }',
1468
+ ' $rx = New-Object System.Text.RegularExpressions.Regex($sb.ToString(), [System.Text.RegularExpressions.RegexOptions]::Singleline)',
1469
+ ' $fx_rep = $repl.Replace([string][char]36, ([string][char]36 + [string][char]36))',
1470
+ ' if ($all) { return $rx.Replace($s, $fx_rep) }',
1471
+ ' return $rx.Replace($s, $fx_rep, 1)',
1472
+ '}',
1473
+ ],
1474
+ 'fx-slice': [
1475
+ 'function fx-slice($s, $off, $len) {',
1476
+ ' if ($null -eq $s) { return \'\' }',
1477
+ ' $s = [string]$s',
1478
+ ' $n = $s.Length',
1479
+ ' $o = 0',
1480
+ ' if (-not [int]::TryParse([string]$off, [ref]$o)) { return \'\' }',
1481
+ ' if ($o -lt 0) { $o = $n + $o }',
1482
+ ' if ($o -lt 0 -or $o -ge $n) { return \'\' }',
1483
+ ' if ($null -eq $len -or [string]$len -eq \'\') { return $s.Substring($o) }',
1484
+ ' $l = 0',
1485
+ ' if (-not [int]::TryParse([string]$len, [ref]$l)) { return \'\' }',
1486
+ ' if ($l -lt 0) {',
1487
+ ' $fx_end = $n + $l',
1488
+ ' if ($fx_end -lt $o) { return \'\' }',
1489
+ ' $l = $fx_end - $o',
1490
+ ' }',
1491
+ ' if ($l -le 0) { return \'\' }',
1492
+ ' if (($o + $l) -gt $n) { $l = $n - $o }',
1493
+ ' return $s.Substring($o, $l)',
1494
+ '}',
1495
+ ],
1219
1496
  'fx-winargv': [
1220
1497
  'function fx-winargv($argv, $cmdmeta) {',
1221
1498
  // Empty [object[]] unwraps to $null on PS 5.1; @($null) is one empty arg.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fauxnix-cli",
3
- "version": "0.9.3",
3
+ "version": "0.11.0",
4
4
  "description": "Fauxnix — run Linux-style commands on Windows via deterministic PowerShell translation. No VM, no WSL. MCP server + CLI for AI agents.",
5
5
  "type": "module",
6
6
  "bin": {