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