fauxnix-cli 0.2.1 → 0.3.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,4 +1,4 @@
1
- import { FauxnixParseError, } from './ast.js';
1
+ import { FauxnixParseError, isUnquotedLiteral, wordToString, } from './ast.js';
2
2
  const OPERATORS = [
3
3
  '&&', '||', '>>', '<<', '2>&1', '1>&2', '2>', '&>>', '&>', '>', '<', '|', ';',
4
4
  ];
@@ -6,28 +6,37 @@ export function tokenize(input) {
6
6
  const tokens = [];
7
7
  let i = 0;
8
8
  const n = input.length;
9
- const pushWord = (parts) => {
9
+ const pushWord = (parts, tightLeft) => {
10
10
  if (parts.length > 0)
11
- tokens.push({ type: 'WORD', parts });
11
+ tokens.push({ type: 'WORD', parts, tightLeft });
12
12
  };
13
13
  let cur = [];
14
14
  let fdDigits = '';
15
+ let lastWasWs = true;
16
+ let wordTightLeft = false;
15
17
  const flush = () => {
16
- pushWord(cur);
18
+ pushWord(cur, wordTightLeft);
17
19
  cur = [];
18
20
  fdDigits = '';
19
21
  };
22
+ const beginWordPart = () => {
23
+ if (cur.length === 0)
24
+ wordTightLeft = !lastWasWs;
25
+ lastWasWs = false;
26
+ };
20
27
  while (i < n) {
21
28
  const ch = input[i];
22
29
  // whitespace separates words; newline acts as ';'
23
30
  if (ch === ' ' || ch === '\t' || ch === '\r') {
24
31
  flush();
32
+ lastWasWs = true;
25
33
  i++;
26
34
  continue;
27
35
  }
28
36
  if (ch === '\n') {
29
37
  flush();
30
- tokens.push({ type: 'OP', op: ';' });
38
+ tokens.push({ type: 'OP', op: '\n', tightLeft: false });
39
+ lastWasWs = true;
31
40
  i++;
32
41
  continue;
33
42
  }
@@ -76,8 +85,10 @@ export function tokenize(input) {
76
85
  if (matched === '<<') {
77
86
  throw new FauxnixParseError('fauxnix: heredocs (<<) are not supported yet. Pass the text via echo pipe or a temp file instead.');
78
87
  }
88
+ const tightLeft = !lastWasWs;
79
89
  flush();
80
- tokens.push({ type: 'OP', op: matched });
90
+ tokens.push({ type: 'OP', op: matched, tightLeft });
91
+ lastWasWs = false;
81
92
  i += advance;
82
93
  continue;
83
94
  }
@@ -86,12 +97,14 @@ export function tokenize(input) {
86
97
  const end = input.indexOf("'", i + 1);
87
98
  if (end === -1)
88
99
  throw new FauxnixParseError('fauxnix: unclosed single quote');
100
+ beginWordPart();
89
101
  cur.push({ kind: 'SingleQuoted', text: input.slice(i + 1, end) });
90
102
  i = end + 1;
91
103
  continue;
92
104
  }
93
105
  // double quotes — interpolated
94
106
  if (ch === '"') {
107
+ beginWordPart();
95
108
  i++;
96
109
  const parts = [];
97
110
  let buf = '';
@@ -129,10 +142,12 @@ export function tokenize(input) {
129
142
  if (ch === '$') {
130
143
  const v = readDollar(input, i);
131
144
  if (v) {
145
+ beginWordPart();
132
146
  cur.push(v.part);
133
147
  i = v.next;
134
148
  continue;
135
149
  }
150
+ beginWordPart();
136
151
  cur.push({ kind: 'Text', text: '$' });
137
152
  i++;
138
153
  continue;
@@ -140,20 +155,24 @@ export function tokenize(input) {
140
155
  if (ch === '`') {
141
156
  throw new FauxnixParseError('fauxnix: backticks are not supported. Use $(...) command substitution instead.');
142
157
  }
143
- // escape outside quotes
158
+ // escape outside quotes — keep the escape so [[ =~ ]] / == can
159
+ // treat `\*` as a literal rather than a metacharacter
144
160
  if (ch === '\\' && i + 1 < n) {
145
- cur.push({ kind: 'Text', text: input[i + 1] });
161
+ beginWordPart();
162
+ cur.push({ kind: 'Text', text: input[i + 1], escaped: true });
146
163
  i += 2;
147
164
  continue;
148
165
  }
149
166
  // track leading digits (potential fd number for redirects)
150
167
  if (/[0-9]/.test(ch) && cur.length === 0 && fdDigits.length < 2) {
168
+ beginWordPart();
151
169
  fdDigits += ch;
152
170
  cur.push({ kind: 'Text', text: ch });
153
171
  i++;
154
172
  continue;
155
173
  }
156
174
  fdDigits = '';
175
+ beginWordPart();
157
176
  cur.push({ kind: 'Text', text: ch });
158
177
  i++;
159
178
  }
@@ -226,6 +245,228 @@ function readDollar(input, i) {
226
245
  }
227
246
  return null;
228
247
  }
248
+ function hasBareAmp(w) {
249
+ let depth = 0;
250
+ for (const p of w) {
251
+ if (p.kind !== 'Text' || p.escaped)
252
+ continue;
253
+ for (const c of p.text) {
254
+ if (c === '(')
255
+ depth++;
256
+ else if (c === ')' && depth > 0)
257
+ depth--;
258
+ else if (c === '&' && depth === 0)
259
+ return true;
260
+ }
261
+ }
262
+ return false;
263
+ }
264
+ function looksLikeArithWord(w) {
265
+ const s = wordToString(w);
266
+ return (/^[0-9a-fA-FxX#+\-*/%()<>&=!~|^?,: \t]+$/.test(s) && /[+\-*/%<>&=!~|^?]/.test(s));
267
+ }
268
+ function hasBareNonExtglobParen(w) {
269
+ const chars = [];
270
+ for (const p of w) {
271
+ if (p.kind === 'Text' && !p.escaped) {
272
+ for (const c of p.text)
273
+ chars.push(c);
274
+ }
275
+ else {
276
+ chars.push('\0');
277
+ }
278
+ }
279
+ for (let i = 0; i < chars.length; i++) {
280
+ if (chars[i] !== '(')
281
+ continue;
282
+ const prev = i > 0 ? chars[i - 1] : '';
283
+ if (prev !== '@' && prev !== '*' && prev !== '?' && prev !== '+' && prev !== '!')
284
+ return true;
285
+ }
286
+ return false;
287
+ }
288
+ function regexHasExtraClose(w) {
289
+ let depth = 0;
290
+ for (const p of w) {
291
+ if (p.kind !== 'Text' || p.escaped)
292
+ continue;
293
+ for (const c of p.text) {
294
+ if (c === '(')
295
+ depth++;
296
+ else if (c === ')') {
297
+ if (depth === 0)
298
+ return true;
299
+ depth--;
300
+ }
301
+ }
302
+ }
303
+ return false;
304
+ }
305
+ function unmatchedOpenParen(w) {
306
+ let depth = 0;
307
+ for (const p of w) {
308
+ if (p.kind !== 'Text' || p.escaped)
309
+ continue;
310
+ for (const c of p.text) {
311
+ if (c === '(')
312
+ depth++;
313
+ else if (c === ')' && depth > 0)
314
+ depth--;
315
+ }
316
+ }
317
+ return depth > 0;
318
+ }
319
+ /** True when `w` has an unclosed unquoted `@(…)`, `+(…)`, `*(…)`, `?(…)`, or `!(…)`. */
320
+ function unmatchedExtglob(w) {
321
+ const chars = [];
322
+ for (const p of w) {
323
+ if (p.kind === 'Text' && !p.escaped) {
324
+ for (const c of p.text)
325
+ chars.push(c);
326
+ }
327
+ else {
328
+ chars.push('\0');
329
+ }
330
+ }
331
+ let depth = 0;
332
+ for (let i = 0; i < chars.length; i++) {
333
+ const c = chars[i];
334
+ if ((c === '@' || c === '*' || c === '?' || c === '+' || c === '!') &&
335
+ chars[i + 1] === '(') {
336
+ depth++;
337
+ i++;
338
+ continue;
339
+ }
340
+ if (c === ')' && depth > 0)
341
+ depth--;
342
+ }
343
+ return depth > 0;
344
+ }
345
+ /**
346
+ * Peel grouping `(` / `)` that bash tokenizes even without spaces,
347
+ * but leave extglob `@(…)` / `!(foo)bar` intact.
348
+ */
349
+ function splitCondParens(w) {
350
+ const leading = [];
351
+ const trailing = [];
352
+ let rest = w.map((p) => (p.kind === 'Text' ? { ...p } : p));
353
+ for (;;) {
354
+ if (rest.length === 0)
355
+ break;
356
+ const p = rest[0];
357
+ if (p.kind !== 'Text' || p.escaped || !p.text.startsWith('('))
358
+ break;
359
+ leading.push([{ kind: 'Text', text: '(' }]);
360
+ rest =
361
+ p.text.length === 1
362
+ ? rest.slice(1)
363
+ : [{ kind: 'Text', text: p.text.slice(1), escaped: p.escaped }, ...rest.slice(1)];
364
+ }
365
+ for (;;) {
366
+ if (rest.length === 0)
367
+ break;
368
+ const last = rest[rest.length - 1];
369
+ if (last.kind !== 'Text' || last.escaped || !last.text.endsWith(')'))
370
+ break;
371
+ const without = last.text.length === 1
372
+ ? rest.slice(0, -1)
373
+ : [
374
+ ...rest.slice(0, -1),
375
+ { kind: 'Text', text: last.text.slice(0, -1), escaped: last.escaped },
376
+ ];
377
+ if (unmatchedExtglob(without) || unmatchedOpenParen(without))
378
+ break;
379
+ trailing.unshift([{ kind: 'Text', text: ')' }]);
380
+ rest = without;
381
+ }
382
+ const mid = rest.length > 0 ? [rest] : [];
383
+ return [...leading, ...mid, ...trailing];
384
+ }
385
+ function groupingDepth(args) {
386
+ let d = 0;
387
+ for (const w of args) {
388
+ if (isUnquotedLiteral(w, '('))
389
+ d++;
390
+ else if (isUnquotedLiteral(w, ')'))
391
+ d--;
392
+ }
393
+ return d;
394
+ }
395
+ /** Peel trailing grouping `)` off a `=~` operand when a `(` is still open. */
396
+ function peelTrailingGroupCloses(w, max) {
397
+ if (max <= 0)
398
+ return w.length ? [w] : [];
399
+ const { rest, trailing } = (() => {
400
+ const trailing = [];
401
+ let rest = w.map((p) => (p.kind === 'Text' ? { ...p } : p));
402
+ while (trailing.length < max) {
403
+ if (rest.length === 0)
404
+ break;
405
+ const last = rest[rest.length - 1];
406
+ if (last.kind !== 'Text' || last.escaped || !last.text.endsWith(')'))
407
+ break;
408
+ const without = last.text.length === 1
409
+ ? rest.slice(0, -1)
410
+ : [
411
+ ...rest.slice(0, -1),
412
+ { kind: 'Text', text: last.text.slice(0, -1), escaped: last.escaped },
413
+ ];
414
+ if (unmatchedOpenParen(without))
415
+ break;
416
+ rest = without;
417
+ trailing.unshift([{ kind: 'Text', text: ')' }]);
418
+ }
419
+ return { rest, trailing };
420
+ })();
421
+ const mid = rest.length > 0 ? [rest] : [];
422
+ return [...mid, ...trailing];
423
+ }
424
+ function canNlInsideDblBracket(args) {
425
+ if (args.length === 0)
426
+ return true;
427
+ const last = args[args.length - 1];
428
+ if (isUnquotedLiteral(last, '&&') ||
429
+ isUnquotedLiteral(last, '||') ||
430
+ isUnquotedLiteral(last, '!') ||
431
+ isUnquotedLiteral(last, '('))
432
+ return true;
433
+ if (isUnquotedLiteral(last, '=~') ||
434
+ isUnquotedLiteral(last, '==') ||
435
+ isUnquotedLiteral(last, '=') ||
436
+ isUnquotedLiteral(last, '!=') ||
437
+ isUnquotedLiteral(last, '-e') ||
438
+ isUnquotedLiteral(last, '-a') ||
439
+ isUnquotedLiteral(last, '-f') ||
440
+ isUnquotedLiteral(last, '-d') ||
441
+ isUnquotedLiteral(last, '-r') ||
442
+ isUnquotedLiteral(last, '-w') ||
443
+ isUnquotedLiteral(last, '-x') ||
444
+ isUnquotedLiteral(last, '-s') ||
445
+ isUnquotedLiteral(last, '-z') ||
446
+ isUnquotedLiteral(last, '-n') ||
447
+ isUnquotedLiteral(last, '-L') ||
448
+ isUnquotedLiteral(last, '-h') ||
449
+ isUnquotedLiteral(last, '-v'))
450
+ return false;
451
+ if (args.length === 1)
452
+ return false;
453
+ return true;
454
+ }
455
+ /** Most recent unquoted `=~` / `==` / `=` / `!=` still open in this `[[`. */
456
+ function pendingPatternOp(args) {
457
+ for (let i = args.length - 1; i >= 0; i--) {
458
+ const w = args[i];
459
+ if (isUnquotedLiteral(w, '=~'))
460
+ return '=~';
461
+ if (isUnquotedLiteral(w, '==') ||
462
+ isUnquotedLiteral(w, '=') ||
463
+ isUnquotedLiteral(w, '!='))
464
+ return '==';
465
+ if (isUnquotedLiteral(w, '&&') || isUnquotedLiteral(w, '||'))
466
+ return null;
467
+ }
468
+ return null;
469
+ }
229
470
  /* ------------------------------------------------------------------ */
230
471
  /* Parser */
231
472
  /* ------------------------------------------------------------------ */
@@ -237,17 +478,18 @@ export function parseCommand(input) {
237
478
  const parseList = () => {
238
479
  const segments = [];
239
480
  let op = ';';
240
- while (peek().type === 'OP' && peek().op === ';')
481
+ const isListSep = (o) => o === ';' || o === '\n';
482
+ while (peek().type === 'OP' && isListSep(peek().op))
241
483
  next();
242
484
  while (peek().type !== 'EOF') {
243
485
  const pipeline = parsePipeline();
244
486
  segments.push({ pipeline, op });
245
487
  const t = peek();
246
- if (t.type === 'OP' && (t.op === '&&' || t.op === '||' || t.op === ';')) {
247
- op = t.op;
488
+ if (t.type === 'OP' && (t.op === '&&' || t.op === '||' || isListSep(t.op))) {
489
+ op = t.op === '\n' ? ';' : t.op;
248
490
  next();
249
- while (peek().type === 'OP' && peek().op === ';')
250
- next(); // trailing / duplicate ;
491
+ while (peek().type === 'OP' && isListSep(peek().op))
492
+ next();
251
493
  }
252
494
  else if (t.type === 'EOF') {
253
495
  break;
@@ -283,6 +525,95 @@ export function parseCommand(input) {
283
525
  if (t.type === 'EOF')
284
526
  break;
285
527
  // possible redirect operator
528
+ // Inside `[[ ... ]]`, && || < > and other redirect-shaped tokens are
529
+ // conditional operators (or just words), not shell redirects/lists.
530
+ // Stop this special case at the first *unquoted* ]].
531
+ if (t.type === 'OP' &&
532
+ name !== null &&
533
+ isUnquotedLiteral(name, '[[') &&
534
+ !args.some((w) => isUnquotedLiteral(w, ']]')) &&
535
+ (t.op === '&&' ||
536
+ t.op === '||' ||
537
+ t.op === '|' ||
538
+ t.op === '>' ||
539
+ t.op === '<' ||
540
+ t.op === '>>' ||
541
+ t.op === '2>' ||
542
+ t.op === '2>>' ||
543
+ t.op === '&>' ||
544
+ t.op === '&>>' ||
545
+ t.op === '2>&1' ||
546
+ t.op === '1>&2')) {
547
+ next();
548
+ // Glue `|` onto the surrounding words so `=~ ^a|z$`,
549
+ // `=~ (a | b)c`, and `== @(x | y)` stay one operand. A
550
+ // spaced `|` outside an open regex / extglob group is a
551
+ // syntax error (bash).
552
+ const last = args.length ? args[args.length - 1] : null;
553
+ const lastIsEqTilde = last !== null && isUnquotedLiteral(last, '=~');
554
+ const prevIsEqTilde = args.length >= 2 && isUnquotedLiteral(args[args.length - 2], '=~');
555
+ const openRe = last !== null &&
556
+ pendingPatternOp(args) === '=~' &&
557
+ unmatchedOpenParen(last);
558
+ const openExt = last !== null &&
559
+ pendingPatternOp(args) === '==' &&
560
+ unmatchedExtglob(last);
561
+ const inRe = lastIsEqTilde || prevIsEqTilde || openRe;
562
+ if (t.op === '|' || ((inRe || openExt) && t.tightLeft && t.op === '||')) {
563
+ if (t.op === '|' &&
564
+ !lastIsEqTilde &&
565
+ !(prevIsEqTilde && t.tightLeft) &&
566
+ !openRe &&
567
+ !openExt) {
568
+ throw new FauxnixParseError("fauxnix: [[: syntax error near unexpected token `|'");
569
+ }
570
+ const piece = t.tightLeft || lastIsEqTilde
571
+ ? [{ kind: 'Text', text: t.op }]
572
+ : [{ kind: 'Text', text: ' ' }, { kind: 'Text', text: t.op }];
573
+ if (lastIsEqTilde)
574
+ args.push(piece);
575
+ else
576
+ args[args.length - 1] = [...args[args.length - 1], ...piece];
577
+ const n = peek();
578
+ if (n.type === 'WORD' && n.tightLeft && n.parts) {
579
+ next();
580
+ args[args.length - 1] = [...args[args.length - 1], ...n.parts];
581
+ }
582
+ }
583
+ else if (t.op === '&&' || t.op === '||' || t.op === '>' || t.op === '<') {
584
+ if (openRe && last) {
585
+ const piece = t.tightLeft
586
+ ? [{ kind: 'Text', text: t.op }]
587
+ : [{ kind: 'Text', text: ' ' }, { kind: 'Text', text: t.op }];
588
+ args[args.length - 1] = [...last, ...piece];
589
+ const n = peek();
590
+ if (n.type === 'WORD' && n.tightLeft && n.parts) {
591
+ next();
592
+ args[args.length - 1] = [...args[args.length - 1], ...n.parts];
593
+ }
594
+ }
595
+ else {
596
+ args.push([{ kind: 'Text', text: t.op }]);
597
+ if (t.op === '&&' || t.op === '||') {
598
+ while (peek().type === 'OP' && peek().op === '\n')
599
+ next();
600
+ }
601
+ }
602
+ }
603
+ else {
604
+ throw new FauxnixParseError("fauxnix: [[: syntax error near unexpected token `" + t.op + "'");
605
+ }
606
+ continue;
607
+ }
608
+ if (t.type === 'OP' &&
609
+ t.op === '\n' &&
610
+ name !== null &&
611
+ isUnquotedLiteral(name, '[[') &&
612
+ !args.some((w) => isUnquotedLiteral(w, ']]')) &&
613
+ canNlInsideDblBracket(args)) {
614
+ next();
615
+ continue;
616
+ }
286
617
  if (t.type === 'OP' && isRedirectOp(t.op)) {
287
618
  const op = t.op;
288
619
  next();
@@ -308,12 +639,86 @@ export function parseCommand(input) {
308
639
  next();
309
640
  }
310
641
  else {
311
- args.push(word);
642
+ if (isUnquotedLiteral(name, '[[') && !args.some((a) => isUnquotedLiteral(a, ']]'))) {
643
+ const pending = pendingPatternOp(args);
644
+ const last = args.length ? args[args.length - 1] : null;
645
+ // bash keeps `( x )` / `@(foo|bar baz)` as one =~ / extglob operand
646
+ if (last &&
647
+ !isUnquotedLiteral(word, ']]') &&
648
+ !isUnquotedLiteral(word, '&&') &&
649
+ !isUnquotedLiteral(word, '||') &&
650
+ ((pending === '=~' && unmatchedOpenParen(last)) ||
651
+ (pending === '==' && unmatchedExtglob(last)))) {
652
+ const glued = [...last, { kind: 'Text', text: ' ' }, ...word];
653
+ const pieces = pending === '=~'
654
+ ? peelTrailingGroupCloses(glued, groupingDepth(args.slice(0, -1)))
655
+ : splitCondParens(glued);
656
+ args.pop();
657
+ for (const sw of pieces) {
658
+ if (sw.length > 0)
659
+ args.push(sw);
660
+ }
661
+ next();
662
+ continue;
663
+ }
664
+ if (hasBareAmp(word) && !(pending === '=~' && last && unmatchedOpenParen(last))) {
665
+ throw new FauxnixParseError("fauxnix: [[: syntax error near unexpected token `&'");
666
+ }
667
+ if (pending === '==' && hasBareNonExtglobParen(word)) {
668
+ throw new FauxnixParseError("fauxnix: [[: syntax error near unexpected token `('");
669
+ }
670
+ const pieces = pending === '=~'
671
+ ? peelTrailingGroupCloses(word, groupingDepth(args))
672
+ : splitCondParens(word);
673
+ if (pending !== '=~') {
674
+ for (const sw of pieces) {
675
+ if (!isUnquotedLiteral(sw, '(') &&
676
+ !isUnquotedLiteral(sw, ')') &&
677
+ hasBareNonExtglobParen(sw) &&
678
+ !looksLikeArithWord(sw)) {
679
+ throw new FauxnixParseError("fauxnix: [[: syntax error near unexpected token `('");
680
+ }
681
+ }
682
+ }
683
+ for (const sw of pieces) {
684
+ if (sw.length > 0)
685
+ args.push(sw);
686
+ }
687
+ }
688
+ else {
689
+ args.push(word);
690
+ }
312
691
  next();
313
692
  }
314
693
  }
315
- if (!name)
694
+ if (!name) {
695
+ // assignment-only segment (`X=1; cmd`) — no command word
696
+ if (assignments.length > 0) {
697
+ return { kind: 'SimpleCommand', assignments, name: null, args: [], redirects };
698
+ }
316
699
  throw new FauxnixParseError('fauxnix: expected a command');
700
+ }
701
+ if (isUnquotedLiteral(name, '[[')) {
702
+ const close = args.findIndex((w) => isUnquotedLiteral(w, ']]'));
703
+ if (close < 0)
704
+ throw new FauxnixParseError("fauxnix: [[: missing `]]'");
705
+ if (close !== args.length - 1) {
706
+ throw new FauxnixParseError("fauxnix: [[: syntax error near unexpected token after `]]'");
707
+ }
708
+ if (args.slice(0, close).some((w) => unmatchedExtglob(w))) {
709
+ throw new FauxnixParseError('fauxnix: [[: syntax error in conditional expression');
710
+ }
711
+ for (let i = 0; i < close; i++) {
712
+ if (!isUnquotedLiteral(args[i], '=~') || i + 1 >= close)
713
+ continue;
714
+ if (regexHasExtraClose(args[i + 1])) {
715
+ throw new FauxnixParseError("fauxnix: [[: syntax error near unexpected token `)'");
716
+ }
717
+ if (unmatchedOpenParen(args[i + 1])) {
718
+ throw new FauxnixParseError('fauxnix: [[: syntax error in conditional expression: unexpected end of file');
719
+ }
720
+ }
721
+ }
317
722
  return { kind: 'SimpleCommand', assignments, name, args, redirects };
318
723
  };
319
724
  const readRedirectTarget = () => {
package/dist/registry.js CHANGED
@@ -18,6 +18,16 @@ export function registeredNames() {
18
18
  /* ------------------------------------------------------------------ */
19
19
  /** Escape a JS string into a single-quoted PowerShell string literal. */
20
20
  export function psStr(s) {
21
+ if (/[\r\n]/.test(s)) {
22
+ return ('"' +
23
+ s
24
+ .replace(/`/g, '``')
25
+ .replace(/"/g, '`"')
26
+ .replace(/\$/g, '`$')
27
+ .replace(/\r/g, '`r')
28
+ .replace(/\n/g, '`n') +
29
+ '"');
30
+ }
21
31
  return "'" + s.replace(/'/g, "''") + "'";
22
32
  }
23
33
  /** bash-style stderr line + exit-flag, as PS statements. */
@@ -1,7 +1,9 @@
1
- import { CommandList, Redirect, SimpleCommand, Word } from './ast.js';
1
+ import { Assignment, CommandList, Redirect, SimpleCommand, Word } from './ast.js';
2
2
  import { PipelineCtx } from './registry.js';
3
3
  /** Map a bash $VAR name to a PowerShell expression (usable inside $(...)). */
4
4
  export declare function varExpr(name: string): string;
5
+ /** Escape text destined for the inside of a PS double-quoted string. */
6
+ export declare function escapeDq(s: string): string;
5
7
  /** Normalize a literal POSIX-ish path to its Windows equivalent. */
6
8
  export declare function normalizeLiteralPath(s: string): string;
7
9
  /**
@@ -26,6 +28,19 @@ export declare function operandExpr(w: Word): string;
26
28
  /** Translate the inside of $(...) — pipelines only, no wrappers. */
27
29
  export declare function translateCmdSub(cmdText: string): string;
28
30
  export declare function translateSimple(cmd: SimpleCommand, position: PipelineCtx['position'], hasStdin: boolean): string;
31
+ /** PS expr: encode a string so SETVALS records can stay newline-delimited. */
32
+ export declare function encodeSetValExpr(srcExpr: string): string;
33
+ /**
34
+ * Apply env assignments (and optional unsets) only for `body`, then restore.
35
+ * All assignment *values* are evaluated before any name is mutated.
36
+ * `persistWords` are evaluated after the prefix is applied (so
37
+ * `export "$NAME"` sees the current env) and those names are not restored.
38
+ */
39
+ export declare function wrapTempEnv(sets: Assignment[], body: string, extra?: {
40
+ unsets?: string[];
41
+ persistNames?: Set<string>;
42
+ persistWords?: Word[];
43
+ }): string;
29
44
  export interface PipelineParts {
30
45
  /** Generated function definitions (empty for single commands). */
31
46
  defs: string;