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.
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
  }
@@ -181,7 +200,11 @@ function readDollar(input, i) {
181
200
  if (end === -1)
182
201
  throw new FauxnixParseError('fauxnix: unclosed ${');
183
202
  const name = input.slice(j + 1, end);
184
- if (!isNameStart(name[0]) || !name.split('').every(isNameChar)) {
203
+ const sub = name.match(/^([A-Za-z_][A-Za-z0-9_]*)\[([0-9]+|@|\*)\]$/);
204
+ if (sub) {
205
+ return { part: { kind: 'Var', name: sub[1], index: sub[2] }, next: end + 1 };
206
+ }
207
+ if (!name || !isNameStart(name[0]) || !name.split('').every(isNameChar)) {
185
208
  // ${VAR:-default} etc. — unsupported, kept as raw text
186
209
  return { part: { kind: 'Text', text: input.slice(i, end + 1) }, next: end + 1 };
187
210
  }
@@ -226,6 +249,228 @@ function readDollar(input, i) {
226
249
  }
227
250
  return null;
228
251
  }
252
+ function hasBareAmp(w) {
253
+ let depth = 0;
254
+ for (const p of w) {
255
+ if (p.kind !== 'Text' || p.escaped)
256
+ continue;
257
+ for (const c of p.text) {
258
+ if (c === '(')
259
+ depth++;
260
+ else if (c === ')' && depth > 0)
261
+ depth--;
262
+ else if (c === '&' && depth === 0)
263
+ return true;
264
+ }
265
+ }
266
+ return false;
267
+ }
268
+ function looksLikeArithWord(w) {
269
+ const s = wordToString(w);
270
+ return (/^[0-9a-fA-FxX#+\-*/%()<>&=!~|^?,: \t]+$/.test(s) && /[+\-*/%<>&=!~|^?]/.test(s));
271
+ }
272
+ function hasBareNonExtglobParen(w) {
273
+ const chars = [];
274
+ for (const p of w) {
275
+ if (p.kind === 'Text' && !p.escaped) {
276
+ for (const c of p.text)
277
+ chars.push(c);
278
+ }
279
+ else {
280
+ chars.push('\0');
281
+ }
282
+ }
283
+ for (let i = 0; i < chars.length; i++) {
284
+ if (chars[i] !== '(')
285
+ continue;
286
+ const prev = i > 0 ? chars[i - 1] : '';
287
+ if (prev !== '@' && prev !== '*' && prev !== '?' && prev !== '+' && prev !== '!')
288
+ return true;
289
+ }
290
+ return false;
291
+ }
292
+ function regexHasExtraClose(w) {
293
+ let depth = 0;
294
+ for (const p of w) {
295
+ if (p.kind !== 'Text' || p.escaped)
296
+ continue;
297
+ for (const c of p.text) {
298
+ if (c === '(')
299
+ depth++;
300
+ else if (c === ')') {
301
+ if (depth === 0)
302
+ return true;
303
+ depth--;
304
+ }
305
+ }
306
+ }
307
+ return false;
308
+ }
309
+ function unmatchedOpenParen(w) {
310
+ let depth = 0;
311
+ for (const p of w) {
312
+ if (p.kind !== 'Text' || p.escaped)
313
+ continue;
314
+ for (const c of p.text) {
315
+ if (c === '(')
316
+ depth++;
317
+ else if (c === ')' && depth > 0)
318
+ depth--;
319
+ }
320
+ }
321
+ return depth > 0;
322
+ }
323
+ /** True when `w` has an unclosed unquoted `@(…)`, `+(…)`, `*(…)`, `?(…)`, or `!(…)`. */
324
+ function unmatchedExtglob(w) {
325
+ const chars = [];
326
+ for (const p of w) {
327
+ if (p.kind === 'Text' && !p.escaped) {
328
+ for (const c of p.text)
329
+ chars.push(c);
330
+ }
331
+ else {
332
+ chars.push('\0');
333
+ }
334
+ }
335
+ let depth = 0;
336
+ for (let i = 0; i < chars.length; i++) {
337
+ const c = chars[i];
338
+ if ((c === '@' || c === '*' || c === '?' || c === '+' || c === '!') &&
339
+ chars[i + 1] === '(') {
340
+ depth++;
341
+ i++;
342
+ continue;
343
+ }
344
+ if (c === ')' && depth > 0)
345
+ depth--;
346
+ }
347
+ return depth > 0;
348
+ }
349
+ /**
350
+ * Peel grouping `(` / `)` that bash tokenizes even without spaces,
351
+ * but leave extglob `@(…)` / `!(foo)bar` intact.
352
+ */
353
+ function splitCondParens(w) {
354
+ const leading = [];
355
+ const trailing = [];
356
+ let rest = w.map((p) => (p.kind === 'Text' ? { ...p } : p));
357
+ for (;;) {
358
+ if (rest.length === 0)
359
+ break;
360
+ const p = rest[0];
361
+ if (p.kind !== 'Text' || p.escaped || !p.text.startsWith('('))
362
+ break;
363
+ leading.push([{ kind: 'Text', text: '(' }]);
364
+ rest =
365
+ p.text.length === 1
366
+ ? rest.slice(1)
367
+ : [{ kind: 'Text', text: p.text.slice(1), escaped: p.escaped }, ...rest.slice(1)];
368
+ }
369
+ for (;;) {
370
+ if (rest.length === 0)
371
+ break;
372
+ const last = rest[rest.length - 1];
373
+ if (last.kind !== 'Text' || last.escaped || !last.text.endsWith(')'))
374
+ break;
375
+ const without = last.text.length === 1
376
+ ? rest.slice(0, -1)
377
+ : [
378
+ ...rest.slice(0, -1),
379
+ { kind: 'Text', text: last.text.slice(0, -1), escaped: last.escaped },
380
+ ];
381
+ if (unmatchedExtglob(without) || unmatchedOpenParen(without))
382
+ break;
383
+ trailing.unshift([{ kind: 'Text', text: ')' }]);
384
+ rest = without;
385
+ }
386
+ const mid = rest.length > 0 ? [rest] : [];
387
+ return [...leading, ...mid, ...trailing];
388
+ }
389
+ function groupingDepth(args) {
390
+ let d = 0;
391
+ for (const w of args) {
392
+ if (isUnquotedLiteral(w, '('))
393
+ d++;
394
+ else if (isUnquotedLiteral(w, ')'))
395
+ d--;
396
+ }
397
+ return d;
398
+ }
399
+ /** Peel trailing grouping `)` off a `=~` operand when a `(` is still open. */
400
+ function peelTrailingGroupCloses(w, max) {
401
+ if (max <= 0)
402
+ return w.length ? [w] : [];
403
+ const { rest, trailing } = (() => {
404
+ const trailing = [];
405
+ let rest = w.map((p) => (p.kind === 'Text' ? { ...p } : p));
406
+ while (trailing.length < max) {
407
+ if (rest.length === 0)
408
+ break;
409
+ const last = rest[rest.length - 1];
410
+ if (last.kind !== 'Text' || last.escaped || !last.text.endsWith(')'))
411
+ break;
412
+ const without = last.text.length === 1
413
+ ? rest.slice(0, -1)
414
+ : [
415
+ ...rest.slice(0, -1),
416
+ { kind: 'Text', text: last.text.slice(0, -1), escaped: last.escaped },
417
+ ];
418
+ if (unmatchedOpenParen(without))
419
+ break;
420
+ rest = without;
421
+ trailing.unshift([{ kind: 'Text', text: ')' }]);
422
+ }
423
+ return { rest, trailing };
424
+ })();
425
+ const mid = rest.length > 0 ? [rest] : [];
426
+ return [...mid, ...trailing];
427
+ }
428
+ function canNlInsideDblBracket(args) {
429
+ if (args.length === 0)
430
+ return true;
431
+ const last = args[args.length - 1];
432
+ if (isUnquotedLiteral(last, '&&') ||
433
+ isUnquotedLiteral(last, '||') ||
434
+ isUnquotedLiteral(last, '!') ||
435
+ isUnquotedLiteral(last, '('))
436
+ return true;
437
+ if (isUnquotedLiteral(last, '=~') ||
438
+ isUnquotedLiteral(last, '==') ||
439
+ isUnquotedLiteral(last, '=') ||
440
+ isUnquotedLiteral(last, '!=') ||
441
+ isUnquotedLiteral(last, '-e') ||
442
+ isUnquotedLiteral(last, '-a') ||
443
+ isUnquotedLiteral(last, '-f') ||
444
+ isUnquotedLiteral(last, '-d') ||
445
+ isUnquotedLiteral(last, '-r') ||
446
+ isUnquotedLiteral(last, '-w') ||
447
+ isUnquotedLiteral(last, '-x') ||
448
+ isUnquotedLiteral(last, '-s') ||
449
+ isUnquotedLiteral(last, '-z') ||
450
+ isUnquotedLiteral(last, '-n') ||
451
+ isUnquotedLiteral(last, '-L') ||
452
+ isUnquotedLiteral(last, '-h') ||
453
+ isUnquotedLiteral(last, '-v'))
454
+ return false;
455
+ if (args.length === 1)
456
+ return false;
457
+ return true;
458
+ }
459
+ /** Most recent unquoted `=~` / `==` / `=` / `!=` still open in this `[[`. */
460
+ function pendingPatternOp(args) {
461
+ for (let i = args.length - 1; i >= 0; i--) {
462
+ const w = args[i];
463
+ if (isUnquotedLiteral(w, '=~'))
464
+ return '=~';
465
+ if (isUnquotedLiteral(w, '==') ||
466
+ isUnquotedLiteral(w, '=') ||
467
+ isUnquotedLiteral(w, '!='))
468
+ return '==';
469
+ if (isUnquotedLiteral(w, '&&') || isUnquotedLiteral(w, '||'))
470
+ return null;
471
+ }
472
+ return null;
473
+ }
229
474
  /* ------------------------------------------------------------------ */
230
475
  /* Parser */
231
476
  /* ------------------------------------------------------------------ */
@@ -237,17 +482,18 @@ export function parseCommand(input) {
237
482
  const parseList = () => {
238
483
  const segments = [];
239
484
  let op = ';';
240
- while (peek().type === 'OP' && peek().op === ';')
485
+ const isListSep = (o) => o === ';' || o === '\n';
486
+ while (peek().type === 'OP' && isListSep(peek().op))
241
487
  next();
242
488
  while (peek().type !== 'EOF') {
243
489
  const pipeline = parsePipeline();
244
490
  segments.push({ pipeline, op });
245
491
  const t = peek();
246
- if (t.type === 'OP' && (t.op === '&&' || t.op === '||' || t.op === ';')) {
247
- op = t.op;
492
+ if (t.type === 'OP' && (t.op === '&&' || t.op === '||' || isListSep(t.op))) {
493
+ op = t.op === '\n' ? ';' : t.op;
248
494
  next();
249
- while (peek().type === 'OP' && peek().op === ';')
250
- next(); // trailing / duplicate ;
495
+ while (peek().type === 'OP' && isListSep(peek().op))
496
+ next();
251
497
  }
252
498
  else if (t.type === 'EOF') {
253
499
  break;
@@ -283,6 +529,95 @@ export function parseCommand(input) {
283
529
  if (t.type === 'EOF')
284
530
  break;
285
531
  // possible redirect operator
532
+ // Inside `[[ ... ]]`, && || < > and other redirect-shaped tokens are
533
+ // conditional operators (or just words), not shell redirects/lists.
534
+ // Stop this special case at the first *unquoted* ]].
535
+ if (t.type === 'OP' &&
536
+ name !== null &&
537
+ isUnquotedLiteral(name, '[[') &&
538
+ !args.some((w) => isUnquotedLiteral(w, ']]')) &&
539
+ (t.op === '&&' ||
540
+ t.op === '||' ||
541
+ t.op === '|' ||
542
+ t.op === '>' ||
543
+ t.op === '<' ||
544
+ t.op === '>>' ||
545
+ t.op === '2>' ||
546
+ t.op === '2>>' ||
547
+ t.op === '&>' ||
548
+ t.op === '&>>' ||
549
+ t.op === '2>&1' ||
550
+ t.op === '1>&2')) {
551
+ next();
552
+ // Glue `|` onto the surrounding words so `=~ ^a|z$`,
553
+ // `=~ (a | b)c`, and `== @(x | y)` stay one operand. A
554
+ // spaced `|` outside an open regex / extglob group is a
555
+ // syntax error (bash).
556
+ const last = args.length ? args[args.length - 1] : null;
557
+ const lastIsEqTilde = last !== null && isUnquotedLiteral(last, '=~');
558
+ const prevIsEqTilde = args.length >= 2 && isUnquotedLiteral(args[args.length - 2], '=~');
559
+ const openRe = last !== null &&
560
+ pendingPatternOp(args) === '=~' &&
561
+ unmatchedOpenParen(last);
562
+ const openExt = last !== null &&
563
+ pendingPatternOp(args) === '==' &&
564
+ unmatchedExtglob(last);
565
+ const inRe = lastIsEqTilde || prevIsEqTilde || openRe;
566
+ if (t.op === '|' || ((inRe || openExt) && t.tightLeft && t.op === '||')) {
567
+ if (t.op === '|' &&
568
+ !lastIsEqTilde &&
569
+ !(prevIsEqTilde && t.tightLeft) &&
570
+ !openRe &&
571
+ !openExt) {
572
+ throw new FauxnixParseError("fauxnix: [[: syntax error near unexpected token `|'");
573
+ }
574
+ const piece = t.tightLeft || lastIsEqTilde
575
+ ? [{ kind: 'Text', text: t.op }]
576
+ : [{ kind: 'Text', text: ' ' }, { kind: 'Text', text: t.op }];
577
+ if (lastIsEqTilde)
578
+ args.push(piece);
579
+ else
580
+ args[args.length - 1] = [...args[args.length - 1], ...piece];
581
+ const n = peek();
582
+ if (n.type === 'WORD' && n.tightLeft && n.parts) {
583
+ next();
584
+ args[args.length - 1] = [...args[args.length - 1], ...n.parts];
585
+ }
586
+ }
587
+ else if (t.op === '&&' || t.op === '||' || t.op === '>' || t.op === '<') {
588
+ if (openRe && last) {
589
+ const piece = t.tightLeft
590
+ ? [{ kind: 'Text', text: t.op }]
591
+ : [{ kind: 'Text', text: ' ' }, { kind: 'Text', text: t.op }];
592
+ args[args.length - 1] = [...last, ...piece];
593
+ const n = peek();
594
+ if (n.type === 'WORD' && n.tightLeft && n.parts) {
595
+ next();
596
+ args[args.length - 1] = [...args[args.length - 1], ...n.parts];
597
+ }
598
+ }
599
+ else {
600
+ args.push([{ kind: 'Text', text: t.op }]);
601
+ if (t.op === '&&' || t.op === '||') {
602
+ while (peek().type === 'OP' && peek().op === '\n')
603
+ next();
604
+ }
605
+ }
606
+ }
607
+ else {
608
+ throw new FauxnixParseError("fauxnix: [[: syntax error near unexpected token `" + t.op + "'");
609
+ }
610
+ continue;
611
+ }
612
+ if (t.type === 'OP' &&
613
+ t.op === '\n' &&
614
+ name !== null &&
615
+ isUnquotedLiteral(name, '[[') &&
616
+ !args.some((w) => isUnquotedLiteral(w, ']]')) &&
617
+ canNlInsideDblBracket(args)) {
618
+ next();
619
+ continue;
620
+ }
286
621
  if (t.type === 'OP' && isRedirectOp(t.op)) {
287
622
  const op = t.op;
288
623
  next();
@@ -308,12 +643,86 @@ export function parseCommand(input) {
308
643
  next();
309
644
  }
310
645
  else {
311
- args.push(word);
646
+ if (isUnquotedLiteral(name, '[[') && !args.some((a) => isUnquotedLiteral(a, ']]'))) {
647
+ const pending = pendingPatternOp(args);
648
+ const last = args.length ? args[args.length - 1] : null;
649
+ // bash keeps `( x )` / `@(foo|bar baz)` as one =~ / extglob operand
650
+ if (last &&
651
+ !isUnquotedLiteral(word, ']]') &&
652
+ !isUnquotedLiteral(word, '&&') &&
653
+ !isUnquotedLiteral(word, '||') &&
654
+ ((pending === '=~' && unmatchedOpenParen(last)) ||
655
+ (pending === '==' && unmatchedExtglob(last)))) {
656
+ const glued = [...last, { kind: 'Text', text: ' ' }, ...word];
657
+ const pieces = pending === '=~'
658
+ ? peelTrailingGroupCloses(glued, groupingDepth(args.slice(0, -1)))
659
+ : splitCondParens(glued);
660
+ args.pop();
661
+ for (const sw of pieces) {
662
+ if (sw.length > 0)
663
+ args.push(sw);
664
+ }
665
+ next();
666
+ continue;
667
+ }
668
+ if (hasBareAmp(word) && !(pending === '=~' && last && unmatchedOpenParen(last))) {
669
+ throw new FauxnixParseError("fauxnix: [[: syntax error near unexpected token `&'");
670
+ }
671
+ if (pending === '==' && hasBareNonExtglobParen(word)) {
672
+ throw new FauxnixParseError("fauxnix: [[: syntax error near unexpected token `('");
673
+ }
674
+ const pieces = pending === '=~'
675
+ ? peelTrailingGroupCloses(word, groupingDepth(args))
676
+ : splitCondParens(word);
677
+ if (pending !== '=~') {
678
+ for (const sw of pieces) {
679
+ if (!isUnquotedLiteral(sw, '(') &&
680
+ !isUnquotedLiteral(sw, ')') &&
681
+ hasBareNonExtglobParen(sw) &&
682
+ !looksLikeArithWord(sw)) {
683
+ throw new FauxnixParseError("fauxnix: [[: syntax error near unexpected token `('");
684
+ }
685
+ }
686
+ }
687
+ for (const sw of pieces) {
688
+ if (sw.length > 0)
689
+ args.push(sw);
690
+ }
691
+ }
692
+ else {
693
+ args.push(word);
694
+ }
312
695
  next();
313
696
  }
314
697
  }
315
- if (!name)
698
+ if (!name) {
699
+ // assignment-only segment (`X=1; cmd`) — no command word
700
+ if (assignments.length > 0) {
701
+ return { kind: 'SimpleCommand', assignments, name: null, args: [], redirects };
702
+ }
316
703
  throw new FauxnixParseError('fauxnix: expected a command');
704
+ }
705
+ if (isUnquotedLiteral(name, '[[')) {
706
+ const close = args.findIndex((w) => isUnquotedLiteral(w, ']]'));
707
+ if (close < 0)
708
+ throw new FauxnixParseError("fauxnix: [[: missing `]]'");
709
+ if (close !== args.length - 1) {
710
+ throw new FauxnixParseError("fauxnix: [[: syntax error near unexpected token after `]]'");
711
+ }
712
+ if (args.slice(0, close).some((w) => unmatchedExtglob(w))) {
713
+ throw new FauxnixParseError('fauxnix: [[: syntax error in conditional expression');
714
+ }
715
+ for (let i = 0; i < close; i++) {
716
+ if (!isUnquotedLiteral(args[i], '=~') || i + 1 >= close)
717
+ continue;
718
+ if (regexHasExtraClose(args[i + 1])) {
719
+ throw new FauxnixParseError("fauxnix: [[: syntax error near unexpected token `)'");
720
+ }
721
+ if (unmatchedOpenParen(args[i + 1])) {
722
+ throw new FauxnixParseError('fauxnix: [[: syntax error in conditional expression: unexpected end of file');
723
+ }
724
+ }
725
+ }
317
726
  return { kind: 'SimpleCommand', assignments, name, args, redirects };
318
727
  };
319
728
  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
- export declare function varExpr(name: string): string;
4
+ export declare function varExpr(name: string, index?: 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
  /**
@@ -15,7 +17,9 @@ export declare function pathExpr(s: string): string;
15
17
  * Literal words become single-quoted strings; dynamic ones become
16
18
  * double-quoted strings with $(...) interpolation.
17
19
  */
18
- export declare function exprOfWord(w: Word): string;
20
+ export declare function exprOfWord(w: Word, opts?: {
21
+ preserveCmdSub?: boolean;
22
+ }): string;
19
23
  /** Literal text of a word when it contains no interpolation, else null. */
20
24
  export declare function literalOfWord(w: Word): string | null;
21
25
  /**
@@ -23,9 +27,39 @@ export declare function literalOfWord(w: Word): string | null;
23
27
  * Literal paths get POSIX-ish normalization (/dev/null, /tmp, /d/...).
24
28
  */
25
29
  export declare function operandExpr(w: Word): string;
26
- /** Translate the inside of $(...) — pipelines only, no wrappers. */
27
- export declare function translateCmdSub(cmdText: string): string;
30
+ /**
31
+ * `${name[@]}` / `"pre${name[@]}post"` — one argv per element.
32
+ * Unquoted `${name[*]}` also splats (bash); quoted `"${name[*]}"` stays one join.
33
+ */
34
+ export declare function splatSpec(w: Word): {
35
+ name: string;
36
+ prefix: string;
37
+ suffix: string;
38
+ } | null;
39
+ /** PS expression of a string[]: `@` words splat, others stay one element. */
40
+ export declare function argListExpr(words: Word[], fn?: (w: Word) => string): string;
41
+ /**
42
+ * Translate the inside of $(...).
43
+ * `keepNl`: quoted words and assignments keep interior newlines (bash).
44
+ * Unquoted command words join non-empty lines with a space (IFS
45
+ * word-split approximation). Handlers often emit one string object, so
46
+ * a bare `$(…)` interpolation would keep those newlines.
47
+ */
48
+ export declare function translateCmdSub(cmdText: string, keepNl?: boolean): string;
28
49
  export declare function translateSimple(cmd: SimpleCommand, position: PipelineCtx['position'], hasStdin: boolean): string;
50
+ /** PS expr: encode a string so SETVALS records can stay newline-delimited. */
51
+ export declare function encodeSetValExpr(srcExpr: string): string;
52
+ /**
53
+ * Apply env assignments (and optional unsets) only for `body`, then restore.
54
+ * All assignment *values* are evaluated before any name is mutated.
55
+ * `persistWords` are evaluated after the prefix is applied (so
56
+ * `export "$NAME"` sees the current env) and those names are not restored.
57
+ */
58
+ export declare function wrapTempEnv(sets: Assignment[], body: string, extra?: {
59
+ unsets?: string[];
60
+ persistNames?: Set<string>;
61
+ persistWords?: Word[];
62
+ }): string;
29
63
  export interface PipelineParts {
30
64
  /** Generated function definitions (empty for single commands). */
31
65
  defs: string;