fauxnix-cli 0.2.1 → 0.4.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,11 +1,17 @@
1
- import { FauxnixParseError, } from './ast.js';
1
+ import { FauxnixParseError, isUnquotedLiteral, } from './ast.js';
2
2
  import { parseCommand } from './parser.js';
3
3
  import { lookup, psStr } from './registry.js';
4
4
  /* ------------------------------------------------------------------ */
5
5
  /* Variable mapping */
6
6
  /* ------------------------------------------------------------------ */
7
7
  /** Map a bash $VAR name to a PowerShell expression (usable inside $(...)). */
8
- export function varExpr(name) {
8
+ export function varExpr(name, index) {
9
+ // Indexed reads always go through fx-subget → fx-arrload → fx-scalar0 so
10
+ // ${PWD[0]} keeps the special mapping and ${bash_rematch[0]} stays
11
+ // case-exact (a `$env:name` fallback would alias BASH_REMATCH on Windows).
12
+ if (index !== undefined) {
13
+ return '(fx-subget ' + psStr(name) + ' ' + psStr(index) + ')';
14
+ }
9
15
  switch (name) {
10
16
  case 'HOME':
11
17
  return '$HOME';
@@ -36,8 +42,13 @@ export function varExpr(name) {
36
42
  /* Word → PowerShell expression */
37
43
  /* ------------------------------------------------------------------ */
38
44
  /** Escape text destined for the inside of a PS double-quoted string. */
39
- function escDq(s) {
40
- return s.replace(/`/g, '``').replace(/"/g, '\\"').replace(/\$/g, '`$');
45
+ export function escapeDq(s) {
46
+ return s
47
+ .replace(/`/g, '``')
48
+ .replace(/"/g, '`"')
49
+ .replace(/\$/g, '`$')
50
+ .replace(/\r/g, '`r')
51
+ .replace(/\n/g, '`n');
41
52
  }
42
53
  /** Normalize a literal POSIX-ish path to its Windows equivalent. */
43
54
  export function normalizeLiteralPath(s) {
@@ -80,7 +91,7 @@ export function pathExpr(s) {
80
91
  * Literal words become single-quoted strings; dynamic ones become
81
92
  * double-quoted strings with $(...) interpolation.
82
93
  */
83
- export function exprOfWord(w) {
94
+ export function exprOfWord(w, opts) {
84
95
  // tilde expansion (unquoted leading ~)
85
96
  const expanded = [];
86
97
  if (w.length > 0 && w[0].kind === 'Text' && w[0].text.startsWith('~')) {
@@ -95,7 +106,12 @@ export function exprOfWord(w) {
95
106
  }
96
107
  // single bare variable → bare expression
97
108
  if (expanded.length === 1 && expanded[0].kind === 'Var') {
98
- return varExpr(expanded[0].name);
109
+ return varExpr(expanded[0].name, expanded[0].index);
110
+ }
111
+ // Bare `$(...)` must not sit inside a PS expandable string: the
112
+ // substitution body contains `"` / `$_` that would break interpolation.
113
+ if (expanded.length === 1 && expanded[0].kind === 'CmdSub') {
114
+ return '$(' + translateCmdSub(expanded[0].cmd, opts?.preserveCmdSub === true) + ')';
99
115
  }
100
116
  const literal = expanded.every((p) => p.kind === 'Text' || p.kind === 'SingleQuoted');
101
117
  if (literal) {
@@ -104,28 +120,28 @@ export function exprOfWord(w) {
104
120
  }
105
121
  // dynamic — build a PS double-quoted string with interpolation
106
122
  let out = '"';
107
- const emitPart = (p) => {
123
+ const emitPart = (p, quoted) => {
108
124
  switch (p.kind) {
109
125
  case 'Text':
110
- out += escDq(p.text);
126
+ out += escapeDq(p.text);
111
127
  break;
112
128
  case 'SingleQuoted':
113
- out += escDq(p.text);
129
+ out += escapeDq(p.text);
114
130
  break;
115
131
  case 'DoubleQuoted':
116
132
  for (const q of p.parts)
117
- emitPart(q);
133
+ emitPart(q, true);
118
134
  break;
119
135
  case 'Var':
120
- out += '$(' + varExpr(p.name) + ')';
136
+ out += '$(' + varExpr(p.name, p.index) + ')';
121
137
  break;
122
138
  case 'CmdSub':
123
- out += '$(' + translateCmdSub(p.cmd) + ')';
139
+ out += '$(' + translateCmdSub(p.cmd, quoted || opts?.preserveCmdSub === true) + ')';
124
140
  break;
125
141
  }
126
142
  };
127
143
  for (const p of expanded)
128
- emitPart(p);
144
+ emitPart(p, false);
129
145
  out += '"';
130
146
  return out;
131
147
  }
@@ -148,27 +164,136 @@ export function operandExpr(w) {
148
164
  return pathExpr(normalizeLiteralPath(lit));
149
165
  return exprOfWord(w);
150
166
  }
167
+ /** Flatten quotes but remember whether a part sat inside `"..."`. */
168
+ function wordPartsForSplat(w) {
169
+ const out = [];
170
+ const walk = (parts, quoted) => {
171
+ for (const p of parts) {
172
+ if (p.kind === 'DoubleQuoted')
173
+ walk(p.parts, true);
174
+ else
175
+ out.push({ part: p, quoted });
176
+ }
177
+ };
178
+ walk(w, false);
179
+ return out;
180
+ }
181
+ /**
182
+ * `${name[@]}` / `"pre${name[@]}post"` — one argv per element.
183
+ * Unquoted `${name[*]}` also splats (bash); quoted `"${name[*]}"` stays one join.
184
+ */
185
+ export function splatSpec(w) {
186
+ const parts = wordPartsForSplat(w);
187
+ let name = null;
188
+ let prefix = '';
189
+ let suffix = '';
190
+ let seen = false;
191
+ for (const { part: p, quoted } of parts) {
192
+ const splat = p.kind === 'Var' && (p.index === '@' || (p.index === '*' && !quoted));
193
+ if (splat) {
194
+ if (seen)
195
+ return null;
196
+ seen = true;
197
+ name = p.name;
198
+ continue;
199
+ }
200
+ if (p.kind !== 'Text' && p.kind !== 'SingleQuoted')
201
+ return null;
202
+ if (seen)
203
+ suffix += p.text;
204
+ else
205
+ prefix += p.text;
206
+ }
207
+ return name ? { name, prefix, suffix } : null;
208
+ }
209
+ /** PS expression of a string[]: `@` words splat, others stay one element. */
210
+ export function argListExpr(words, fn = exprOfWord) {
211
+ if (words.length === 0)
212
+ return '@()';
213
+ return ('(' +
214
+ words
215
+ .map((w) => {
216
+ const s = splatSpec(w);
217
+ if (!s)
218
+ return '@(' + fn(w) + ')';
219
+ if (!s.prefix && !s.suffix)
220
+ return '@(fx-arrload ' + psStr(s.name) + ')';
221
+ return ('@($( $fx_sp = @(fx-arrload ' +
222
+ psStr(s.name) +
223
+ '); if ($fx_sp.Count -eq 0) { $fx_sp = @(' +
224
+ psStr(s.prefix + s.suffix) +
225
+ ') } else { $fx_sp[0] = ' +
226
+ psStr(s.prefix) +
227
+ ' + $fx_sp[0]; $fx_sp[$fx_sp.Count-1] = $fx_sp[$fx_sp.Count-1] + ' +
228
+ psStr(s.suffix) +
229
+ ' }; $fx_sp ))');
230
+ })
231
+ .join(' + ') +
232
+ ')');
233
+ }
151
234
  /* ------------------------------------------------------------------ */
152
235
  /* Command substitution */
153
236
  /* ------------------------------------------------------------------ */
154
- /** Translate the inside of $(...) — pipelines only, no wrappers. */
155
- export function translateCmdSub(cmdText) {
237
+ /**
238
+ * Translate the inside of $(...).
239
+ * `keepNl`: quoted words and assignments keep interior newlines (bash).
240
+ * Unquoted command words join non-empty lines with a space (IFS
241
+ * word-split approximation). Handlers often emit one string object, so
242
+ * a bare `$(…)` interpolation would keep those newlines.
243
+ */
244
+ export function translateCmdSub(cmdText, keepNl = false) {
156
245
  const list = parseCommand(cmdText);
157
246
  if (list.segments.length !== 1) {
158
247
  throw new FauxnixParseError('fauxnix: command substitution with ; && || is not supported yet');
159
248
  }
160
249
  const { defs, call } = translatePipelineBody(list.segments[0].pipeline);
161
- return defs ? defs + '\n' + call : call;
250
+ const inner = defs ? defs + '\n' + call : call;
251
+ const collected = '(fx-csub { ' + inner + ' })';
252
+ if (keepNl)
253
+ return collected;
254
+ return ('((' +
255
+ collected +
256
+ " -split [string][char]10 | Where-Object { $_ -ne '' }) -join ' ')");
162
257
  }
163
258
  /* ------------------------------------------------------------------ */
164
259
  /* Simple command translation */
165
260
  /* ------------------------------------------------------------------ */
166
261
  export function translateSimple(cmd, position, hasStdin) {
262
+ // assignment-only segment (`X=1; cmd`): bash semantics are "set for the
263
+ // rest of the shell". Reuse the export code path — persist + env shadow —
264
+ // so empty values (`X=`) and `[[ -v X ]]` behave like bash (documented
265
+ // deviation: shell var vs exported var are indistinguishable here).
266
+ if (cmd.name === null) {
267
+ const exportHandler = lookup('export');
268
+ const words = cmd.assignments.map((a) => [
269
+ { kind: 'Text', text: a.name + '=' },
270
+ ...a.value,
271
+ ]);
272
+ return exportHandler ? exportHandler(words, { position, hasStdin }) : '';
273
+ }
167
274
  const nameLit = literalOfWord(cmd.name);
275
+ const nameSplat = splatSpec(cmd.name);
168
276
  let body;
169
- if (nameLit !== null) {
277
+ if (nameSplat) {
278
+ const invoke = '& $fx_cmd @fx_na';
279
+ body = [
280
+ '$fx_cw = @(fx-arrload ' + psStr(nameSplat.name) + ')',
281
+ 'if ($fx_cw.Count -eq 0) { $fx_cw = @(' + psStr(nameSplat.prefix + nameSplat.suffix) + ') }',
282
+ 'else { $fx_cw[0] = ' +
283
+ psStr(nameSplat.prefix) +
284
+ ' + $fx_cw[0]; $fx_cw[$fx_cw.Count-1] = $fx_cw[$fx_cw.Count-1] + ' +
285
+ psStr(nameSplat.suffix) +
286
+ ' }',
287
+ '$fx_cmd = [string]$fx_cw[0]',
288
+ '$fx_na = ' + argListExpr(cmd.args),
289
+ 'if ($fx_cw.Count -gt 1) { $fx_na = @($fx_cw[1..($fx_cw.Count - 1)]) + $fx_na }',
290
+ (hasStdin ? '($input | ' + invoke + ')' : invoke) + ' | ForEach-Object { [string]$_ }',
291
+ 'if ($LASTEXITCODE -gt 0) { $script:fx_exit = $LASTEXITCODE } elseif ($LASTEXITCODE -lt 0) { $script:fx_exit = 1 }',
292
+ ].join('\n');
293
+ }
294
+ else if (nameLit !== null) {
170
295
  const handler = lookup(nameLit);
171
- if (handler) {
296
+ if (handler && !(nameLit === '[[' && !isUnquotedLiteral(cmd.name, '[['))) {
172
297
  body = handler(cmd.args, { position, hasStdin });
173
298
  }
174
299
  else {
@@ -176,12 +301,11 @@ export function translateSimple(cmd, position, hasStdin) {
176
301
  // invoked with the call operator and an argv-style argument array —
177
302
  // no string re-parsing of user text.
178
303
  const nameExpr = psStr(nameLit);
179
- const argExprs = cmd.args.map((a) => exprOfWord(a));
180
- const args = argExprs.length ? ' @(' + argExprs.join(', ') + ')' : '';
181
- const call = '& ' + nameExpr + args;
304
+ const invoke = '& ' + nameExpr + ' @fx_na';
182
305
  body = [
306
+ '$fx_na = ' + argListExpr(cmd.args),
183
307
  // feed pipeline stdin into the native process when we are a non-first stage
184
- (hasStdin ? '($input | ' + call + ')' : call) + ' | ForEach-Object { [string]$_ }',
308
+ (hasStdin ? '($input | ' + invoke + ')' : invoke) + ' | ForEach-Object { [string]$_ }',
185
309
  'if ($LASTEXITCODE -gt 0) { $script:fx_exit = $LASTEXITCODE } elseif ($LASTEXITCODE -lt 0) { $script:fx_exit = 1 }',
186
310
  ].join('\n');
187
311
  }
@@ -189,23 +313,277 @@ export function translateSimple(cmd, position, hasStdin) {
189
313
  else {
190
314
  // dynamic command name — evaluate it
191
315
  const nameExpr = exprOfWord(cmd.name);
192
- const argExprs = cmd.args.map((a) => exprOfWord(a));
193
- const args = argExprs.length ? ' @(' + argExprs.join(', ') + ')' : '';
194
- const call = '& (' + nameExpr + ')' + args;
316
+ const invoke = '& (' + nameExpr + ') @fx_na';
195
317
  body = [
196
- (hasStdin ? '($input | ' + call + ')' : call) + ' | ForEach-Object { [string]$_ }',
318
+ '$fx_na = ' + argListExpr(cmd.args),
319
+ (hasStdin ? '($input | ' + invoke + ')' : invoke) + ' | ForEach-Object { [string]$_ }',
197
320
  'if ($LASTEXITCODE -gt 0) { $script:fx_exit = $LASTEXITCODE } elseif ($LASTEXITCODE -lt 0) { $script:fx_exit = 1 }',
198
321
  ].join('\n');
199
322
  }
200
- // `VAR=value cmd ...` prefix set process env for the invocation.
323
+ // `VAR=value cmd` is command-scoped. Values are captured in the
324
+ // current environment, then applied, then restored — including when
325
+ // the command throws — so they never leak into later list segments
326
+ // or the persisted MCP session. `VAR=x export VAR` keeps VAR (bash).
201
327
  if (cmd.assignments.length > 0) {
202
- const sets = cmd.assignments
203
- .map((a) => '$env:' + a.name + ' = ' + exprOfWord(a.value))
204
- .join('; ');
205
- body = sets + '\n' + body;
328
+ const persistNames = new Set();
329
+ const persistWords = [];
330
+ if (nameLit === 'export') {
331
+ for (const w of cmd.args) {
332
+ const lit = literalExportName(w);
333
+ if (lit === '')
334
+ continue; // flag
335
+ if (lit)
336
+ persistNames.add(lit);
337
+ else
338
+ persistWords.push(w);
339
+ }
340
+ }
341
+ body = wrapTempEnv(cmd.assignments, body, { persistNames, persistWords });
206
342
  }
207
343
  return body;
208
344
  }
345
+ /** Literal `NAME` / `NAME=...` from an export argument. `''` = flag. */
346
+ function literalExportName(w) {
347
+ if (w.length === 0)
348
+ return null;
349
+ let s = '';
350
+ for (const p of w) {
351
+ if (p.kind !== 'Text' && p.kind !== 'SingleQuoted')
352
+ return null;
353
+ const eq = p.text.indexOf('=');
354
+ if (eq >= 0) {
355
+ const name = s + p.text.slice(0, eq);
356
+ return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) ? name : null;
357
+ }
358
+ s += p.text;
359
+ }
360
+ if (s.startsWith('-'))
361
+ return '';
362
+ return /^[A-Za-z_][A-Za-z0-9_]*$/.test(s) ? s : null;
363
+ }
364
+ let tempEnvSeq = 0;
365
+ /** PS expr: encode a string so SETVALS records can stay newline-delimited. */
366
+ export function encodeSetValExpr(srcExpr) {
367
+ return ('([string](' +
368
+ srcExpr +
369
+ ')).Replace([string][char]92, ([string][char]92 + [string][char]92)).Replace([string][char]13, ([string][char]92 + [char]114)).Replace([string][char]10, ([string][char]92 + [char]110))');
370
+ }
371
+ /**
372
+ * Apply env assignments (and optional unsets) only for `body`, then restore.
373
+ * All assignment *values* are evaluated before any name is mutated.
374
+ * `persistWords` are evaluated after the prefix is applied (so
375
+ * `export "$NAME"` sees the current env) and those names are not restored.
376
+ */
377
+ export function wrapTempEnv(sets, body, extra) {
378
+ const unsets = extra?.unsets ?? [];
379
+ const persistNames = extra?.persistNames ?? new Set();
380
+ const persistWords = extra?.persistWords;
381
+ const names = [];
382
+ const seen = new Set();
383
+ for (const s of sets) {
384
+ if (!seen.has(s.name)) {
385
+ seen.add(s.name);
386
+ names.push(s.name);
387
+ }
388
+ }
389
+ for (const u of unsets) {
390
+ if (!seen.has(u)) {
391
+ seen.add(u);
392
+ names.push(u);
393
+ }
394
+ }
395
+ if (names.length === 0)
396
+ return body;
397
+ const assigned = new Set(sets.map((s) => s.name));
398
+ const id = tempEnvSeq++;
399
+ const save = '$fx_es' + id;
400
+ const arrSave = '$fx_ar' + id;
401
+ const keep = persistWords && persistWords.length > 0 ? '$fx_ek' + id : '';
402
+ const lines = [
403
+ save + ' = @{}',
404
+ arrSave + ' = @{}',
405
+ '$fx_sv0' + id + ' = $env:FAUXNIX_SETVARS',
406
+ '$fx_uv0' + id + ' = $env:FAUXNIX_UNSETVARS',
407
+ '$fx_xv0' + id + ' = $env:FAUXNIX_SETVALS',
408
+ ];
409
+ for (const n of names) {
410
+ const p = psStr('Env:\\' + n);
411
+ lines.push(save +
412
+ '[' +
413
+ psStr(n) +
414
+ '] = $(if (Test-Path -LiteralPath ' +
415
+ p +
416
+ ') { [string](Get-Item -LiteralPath ' +
417
+ p +
418
+ ').Value } else { $null })');
419
+ lines.push(arrSave + '[' + psStr(n) + '] = (fx-arrpackget ' + psStr(n) + ')');
420
+ }
421
+ const valVars = [];
422
+ for (let i = 0; i < sets.length; i++) {
423
+ const vn = '$fx_ev' + id + '_' + i;
424
+ valVars.push(vn);
425
+ lines.push(vn + ' = ' + exprOfWord(sets[i].value, { preserveCmdSub: true }));
426
+ }
427
+ lines.push('try {');
428
+ for (const u of unsets) {
429
+ const uq = u.replace(/'/g, "''");
430
+ lines.push(' Remove-Item -LiteralPath ' + psStr('Env:\\' + u) + ' -ErrorAction SilentlyContinue');
431
+ lines.push(' fx-arrdrop ' + psStr(u));
432
+ // `env -u NAME` must hide NAME from fx-envget / fx-isset for the
433
+ // wrapped body. Removing Env:\NAME is not enough: an earlier
434
+ // `export NAME=x` still lives in SETVARS/SETVALS, and special
435
+ // names (PATH, HOME, …) have hardcoded fallbacks.
436
+ lines.push(" $env:FAUXNIX_SETVARS = (@($env:FAUXNIX_SETVARS -split ';' | Where-Object { $_ -ne '' -and $_ -cne '" +
437
+ uq +
438
+ "' }) -join ';')");
439
+ lines.push(" $env:FAUXNIX_UNSETVARS = ((@($env:FAUXNIX_UNSETVARS -split ';' | Where-Object { $_ -ne '' -and $_ -cne '" +
440
+ uq +
441
+ "' }) + '" +
442
+ uq +
443
+ "') -join ';')");
444
+ lines.push(' $fx_sm = @(); foreach ($fx_pair in @($env:FAUXNIX_SETVALS -split [string][char]10)) { $fx_eq = $fx_pair.IndexOf([char]61); if ($fx_eq -lt 1) { continue }; if ($fx_pair.Substring(0, $fx_eq) -cne \'' +
445
+ uq +
446
+ '\') { $fx_sm += $fx_pair } }; $env:FAUXNIX_SETVALS = ($fx_sm -join [string][char]10)');
447
+ }
448
+ for (let i = 0; i < sets.length; i++) {
449
+ const n = sets[i].name;
450
+ const nq = n.replace(/'/g, "''");
451
+ lines.push(' $env:' + n + ' = ' + valVars[i]);
452
+ lines.push(' fx-arrdrop ' + psStr(n));
453
+ lines.push(" $env:FAUXNIX_SETVARS = ((@($env:FAUXNIX_SETVARS -split ';' | Where-Object { $_ -ne '' -and $_ -cne '" +
454
+ nq +
455
+ "' }) + '" +
456
+ nq +
457
+ "') -join ';')");
458
+ lines.push(" $env:FAUXNIX_UNSETVARS = (@($env:FAUXNIX_UNSETVARS -split ';' | Where-Object { $_ -ne '' -and $_ -cne '" +
459
+ nq +
460
+ "' }) -join ';')");
461
+ lines.push(' $fx_sm = @(); foreach ($fx_pair in @($env:FAUXNIX_SETVALS -split [string][char]10)) { $fx_eq = $fx_pair.IndexOf([char]61); if ($fx_eq -lt 1) { continue }; if ($fx_pair.Substring(0, $fx_eq) -cne \'' +
462
+ nq +
463
+ '\') { $fx_sm += $fx_pair } }; $fx_sm += (\'' +
464
+ nq +
465
+ "' + [string][char]61 + " +
466
+ encodeSetValExpr(valVars[i]) +
467
+ '); $env:FAUXNIX_SETVALS = ($fx_sm -join [string][char]10)');
468
+ }
469
+ if (keep) {
470
+ lines.push(' ' + keep + ' = @{}');
471
+ for (const w of persistWords) {
472
+ const ev = '$fx_en' + id + '_' + lines.length;
473
+ lines.push(' ' + ev + ' = [string](' + exprOfWord(w) + ')');
474
+ lines.push(' if (' +
475
+ ev +
476
+ " -notmatch '^-') { $fx_nm = if (" +
477
+ ev +
478
+ ".Contains([string][char]61)) { " +
479
+ ev +
480
+ '.Substring(0, ' +
481
+ ev +
482
+ ".IndexOf([string][char]61)) } else { " +
483
+ ev +
484
+ " }; if ($fx_nm -match '^[A-Za-z_][A-Za-z0-9_]*$') { " +
485
+ keep +
486
+ '[$fx_nm] = $true } }');
487
+ }
488
+ }
489
+ for (const l of body.split('\n'))
490
+ lines.push(l ? ' ' + l : l);
491
+ lines.push('} finally {');
492
+ lines.push(' $env:FAUXNIX_SETVARS = $fx_sv0' + id);
493
+ lines.push(' $env:FAUXNIX_UNSETVARS = $fx_uv0' + id);
494
+ lines.push(' $env:FAUXNIX_SETVALS = $fx_xv0' + id);
495
+ for (const n of names) {
496
+ if (persistNames.has(n))
497
+ continue;
498
+ const p = psStr('Env:\\' + n);
499
+ if (keep) {
500
+ lines.push(' $fx_skip = if (' + keep + '[' + psStr(n) + ']) { $true } else { $false }');
501
+ lines.push(' if (-not $fx_skip) { if ($null -eq ' +
502
+ save +
503
+ '[' +
504
+ psStr(n) +
505
+ ']) { Remove-Item -LiteralPath ' +
506
+ p +
507
+ ' -ErrorAction SilentlyContinue } else { Set-Item -LiteralPath ' +
508
+ p +
509
+ ' -Value ' +
510
+ save +
511
+ '[' +
512
+ psStr(n) +
513
+ '] } }');
514
+ }
515
+ else {
516
+ lines.push(' if ($null -eq ' +
517
+ save +
518
+ '[' +
519
+ psStr(n) +
520
+ ']) { Remove-Item -LiteralPath ' +
521
+ p +
522
+ ' -ErrorAction SilentlyContinue } else { Set-Item -LiteralPath ' +
523
+ p +
524
+ ' -Value ' +
525
+ save +
526
+ '[' +
527
+ psStr(n) +
528
+ '] }');
529
+ }
530
+ const restoreArr = 'fx-arrpackset ' + psStr(n) + ' ' + arrSave + '[' + psStr(n) + ']';
531
+ if (keep) {
532
+ lines.push(' if (-not $fx_skip) { ' + restoreArr + ' }');
533
+ }
534
+ else {
535
+ lines.push(' ' + restoreArr);
536
+ }
537
+ }
538
+ for (const n of persistNames) {
539
+ const nq = n.replace(/'/g, "''");
540
+ const ep = psStr('Env:\\' + n);
541
+ // Bare `export UNSET` only marks the name for export; it must stay
542
+ // unset (`[[ -v UNSET ]]` is false). Persist a record only when the
543
+ // prefix assigned the name (including empty) or it already exists.
544
+ const cond = assigned.has(n) ? '$true' : '(Test-Path -LiteralPath ' + ep + ')';
545
+ lines.push(' if (' + cond + ') {');
546
+ lines.push(" $env:FAUXNIX_SETVARS = ((@($env:FAUXNIX_SETVARS -split ';' | Where-Object { $_ -ne '' -and $_ -cne '" +
547
+ nq +
548
+ "' }) + '" +
549
+ nq +
550
+ "') -join ';')");
551
+ lines.push(" $env:FAUXNIX_UNSETVARS = (@($env:FAUXNIX_UNSETVARS -split ';' | Where-Object { $_ -ne '' -and $_ -cne '" +
552
+ nq +
553
+ "' }) -join ';')");
554
+ lines.push(' $fx_pv = $(if (Test-Path -LiteralPath ' +
555
+ ep +
556
+ ') { [string](Get-Item -LiteralPath ' +
557
+ ep +
558
+ ").Value } else { '' })");
559
+ lines.push(' $fx_sm = @(); foreach ($fx_pair in @($env:FAUXNIX_SETVALS -split [string][char]10)) { $fx_eq = $fx_pair.IndexOf([char]61); if ($fx_eq -lt 1) { continue }; if ($fx_pair.Substring(0, $fx_eq) -cne \'' +
560
+ nq +
561
+ '\') { $fx_sm += $fx_pair } }; $fx_sm += (\'' +
562
+ nq +
563
+ "' + [string][char]61 + " +
564
+ encodeSetValExpr('$fx_pv') +
565
+ '); $env:FAUXNIX_SETVALS = ($fx_sm -join [string][char]10)');
566
+ lines.push(' }');
567
+ }
568
+ if (keep) {
569
+ const had = '@(' +
570
+ [...assigned].map((n) => "'" + n.replace(/'/g, "''") + "'").join(',') +
571
+ ')';
572
+ lines.push(' foreach ($fx_pn in @(' + keep + '.Keys)) {');
573
+ lines.push(' if (-not ((Test-Path -LiteralPath (\'Env:\\\' + $fx_pn)) -or (' +
574
+ had +
575
+ ' -ccontains $fx_pn))) { continue }');
576
+ lines.push(" $env:FAUXNIX_SETVARS = ((@($env:FAUXNIX_SETVARS -split ';' | Where-Object { $_ -ne '' -and $_ -cne $fx_pn }) + $fx_pn) -join ';')");
577
+ lines.push(" $env:FAUXNIX_UNSETVARS = (@($env:FAUXNIX_UNSETVARS -split ';' | Where-Object { $_ -ne '' -and $_ -cne $fx_pn }) -join ';')");
578
+ lines.push(" $fx_pv = $(if (Test-Path -LiteralPath ('Env:\\' + $fx_pn)) { [string](Get-Item -LiteralPath ('Env:\\' + $fx_pn)).Value } else { '' })");
579
+ lines.push(' $fx_sm = @(); foreach ($fx_pair in @($env:FAUXNIX_SETVALS -split [string][char]10)) { $fx_eq = $fx_pair.IndexOf([char]61); if ($fx_eq -lt 1) { continue }; if ($fx_pair.Substring(0, $fx_eq) -cne $fx_pn) { $fx_sm += $fx_pair } }; $fx_sm += ($fx_pn + [string][char]61 + ' +
580
+ encodeSetValExpr('$fx_pv') +
581
+ '); $env:FAUXNIX_SETVALS = ($fx_sm -join [string][char]10)');
582
+ lines.push(' }');
583
+ }
584
+ lines.push('}');
585
+ return lines.join('\n');
586
+ }
209
587
  /** Unique suffix for generated stage functions (nested pipelines included). */
210
588
  let stageSeq = 0;
211
589
  /**
@@ -302,6 +680,144 @@ export function wrapScript(body) {
302
680
  " if ($parts[$parts.Count - 1] -eq '') { $parts = $parts[0..($parts.Count - 2)] }",
303
681
  ' return $parts',
304
682
  '}',
683
+ 'function fx-csub([scriptblock]$b) {',
684
+ ' $fx_prevcs = $script:fx_csub',
685
+ ' $script:fx_csub = $true',
686
+ ' try { $fx_o = @(& $b | ForEach-Object { [string]$_ }) }',
687
+ ' finally { $script:fx_csub = $fx_prevcs }',
688
+ ' $fx_s = ($fx_o -join [string][char]10)',
689
+ ' while ($fx_s.Length -gt 0 -and $fx_s[$fx_s.Length - 1] -eq [char]10) {',
690
+ ' $fx_s = $fx_s.Substring(0, $fx_s.Length - 1)',
691
+ ' }',
692
+ ' return $fx_s',
693
+ '}',
694
+ 'function fx-svenc($s) {',
695
+ ' return ([string]$s).Replace([string][char]92, ([string][char]92 + [string][char]92)).Replace([string][char]13, ([string][char]92 + [char]114)).Replace([string][char]10, ([string][char]92 + [char]110))',
696
+ '}',
697
+ 'function fx-svdec($s) {',
698
+ ' $s = [string]$s',
699
+ ' $sb = New-Object System.Text.StringBuilder',
700
+ ' $i = 0',
701
+ ' while ($i -lt $s.Length) {',
702
+ ' $c = $s[$i]',
703
+ ' if ($c -eq [char]92 -and ($i + 1) -lt $s.Length) {',
704
+ ' $n2 = $s[$i + 1]',
705
+ ' if ($n2 -eq [char]110) { [void]$sb.Append([char]10); $i += 2; continue }',
706
+ ' if ($n2 -eq [char]114) { [void]$sb.Append([char]13); $i += 2; continue }',
707
+ ' if ($n2 -eq [char]92) { [void]$sb.Append([char]92); $i += 2; continue }',
708
+ ' }',
709
+ ' [void]$sb.Append($c)',
710
+ ' $i++',
711
+ ' }',
712
+ ' return [string]$sb',
713
+ '}',
714
+ 'function fx-arrload($n) {',
715
+ ' $n = [string]$n',
716
+ ' foreach ($fx_pair in @($env:FAUXNIX_ARRS -split [string][char]10)) {',
717
+ ' $fx_eq = $fx_pair.IndexOf([char]61)',
718
+ ' if ($fx_eq -lt 1) { continue }',
719
+ ' if ($fx_pair.Substring(0, $fx_eq) -cne $n) { continue }',
720
+ ' $out = @()',
721
+ ' foreach ($el in @($fx_pair.Substring($fx_eq + 1) -split [string][char]30)) { $out += ,(fx-svdec $el) }',
722
+ ' return $out',
723
+ ' }',
724
+ ' $s0 = fx-scalar0 $n',
725
+ ' if ($null -eq $s0) { return @() }',
726
+ ' return @([string]$s0)',
727
+ '}',
728
+ 'function fx-scalar0($n) {',
729
+ ' $n = [string]$n',
730
+ " if (@($env:FAUXNIX_UNSETVARS -split ';' | Where-Object { $_ -ceq $n }).Count -gt 0) { return $null }",
731
+ ' foreach ($fx_pair in @($env:FAUXNIX_SETVALS -split [string][char]10)) {',
732
+ ' $fx_eq = $fx_pair.IndexOf([char]61)',
733
+ ' if ($fx_eq -lt 1) { continue }',
734
+ ' if ($fx_pair.Substring(0, $fx_eq) -ceq $n) { return (fx-svdec $fx_pair.Substring($fx_eq + 1)) }',
735
+ ' }',
736
+ " if ($n -ceq 'HOME') { return [string]$HOME }",
737
+ " if ($n -ceq 'PWD') { return [string]$PWD.Path }",
738
+ " if ($n -ceq 'USER' -or $n -ceq 'LOGNAME') { return [string]$env:USERNAME }",
739
+ " if ($n -ceq 'PATH') { return [string]$env:PATH }",
740
+ " if ($n -ceq 'SHELL') { return 'powershell' }",
741
+ " if ($n -ceq 'TERM') { return 'xterm-256color' }",
742
+ " if ($n -ceq 'OLDPWD') { return $(if ($env:FAUXNIX_OLDPWD) { [string]$env:FAUXNIX_OLDPWD } else { $null }) }",
743
+ " if ($n -ceq 'HOSTNAME') { return [string]$env:COMPUTERNAME }",
744
+ ' $ev = Get-ChildItem Env: | Where-Object { $_.Name -ceq $n } | Select-Object -First 1',
745
+ ' if ($ev) { return [string]$ev.Value }',
746
+ ' return $null',
747
+ '}',
748
+ 'function fx-ifs1 {',
749
+ " $s = fx-scalar0 'IFS'",
750
+ " if ($null -eq $s) { return ' ' }",
751
+ " if ([string]$s -eq '') { return '' }",
752
+ ' return [string]$s[0]',
753
+ '}',
754
+ 'function fx-arrdrop($n) {',
755
+ ' $n = [string]$n',
756
+ ' $fx_sm = @()',
757
+ ' foreach ($fx_pair in @($env:FAUXNIX_ARRS -split [string][char]10)) {',
758
+ ' $fx_eq = $fx_pair.IndexOf([char]61)',
759
+ ' if ($fx_eq -lt 1) { continue }',
760
+ ' if ($fx_pair.Substring(0, $fx_eq) -cne $n) { $fx_sm += $fx_pair }',
761
+ ' }',
762
+ ' $env:FAUXNIX_ARRS = ($fx_sm -join [string][char]10)',
763
+ '}',
764
+ 'function fx-arrhas($n) {',
765
+ ' $n = [string]$n',
766
+ ' foreach ($fx_pair in @($env:FAUXNIX_ARRS -split [string][char]10)) {',
767
+ ' $fx_eq = $fx_pair.IndexOf([char]61)',
768
+ ' if ($fx_eq -lt 1) { continue }',
769
+ ' if ($fx_pair.Substring(0, $fx_eq) -ceq $n) { return $true }',
770
+ ' }',
771
+ ' return $false',
772
+ '}',
773
+ 'function fx-arrpackget($n) {',
774
+ ' $n = [string]$n',
775
+ ' foreach ($fx_pair in @($env:FAUXNIX_ARRS -split [string][char]10)) {',
776
+ ' $fx_eq = $fx_pair.IndexOf([char]61)',
777
+ ' if ($fx_eq -lt 1) { continue }',
778
+ ' if ($fx_pair.Substring(0, $fx_eq) -ceq $n) { return $fx_pair.Substring($fx_eq + 1) }',
779
+ ' }',
780
+ ' return $null',
781
+ '}',
782
+ 'function fx-arrpackset($n, $pay) {',
783
+ ' fx-arrdrop $n',
784
+ ' if ($null -eq $pay) { return }',
785
+ " $env:FAUXNIX_ARRS = ((@($env:FAUXNIX_ARRS -split [string][char]10 | Where-Object { $_ -ne '' }) + ([string]$n + [string][char]61 + [string]$pay)) -join [string][char]10)",
786
+ '}',
787
+ 'function fx-arrput($n, $vals) {',
788
+ ' $n = [string]$n',
789
+ ' $vals = @($vals)',
790
+ ' fx-arrdrop $n',
791
+ ' if ($vals.Count -eq 0) { } else {',
792
+ ' $encs = @(); foreach ($v in $vals) { $encs += (fx-svenc $v) }',
793
+ " $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)",
794
+ ' }',
795
+ " $fx_0 = $(if ($vals.Count -gt 0) { [string]$vals[0] } else { '' })",
796
+ ' Set-Item -LiteralPath (\'Env:\\\' + $n) -Value $fx_0',
797
+ " $env:FAUXNIX_SETVARS = ((@($env:FAUXNIX_SETVARS -split ';' | Where-Object { $_ -ne '' -and $_ -cne $n }) + $n) -join ';')",
798
+ " $env:FAUXNIX_UNSETVARS = (@($env:FAUXNIX_UNSETVARS -split ';' | Where-Object { $_ -ne '' -and $_ -cne $n }) -join ';')",
799
+ ' $fx_sv = @(); foreach ($fx_pair in @($env:FAUXNIX_SETVALS -split [string][char]10)) { $fx_eq = $fx_pair.IndexOf([char]61); if ($fx_eq -lt 1) { continue }; if ($fx_pair.Substring(0, $fx_eq) -cne $n) { $fx_sv += $fx_pair } }',
800
+ " $fx_sv += ($n + [string][char]61 + (fx-svenc $fx_0)); $env:FAUXNIX_SETVALS = ($fx_sv -join [string][char]10)",
801
+ '}',
802
+ 'function fx-arrclr($n) {',
803
+ ' $n = [string]$n',
804
+ ' fx-arrdrop $n',
805
+ " Remove-Item -LiteralPath ('Env:\\' + $n) -ErrorAction SilentlyContinue",
806
+ " $env:FAUXNIX_SETVARS = (@($env:FAUXNIX_SETVARS -split ';' | Where-Object { $_ -ne '' -and $_ -cne $n }) -join ';')",
807
+ " $env:FAUXNIX_UNSETVARS = ((@($env:FAUXNIX_UNSETVARS -split ';' | Where-Object { $_ -ne '' -and $_ -cne $n }) + $n) -join ';')",
808
+ ' $fx_sv = @(); foreach ($fx_pair in @($env:FAUXNIX_SETVALS -split [string][char]10)) { $fx_eq = $fx_pair.IndexOf([char]61); if ($fx_eq -lt 1) { continue }; if ($fx_pair.Substring(0, $fx_eq) -cne $n) { $fx_sv += $fx_pair } }; $env:FAUXNIX_SETVALS = ($fx_sv -join [string][char]10)',
809
+ '}',
810
+ 'function fx-subget($n, $ix) {',
811
+ ' $arr = @(fx-arrload $n)',
812
+ ' $ix = [string]$ix',
813
+ // argv-level `@` is expanded by argListExpr; this is the scalar/quoted-* join.
814
+ " if ($ix -eq '*') { return ($arr -join (fx-ifs1)) }",
815
+ " if ($ix -eq '@') { return ($arr -join (fx-ifs1)) }",
816
+ ' $i = 0',
817
+ ' if (-not [int]::TryParse($ix, [ref]$i)) { return \'\' }',
818
+ " if ($i -lt 0 -or $i -ge $arr.Count) { return '' }",
819
+ ' return [string]$arr[$i]',
820
+ '}',
305
821
  'try {',
306
822
  ...body.split('\n').map((l) => ' ' + l),
307
823
  '} catch [System.Management.Automation.CommandNotFoundException] {',