tarsec 0.5.1 → 0.5.2
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/parsers/bash/index.d.ts +47 -50
- package/dist/parsers/bash/index.js +47 -51
- package/dist/parsers/bash/parsers.d.ts +95 -0
- package/dist/parsers/bash/parsers.js +306 -0
- package/dist/parsers/bash/types.d.ts +59 -110
- package/dist/parsers/bash/types.js +3 -1
- package/package.json +1 -1
- package/dist/parsers/bash/commands.d.ts +0 -11
- package/dist/parsers/bash/commands.js +0 -197
- package/dist/parsers/bash/lexemes.d.ts +0 -27
- package/dist/parsers/bash/lexemes.js +0 -44
- package/dist/parsers/bash/lists.d.ts +0 -18
- package/dist/parsers/bash/lists.js +0 -85
- package/dist/parsers/bash/words.d.ts +0 -22
- package/dist/parsers/bash/words.js +0 -112
|
@@ -1,57 +1,54 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* A
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* A deliberately small bash parser: a narrow subset of bash that is either
|
|
3
|
+
* parsed correctly or rejected, never silently mis-parsed. Intended for
|
|
4
|
+
* contexts where acting on a wrong parse is worse than rejecting a command
|
|
5
|
+
* outright — an unsupported construct costs you a rejection, not a wrong
|
|
6
|
+
* answer.
|
|
6
7
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* 1. Separators between commands are MANDATORY (`;`, `&`, or newline),
|
|
10
|
-
* so text left over by a construct becomes a parse error instead of a
|
|
11
|
-
* phantom second command.
|
|
12
|
-
* 2. No permissive fallbacks: a `$`, backtick, or `{`/`}` that isn't part
|
|
13
|
-
* of a supported construct fails the parse instead of degrading to a
|
|
14
|
-
* literal.
|
|
15
|
-
*
|
|
16
|
-
* Supported: words with single/double quoting, escapes, `$var`, `${...}`,
|
|
17
|
-
* recursive `$(...)`, `$((...))`; assignments; redirects (fd prefixes,
|
|
18
|
-
* `2>&1`, `>&2`, `<<<`); pipelines with `!`; `&&`/`||`; `&` background;
|
|
19
|
-
* `;`/newline separators; comments and line continuations; `if`/`elif`/
|
|
20
|
-
* `else`, `while`/`until`, `for`, `case`, subshells, `{ }` groups,
|
|
21
|
-
* `(( ))`, and both function-definition styles.
|
|
22
|
-
*
|
|
23
|
-
* Deliberately UNSUPPORTED (valid bash, rejected here — fail closed):
|
|
24
|
-
* arrays `x=(...)`, append `x+=`, ANSI-C `$'...'` / locale `$"..."`,
|
|
25
|
-
* backtick substitution (use `$()`), `[[ ]]` (its `]]` delimiter can
|
|
26
|
-
* appear inside quoted operands, so scanning for it is unsound; `[` is an
|
|
27
|
-
* ordinary command and still works), brace expansion `{a,b}` and literal
|
|
28
|
-
* `{`/`}` in words (quote them), bare `$` (write `\$` or '$'), heredocs,
|
|
29
|
-
* process substitution `<()`, `select`/`coproc`/`time`.
|
|
30
|
-
*
|
|
31
|
-
* Kept as RAW TEXT (delimiters are sound, contents are not interpreted):
|
|
32
|
-
* `${...}` (balanced braces), `$((...))` and `((...))` (balanced parens —
|
|
33
|
-
* quotes are not delimiters in bash arithmetic).
|
|
34
|
-
*
|
|
35
|
-
* Out of scope by design, as in bash's own parser: glob (`*`, `?`) and
|
|
36
|
-
* tilde expansion happen after parsing; consumers see the literal word
|
|
37
|
-
* text and decide for themselves.
|
|
38
|
-
*
|
|
39
|
-
* For error positions, use the standard tarsec flow:
|
|
8
|
+
* `bashParser` is the entry point. It parses a whole script and fails
|
|
9
|
+
* unless the entire input is consumed.
|
|
40
10
|
*
|
|
41
11
|
* ```ts
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* const result = bashParser(input);
|
|
45
|
-
* if (!result.success) console.log(getErrorMessage());
|
|
12
|
+
* const result = bashParser("git commit -m 'hi'");
|
|
13
|
+
* if (result.success) console.log(result.result);
|
|
46
14
|
* ```
|
|
15
|
+
*
|
|
16
|
+
* The AST is shaped for consumers asking "what command is this, and what
|
|
17
|
+
* does it operate on?" — a `SimpleCommand` carries `command`, `args`,
|
|
18
|
+
* `assignments` and `redirects` as separate fields. There is no
|
|
19
|
+
* `subcommands` field: no syntactic rule separates `git status` from
|
|
20
|
+
* `echo status`, so `args` is one ordered list.
|
|
21
|
+
*
|
|
22
|
+
* SUPPORTED
|
|
23
|
+
* - Simple commands: a name, arguments, and `name=value` assignments
|
|
24
|
+
* before it. A line that is only an assignment (`FOO=bar`) is a command
|
|
25
|
+
* with a null `command`.
|
|
26
|
+
* - Words: bare words (letters, digits, and `_ - . = : +`), paths
|
|
27
|
+
* (any word containing `/`), single and double quotes, `$var` and
|
|
28
|
+
* `${var}`, and `-f` / `--flag` / `--flag=value` flags.
|
|
29
|
+
* - Double quotes interpolate `$var` and handle `\"`, `\\`, `\$`, `` \` ``.
|
|
30
|
+
* - Words built from adjacent parts: `$HOME/bin`, `"a"b`, `"$HOME"/x`.
|
|
31
|
+
* - Redirects: `>`, `>>`, `<`, and `&>`, with an optional attached file
|
|
32
|
+
* descriptor on the first three (`2> err.txt`).
|
|
33
|
+
* - `&&` / `||` chains (one precedence level, left-associative, as in
|
|
34
|
+
* bash) and `( ... )` grouping.
|
|
35
|
+
* - Commands separated by `;` or newlines, including CRLF.
|
|
36
|
+
*
|
|
37
|
+
* REJECTED, and worth knowing because a reader would not predict them
|
|
38
|
+
* - Pipelines (`|`), background (`&`), and `;` inside `( ... )` — so
|
|
39
|
+
* `(a && b) || c` parses but `a && (b; c)` does not.
|
|
40
|
+
* - Reserved words ANYWHERE, not only at command position: `echo if` and
|
|
41
|
+
* `echo done` are valid bash and are rejected here.
|
|
42
|
+
* - `--` as an end-of-flags marker, so `git checkout -- file` is rejected,
|
|
43
|
+
* as is a bare `-` for stdin (`cat -`).
|
|
44
|
+
* - Compound commands: `if`, `for`, `while`, `case`, function definitions.
|
|
45
|
+
* - Command substitution `$(...)`, backticks, arithmetic `$((...))`,
|
|
46
|
+
* parameter expansion beyond `${name}` (`${x:-y}`), and the special
|
|
47
|
+
* parameters `$?` / `$@` / `$$`.
|
|
48
|
+
* - Globs (`*`, `?`, `[...]`), tilde (`~/x`), brace expansion, heredocs
|
|
49
|
+
* and here-strings, `2>&1`, comments (`#`), and escapes outside double
|
|
50
|
+
* quotes (`echo a\ b`).
|
|
51
|
+
* - Characters outside the word set above, including `@` and `%`.
|
|
47
52
|
*/
|
|
48
53
|
export * from "./types.js";
|
|
49
|
-
export * from "./
|
|
50
|
-
export * from "./words.js";
|
|
51
|
-
export * from "./commands.js";
|
|
52
|
-
export * from "./lists.js";
|
|
53
|
-
import { Parser } from "../../types.js";
|
|
54
|
-
import { List } from "./types.js";
|
|
55
|
-
/** Parse a whole script (or any command list) to a `List`. Fails unless
|
|
56
|
-
* the entire input is consumed. */
|
|
57
|
-
export declare const bashParser: Parser<List>;
|
|
54
|
+
export * from "./parsers.js";
|
|
@@ -1,58 +1,54 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* A
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* A deliberately small bash parser: a narrow subset of bash that is either
|
|
3
|
+
* parsed correctly or rejected, never silently mis-parsed. Intended for
|
|
4
|
+
* contexts where acting on a wrong parse is worse than rejecting a command
|
|
5
|
+
* outright — an unsupported construct costs you a rejection, not a wrong
|
|
6
|
+
* answer.
|
|
6
7
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* 1. Separators between commands are MANDATORY (`;`, `&`, or newline),
|
|
10
|
-
* so text left over by a construct becomes a parse error instead of a
|
|
11
|
-
* phantom second command.
|
|
12
|
-
* 2. No permissive fallbacks: a `$`, backtick, or `{`/`}` that isn't part
|
|
13
|
-
* of a supported construct fails the parse instead of degrading to a
|
|
14
|
-
* literal.
|
|
15
|
-
*
|
|
16
|
-
* Supported: words with single/double quoting, escapes, `$var`, `${...}`,
|
|
17
|
-
* recursive `$(...)`, `$((...))`; assignments; redirects (fd prefixes,
|
|
18
|
-
* `2>&1`, `>&2`, `<<<`); pipelines with `!`; `&&`/`||`; `&` background;
|
|
19
|
-
* `;`/newline separators; comments and line continuations; `if`/`elif`/
|
|
20
|
-
* `else`, `while`/`until`, `for`, `case`, subshells, `{ }` groups,
|
|
21
|
-
* `(( ))`, and both function-definition styles.
|
|
22
|
-
*
|
|
23
|
-
* Deliberately UNSUPPORTED (valid bash, rejected here — fail closed):
|
|
24
|
-
* arrays `x=(...)`, append `x+=`, ANSI-C `$'...'` / locale `$"..."`,
|
|
25
|
-
* backtick substitution (use `$()`), `[[ ]]` (its `]]` delimiter can
|
|
26
|
-
* appear inside quoted operands, so scanning for it is unsound; `[` is an
|
|
27
|
-
* ordinary command and still works), brace expansion `{a,b}` and literal
|
|
28
|
-
* `{`/`}` in words (quote them), bare `$` (write `\$` or '$'), heredocs,
|
|
29
|
-
* process substitution `<()`, `select`/`coproc`/`time`.
|
|
30
|
-
*
|
|
31
|
-
* Kept as RAW TEXT (delimiters are sound, contents are not interpreted):
|
|
32
|
-
* `${...}` (balanced braces), `$((...))` and `((...))` (balanced parens —
|
|
33
|
-
* quotes are not delimiters in bash arithmetic).
|
|
34
|
-
*
|
|
35
|
-
* Out of scope by design, as in bash's own parser: glob (`*`, `?`) and
|
|
36
|
-
* tilde expansion happen after parsing; consumers see the literal word
|
|
37
|
-
* text and decide for themselves.
|
|
38
|
-
*
|
|
39
|
-
* For error positions, use the standard tarsec flow:
|
|
8
|
+
* `bashParser` is the entry point. It parses a whole script and fails
|
|
9
|
+
* unless the entire input is consumed.
|
|
40
10
|
*
|
|
41
11
|
* ```ts
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* const result = bashParser(input);
|
|
45
|
-
* if (!result.success) console.log(getErrorMessage());
|
|
12
|
+
* const result = bashParser("git commit -m 'hi'");
|
|
13
|
+
* if (result.success) console.log(result.result);
|
|
46
14
|
* ```
|
|
15
|
+
*
|
|
16
|
+
* The AST is shaped for consumers asking "what command is this, and what
|
|
17
|
+
* does it operate on?" — a `SimpleCommand` carries `command`, `args`,
|
|
18
|
+
* `assignments` and `redirects` as separate fields. There is no
|
|
19
|
+
* `subcommands` field: no syntactic rule separates `git status` from
|
|
20
|
+
* `echo status`, so `args` is one ordered list.
|
|
21
|
+
*
|
|
22
|
+
* SUPPORTED
|
|
23
|
+
* - Simple commands: a name, arguments, and `name=value` assignments
|
|
24
|
+
* before it. A line that is only an assignment (`FOO=bar`) is a command
|
|
25
|
+
* with a null `command`.
|
|
26
|
+
* - Words: bare words (letters, digits, and `_ - . = : +`), paths
|
|
27
|
+
* (any word containing `/`), single and double quotes, `$var` and
|
|
28
|
+
* `${var}`, and `-f` / `--flag` / `--flag=value` flags.
|
|
29
|
+
* - Double quotes interpolate `$var` and handle `\"`, `\\`, `\$`, `` \` ``.
|
|
30
|
+
* - Words built from adjacent parts: `$HOME/bin`, `"a"b`, `"$HOME"/x`.
|
|
31
|
+
* - Redirects: `>`, `>>`, `<`, and `&>`, with an optional attached file
|
|
32
|
+
* descriptor on the first three (`2> err.txt`).
|
|
33
|
+
* - `&&` / `||` chains (one precedence level, left-associative, as in
|
|
34
|
+
* bash) and `( ... )` grouping.
|
|
35
|
+
* - Commands separated by `;` or newlines, including CRLF.
|
|
36
|
+
*
|
|
37
|
+
* REJECTED, and worth knowing because a reader would not predict them
|
|
38
|
+
* - Pipelines (`|`), background (`&`), and `;` inside `( ... )` — so
|
|
39
|
+
* `(a && b) || c` parses but `a && (b; c)` does not.
|
|
40
|
+
* - Reserved words ANYWHERE, not only at command position: `echo if` and
|
|
41
|
+
* `echo done` are valid bash and are rejected here.
|
|
42
|
+
* - `--` as an end-of-flags marker, so `git checkout -- file` is rejected,
|
|
43
|
+
* as is a bare `-` for stdin (`cat -`).
|
|
44
|
+
* - Compound commands: `if`, `for`, `while`, `case`, function definitions.
|
|
45
|
+
* - Command substitution `$(...)`, backticks, arithmetic `$((...))`,
|
|
46
|
+
* parameter expansion beyond `${name}` (`${x:-y}`), and the special
|
|
47
|
+
* parameters `$?` / `$@` / `$$`.
|
|
48
|
+
* - Globs (`*`, `?`, `[...]`), tilde (`~/x`), brace expansion, heredocs
|
|
49
|
+
* and here-strings, `2>&1`, comments (`#`), and escapes outside double
|
|
50
|
+
* quotes (`echo a\ b`).
|
|
51
|
+
* - Characters outside the word set above, including `@` and `%`.
|
|
47
52
|
*/
|
|
48
53
|
export * from "./types.js";
|
|
49
|
-
export * from "./
|
|
50
|
-
export * from "./words.js";
|
|
51
|
-
export * from "./commands.js";
|
|
52
|
-
export * from "./lists.js";
|
|
53
|
-
import { seq } from "../../combinators.js";
|
|
54
|
-
import { eof } from "../../parsers.js";
|
|
55
|
-
import { list0 } from "./lists.js";
|
|
56
|
-
/** Parse a whole script (or any command list) to a `List`. Fails unless
|
|
57
|
-
* the entire input is consumed. */
|
|
58
|
-
export const bashParser = seq([list0, eof], (results) => results[0]);
|
|
54
|
+
export * from "./parsers.js";
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { Parser, ParserResult } from "../../types.js";
|
|
2
|
+
import { Assignment, BashAST, Command, DoubleQuotedWord, FlagWord, InterpolatedVariableWord, LiteralWord, Parens, PathWord, Redirect, ScriptName, SimpleCommand, SingleQuotedWord, VariableWord, Word } from "./types.js";
|
|
3
|
+
export declare const RESERVED_WORDS: string[];
|
|
4
|
+
/** Characters allowed inside a VARIABLE name. Deliberately narrower than
|
|
5
|
+
* `wordChars`: a dot or hyphen ends the name, so `$HOME.txt` is `$HOME`
|
|
6
|
+
* followed by the text ".txt", as in bash. */
|
|
7
|
+
export declare const varNameChars: Parser<string>;
|
|
8
|
+
/** Characters allowed in a flag name or a flag value. Wider than
|
|
9
|
+
* `varNameChars` because filenames routinely contain dots and hyphens
|
|
10
|
+
* (`my-file.txt`).
|
|
11
|
+
*
|
|
12
|
+
* Deliberately excludes `=`, unlike `bareWordChars`: `--format=oneline`
|
|
13
|
+
* splits into a name and a value on that `=`, so a flag name that could
|
|
14
|
+
* swallow it would take the whole thing as the name. */
|
|
15
|
+
export declare const wordChars: Parser<string>;
|
|
16
|
+
/** Characters allowed in a bare word — a command name, a filename, an
|
|
17
|
+
* argument. Includes `=` because an assignment-shaped ARGUMENT is an
|
|
18
|
+
* ordinary word: `env FOO=bar` passes "FOO=bar" to env in one piece.
|
|
19
|
+
* (A leading assignment is still an assignment: those are parsed before
|
|
20
|
+
* the command name.) */
|
|
21
|
+
export declare const bareWordChars: Parser<string>;
|
|
22
|
+
/** A bare word may not START with `-`: that is a flag. Without this rule
|
|
23
|
+
* `subcommands` (bare literals, parsed before `args`) swallows a leading
|
|
24
|
+
* flag as a literal and `FlagWord` is unreachable for `ls -la`. A hyphen
|
|
25
|
+
* inside a word (`my-file`) is still fine. */
|
|
26
|
+
export declare const bareWordStartChars: Parser<string>;
|
|
27
|
+
/** The name in `$name` / `${name}`. A lone digit is a positional
|
|
28
|
+
* parameter (`$1`); unbraced, `$12` is `$1` followed by "2". */
|
|
29
|
+
export declare const varNameParser: Parser<string>;
|
|
30
|
+
/** The target of an assignment. Unlike `varNameParser` this refuses a
|
|
31
|
+
* digit: bash runs `1x=1` as a command literally named "1x=1", so
|
|
32
|
+
* recording it as an assignment would misreport what runs. */
|
|
33
|
+
export declare const assignmentNameParser: Parser<string>;
|
|
34
|
+
export declare const literalWordParser: Parser<LiteralWord>;
|
|
35
|
+
export declare const pathWordParser: Parser<PathWord>;
|
|
36
|
+
export declare const flagNameParser: Parser<string>;
|
|
37
|
+
export declare const flagWordNameOnlyParser: Parser<FlagWord>;
|
|
38
|
+
export declare const flagWordNameAndValueParser: Parser<FlagWord>;
|
|
39
|
+
export declare const flagWordParser: Parser<FlagWord>;
|
|
40
|
+
export declare const singleQuotedWordParser: Parser<SingleQuotedWord>;
|
|
41
|
+
/** Double quotes interpolate: `"$HOME"` expands, so the parts are text runs
|
|
42
|
+
* interleaved with variables. Recording the whole thing as literal text
|
|
43
|
+
* would pass a dollar sign through to the command instead of its value.
|
|
44
|
+
*
|
|
45
|
+
* A `$` that starts no supported expansion (`"$(date)"`, a bare `"$"`)
|
|
46
|
+
* fails the parse rather than degrading to text. */
|
|
47
|
+
export declare const doubleQuotedWordParser: Parser<DoubleQuotedWord>;
|
|
48
|
+
export declare const variableWordNoBracesParser: Parser<VariableWord>;
|
|
49
|
+
export declare const variableWordWithBracesParser: Parser<VariableWord>;
|
|
50
|
+
export declare const variableWordParser: Parser<VariableWord>;
|
|
51
|
+
/**
|
|
52
|
+
* An unquoted word that mixes literal text and variables: `$HOME.txt`,
|
|
53
|
+
* `$HOME/bin`, `prefix$NAME`, `$A$B`.
|
|
54
|
+
*
|
|
55
|
+
* These are ONE word in bash. Parsed as separate words they become
|
|
56
|
+
* separate arguments, which changes what the command means — and in an
|
|
57
|
+
* assignment (`PATH=$HOME/bin cmd`) it changes which program runs.
|
|
58
|
+
*
|
|
59
|
+
* Only matches when the word genuinely mixes: two or more adjacent
|
|
60
|
+
* parts, in any combination. A single part keeps its own simpler type, so
|
|
61
|
+
* a plain `file.txt` stays a `LiteralWord` and a lone `$HOME` stays a
|
|
62
|
+
* `VariableWord` rather than every word acquiring a `parts` array a
|
|
63
|
+
* consumer has to walk.
|
|
64
|
+
*/
|
|
65
|
+
export declare const interpolatedVariableWordParser: Parser<InterpolatedVariableWord>;
|
|
66
|
+
export declare const wordParser: Parser<Word>;
|
|
67
|
+
export declare const scriptNameParser: Parser<ScriptName>;
|
|
68
|
+
export declare const emptyAssignmentParser: Parser<Assignment>;
|
|
69
|
+
export declare const assignmentWithValueParser: Parser<Assignment>;
|
|
70
|
+
export declare const assignmentParser: Parser<Assignment>;
|
|
71
|
+
export declare const redirectParser: Parser<Redirect>;
|
|
72
|
+
export declare const argParser: Parser<Word>;
|
|
73
|
+
export declare const simpleCommandParser: Parser<SimpleCommand>;
|
|
74
|
+
/**
|
|
75
|
+
* `a && b || c`, plus `( ... )` grouping.
|
|
76
|
+
*
|
|
77
|
+
* Built with `buildExpressionParser` rather than by hand because the naive
|
|
78
|
+
* shape is left-recursive: an `and` parser whose first move is to call the
|
|
79
|
+
* command parser recurses forever on the same input. Here the atom
|
|
80
|
+
* (`simpleCommandParser`) always consumes before any operator is tried.
|
|
81
|
+
*
|
|
82
|
+
* Both operators sit at ONE precedence level, left-associative, because
|
|
83
|
+
* that is what bash does: `a || b && c` is `((a || b) && c)`, not
|
|
84
|
+
* `(a || (b && c))`. Splitting them across two levels would silently
|
|
85
|
+
* change the meaning of every mixed chain.
|
|
86
|
+
*/
|
|
87
|
+
export declare const commandParser: Parser<Command>;
|
|
88
|
+
export declare const parensParser: Parser<Parens>;
|
|
89
|
+
/** What separates two commands: a `;` or a newline.
|
|
90
|
+
*
|
|
91
|
+
* The trailing run absorbs blank lines and a `;` followed by a newline,
|
|
92
|
+
* but NOT a second `;` — `echo a ;; echo b` stays a parse error. */
|
|
93
|
+
export declare const commandSeparator: Parser<string[]>;
|
|
94
|
+
/** Parse a whole script: commands separated by `;` or newlines. */
|
|
95
|
+
export declare function bashParser(input: string): ParserResult<BashAST>;
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { failure, success } from "../../types.js";
|
|
2
|
+
import { literalWord } from "./types.js";
|
|
3
|
+
import { buildExpressionParser, capture, char, digit, eof, label, lazy, letter, many, many1, many1WithJoin, manyWithJoin, map, noneOf, num, oneOf, optional, or, peek, sepBy1, seqC, seqR, set, str, trace } from "../../index.js";
|
|
4
|
+
export const RESERVED_WORDS = [
|
|
5
|
+
"if", "then", "elif", "else", "fi",
|
|
6
|
+
"do", "done", "while", "until", "for", "in",
|
|
7
|
+
"case", "esac", "function",
|
|
8
|
+
"select", "coproc", "time", "[[", "]]",
|
|
9
|
+
];
|
|
10
|
+
function result(parser) {
|
|
11
|
+
return capture(parser, "result");
|
|
12
|
+
}
|
|
13
|
+
function getResult(parser) {
|
|
14
|
+
return (input) => {
|
|
15
|
+
const result = parser(input);
|
|
16
|
+
if (result.success) {
|
|
17
|
+
return success(result.result.result, result.rest);
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
return result;
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
const LETTERS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
25
|
+
const DIGITS = "0123456789";
|
|
26
|
+
/** Characters allowed inside a VARIABLE name. Deliberately narrower than
|
|
27
|
+
* `wordChars`: a dot or hyphen ends the name, so `$HOME.txt` is `$HOME`
|
|
28
|
+
* followed by the text ".txt", as in bash. */
|
|
29
|
+
export const varNameChars = label("a variable name character", oneOf(LETTERS + DIGITS + "_"));
|
|
30
|
+
/** Characters allowed in a flag name or a flag value. Wider than
|
|
31
|
+
* `varNameChars` because filenames routinely contain dots and hyphens
|
|
32
|
+
* (`my-file.txt`).
|
|
33
|
+
*
|
|
34
|
+
* Deliberately excludes `=`, unlike `bareWordChars`: `--format=oneline`
|
|
35
|
+
* splits into a name and a value on that `=`, so a flag name that could
|
|
36
|
+
* swallow it would take the whole thing as the name. */
|
|
37
|
+
export const wordChars = label("a word character", oneOf(LETTERS + DIGITS + "_-./"));
|
|
38
|
+
/** Characters allowed in a bare word — a command name, a filename, an
|
|
39
|
+
* argument. Includes `=` because an assignment-shaped ARGUMENT is an
|
|
40
|
+
* ordinary word: `env FOO=bar` passes "FOO=bar" to env in one piece.
|
|
41
|
+
* (A leading assignment is still an assignment: those are parsed before
|
|
42
|
+
* the command name.) */
|
|
43
|
+
export const bareWordChars = label("a word character", oneOf(LETTERS + DIGITS + "_-.=:+"));
|
|
44
|
+
/** A bare word may not START with `-`: that is a flag. Without this rule
|
|
45
|
+
* `subcommands` (bare literals, parsed before `args`) swallows a leading
|
|
46
|
+
* flag as a literal and `FlagWord` is unreachable for `ls -la`. A hyphen
|
|
47
|
+
* inside a word (`my-file`) is still fine. */
|
|
48
|
+
export const bareWordStartChars = label("a word character", oneOf(LETTERS + DIGITS + "_.=:+"));
|
|
49
|
+
/** Characters that end a bare word: whitespace and the operators that
|
|
50
|
+
* separate commands. */
|
|
51
|
+
const WORD_END_CHARS = " \t\n\r&|;()<>";
|
|
52
|
+
/**
|
|
53
|
+
* Whitespace WITHIN a command: spaces and tabs only.
|
|
54
|
+
*
|
|
55
|
+
* Deliberately not tarsec's `spaces`, which includes `\n`. A newline is a
|
|
56
|
+
* command SEPARATOR, and eating it as whitespace merges commands —
|
|
57
|
+
* `ls\nrm -rf /tmp/x` becomes a single `ls` with arguments, so anything
|
|
58
|
+
* that inspects the command name sees `ls` while bash runs the `rm`.
|
|
59
|
+
*/
|
|
60
|
+
const blanks = manyWithJoin(oneOf(" \t"));
|
|
61
|
+
/**
|
|
62
|
+
* Require that a word ran all the way to a boundary.
|
|
63
|
+
*
|
|
64
|
+
* Without this a word parser happily matches the FRONT of a longer token
|
|
65
|
+
* — `src` out of `src/main.ts`, `FOO` out of `FOO=bar` — and the leftover
|
|
66
|
+
* (`/main.ts`) matches nothing, so the whole command fails to parse with
|
|
67
|
+
* no indication of why. Stopping mid-token is never right: whatever
|
|
68
|
+
* follows is part of the same word.
|
|
69
|
+
*/
|
|
70
|
+
function wholeWord(parser) {
|
|
71
|
+
// The boundary is REQUIRED, not optional: `optional(peek(...))` succeeds
|
|
72
|
+
// whether or not a boundary follows, which makes the whole check a no-op
|
|
73
|
+
// and lets `src` match out of `src/main.ts` again. `eof` covers the word
|
|
74
|
+
// that ends the input, which is the only reason it looked optional.
|
|
75
|
+
return getResult(seqC(result(parser), or(peek(oneOf(WORD_END_CHARS)), eof)));
|
|
76
|
+
}
|
|
77
|
+
/** An identifier: `[A-Za-z_][A-Za-z0-9_]*`. `manyWithJoin`, not `many1`,
|
|
78
|
+
* so a one-character name (`x=1`, `$A`) is valid. */
|
|
79
|
+
const identifierParser = map(seqR(or(letter, char("_")), manyWithJoin(varNameChars)), (result) => result.join(""));
|
|
80
|
+
/** The name in `$name` / `${name}`. A lone digit is a positional
|
|
81
|
+
* parameter (`$1`); unbraced, `$12` is `$1` followed by "2". */
|
|
82
|
+
export const varNameParser = trace("varNameParser", or(identifierParser, digit));
|
|
83
|
+
/** The target of an assignment. Unlike `varNameParser` this refuses a
|
|
84
|
+
* digit: bash runs `1x=1` as a command literally named "1x=1", so
|
|
85
|
+
* recording it as an assignment would misreport what runs. */
|
|
86
|
+
export const assignmentNameParser = trace("assignmentNameParser", identifierParser);
|
|
87
|
+
/** Reserved words are refused everywhere, not only at command position:
|
|
88
|
+
* this is a small subset of bash, and refusing to parse something valid is
|
|
89
|
+
* cheaper than mis-parsing it. */
|
|
90
|
+
function rejectReservedWord(result, input) {
|
|
91
|
+
if (result.success && RESERVED_WORDS.includes(result.result.text)) {
|
|
92
|
+
return failure(`Reserved word "${result.result.text}" cannot be used.`, input);
|
|
93
|
+
}
|
|
94
|
+
return result;
|
|
95
|
+
}
|
|
96
|
+
export const literalWordParser = wholeWord((input) => {
|
|
97
|
+
const result = trace("literalWordParser", seqC(set("tag", "literal"), capture(map(seqR(bareWordStartChars, manyWithJoin(bareWordChars)), (r) => r.join("")), "text")))(input);
|
|
98
|
+
return rejectReservedWord(result, input);
|
|
99
|
+
});
|
|
100
|
+
// Slashes are scanned as part of the run rather than used as separators,
|
|
101
|
+
// so a LEADING slash is just another character: `sepBy1` needed a segment
|
|
102
|
+
// before the first separator, which made every absolute path unparseable.
|
|
103
|
+
// A bare "/" and a trailing "/" are both real paths (`ls /`, `ls src/`).
|
|
104
|
+
export const pathWordParser = wholeWord((input) => {
|
|
105
|
+
const result = trace("pathWordParser", seqC(set("tag", "path"), capture(many1WithJoin(or(bareWordChars, char("/"))), "text")))(input);
|
|
106
|
+
if (result.success) {
|
|
107
|
+
const text = result.result.text;
|
|
108
|
+
if (!text.includes("/")) {
|
|
109
|
+
return failure(`Path must contain at least one "/".`, input);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return result;
|
|
113
|
+
});
|
|
114
|
+
export const flagNameParser = trace("flagNameParser", map(seqR(char("-"), optional(char("-")), many1WithJoin(wordChars)), (result) => result.join("")));
|
|
115
|
+
export const flagWordNameOnlyParser = trace("flagWordNameOnlyParser", seqC(set("tag", "flag"), capture(flagNameParser, "flagName")));
|
|
116
|
+
export const flagWordNameAndValueParser = trace("flagWordNameAndValueParser", seqC(set("tag", "flag"), capture(flagNameParser, "flagName"), char("="), capture(many1WithJoin(wordChars), "flagValue")));
|
|
117
|
+
export const flagWordParser = trace("flagWordParser", wholeWord(or(flagWordNameAndValueParser, flagWordNameOnlyParser)));
|
|
118
|
+
export const singleQuotedWordParser = trace("singleQuotedWordParser", seqC(set("tag", "singleQuoted"), char("'"), capture(manyWithJoin(noneOf("'")), "text"), char("'")));
|
|
119
|
+
// A run of ordinary text inside double quotes. Stops at `$` so an
|
|
120
|
+
// expansion is not swallowed as text, and at `\` so an escape is not.
|
|
121
|
+
const doubleQuotedLiteralParser = trace("doubleQuotedLiteralParser", map(many1WithJoin(noneOf('"$\\')), literalWord));
|
|
122
|
+
/** The characters a backslash escapes inside double quotes. Before
|
|
123
|
+
* anything else the backslash is ordinary text: bash prints `"a\zb"` as
|
|
124
|
+
* `a\zb`, but `"a\"b"` as `a"b`. */
|
|
125
|
+
const DOUBLE_QUOTE_ESCAPABLE = '"$`\\';
|
|
126
|
+
const doubleQuotedEscapeParser = trace("doubleQuotedEscapeParser", or(map(seqR(char("\\"), oneOf(DOUBLE_QUOTE_ESCAPABLE)), (results) => literalWord(results[1])), map(char("\\"), () => literalWord("\\"))));
|
|
127
|
+
// No alternative can match empty, so `many` below always terminates.
|
|
128
|
+
const doubleQuotedPartParser = trace("doubleQuotedPartParser", or(lazy(() => variableWordParser), doubleQuotedEscapeParser, doubleQuotedLiteralParser));
|
|
129
|
+
/** Merge adjacent literal parts. An escape splits the text run in three
|
|
130
|
+
* (`a`, `"`, `b`), which is an artifact of how it was parsed rather than
|
|
131
|
+
* anything a consumer should have to reassemble. */
|
|
132
|
+
function mergeLiterals(parts) {
|
|
133
|
+
return parts.reduce((merged, part) => {
|
|
134
|
+
const previous = merged[merged.length - 1];
|
|
135
|
+
if (part.tag === "literal" && (previous === null || previous === void 0 ? void 0 : previous.tag) === "literal") {
|
|
136
|
+
merged[merged.length - 1] = literalWord(previous.text + part.text);
|
|
137
|
+
return merged;
|
|
138
|
+
}
|
|
139
|
+
merged.push(part);
|
|
140
|
+
return merged;
|
|
141
|
+
}, []);
|
|
142
|
+
}
|
|
143
|
+
/** Double quotes interpolate: `"$HOME"` expands, so the parts are text runs
|
|
144
|
+
* interleaved with variables. Recording the whole thing as literal text
|
|
145
|
+
* would pass a dollar sign through to the command instead of its value.
|
|
146
|
+
*
|
|
147
|
+
* A `$` that starts no supported expansion (`"$(date)"`, a bare `"$"`)
|
|
148
|
+
* fails the parse rather than degrading to text. */
|
|
149
|
+
export const doubleQuotedWordParser = trace("doubleQuotedWordParser", seqC(set("tag", "doubleQuoted"), char('"'), capture(map(many(doubleQuotedPartParser), mergeLiterals), "parts"), char('"')));
|
|
150
|
+
export const variableWordNoBracesParser = trace("variableWordNoBracesParser", seqC(set("tag", "variable"), char("$"), capture(varNameParser, "name")));
|
|
151
|
+
export const variableWordWithBracesParser = trace("variableWordWithBracesParser", seqC(set("tag", "variable"), char("$"), char("{"), capture(varNameParser, "name"), char("}")));
|
|
152
|
+
export const variableWordParser = trace("variableWordParser", or(variableWordWithBracesParser, variableWordNoBracesParser));
|
|
153
|
+
// A run of ordinary text inside an unquoted word. Slashes are included so
|
|
154
|
+
// `$HOME/bin` keeps its suffix; `$` is excluded so a variable is not eaten
|
|
155
|
+
// as text.
|
|
156
|
+
const interpolatedLiteralParser = trace("interpolatedLiteralParser", map(many1WithJoin(or(bareWordChars, char("/"))), literalWord));
|
|
157
|
+
// Quoted words are parts too: `"$HOME"/x` and `"a"b` are single words.
|
|
158
|
+
// The literal run cannot contain a quote, so these never overlap.
|
|
159
|
+
const interpolatedPartParser = trace("interpolatedPartParser", or(variableWordParser, singleQuotedWordParser, doubleQuotedWordParser, interpolatedLiteralParser));
|
|
160
|
+
/**
|
|
161
|
+
* An unquoted word that mixes literal text and variables: `$HOME.txt`,
|
|
162
|
+
* `$HOME/bin`, `prefix$NAME`, `$A$B`.
|
|
163
|
+
*
|
|
164
|
+
* These are ONE word in bash. Parsed as separate words they become
|
|
165
|
+
* separate arguments, which changes what the command means — and in an
|
|
166
|
+
* assignment (`PATH=$HOME/bin cmd`) it changes which program runs.
|
|
167
|
+
*
|
|
168
|
+
* Only matches when the word genuinely mixes: two or more adjacent
|
|
169
|
+
* parts, in any combination. A single part keeps its own simpler type, so
|
|
170
|
+
* a plain `file.txt` stays a `LiteralWord` and a lone `$HOME` stays a
|
|
171
|
+
* `VariableWord` rather than every word acquiring a `parts` array a
|
|
172
|
+
* consumer has to walk.
|
|
173
|
+
*/
|
|
174
|
+
export const interpolatedVariableWordParser = wholeWord((input) => {
|
|
175
|
+
const result = trace("interpolatedVariableWordParser", many1(interpolatedPartParser))(input);
|
|
176
|
+
if (!result.success)
|
|
177
|
+
return result;
|
|
178
|
+
const parts = result.result;
|
|
179
|
+
// Two or more parts is what makes a word interpolated. A single part
|
|
180
|
+
// keeps its own simpler type: `file.txt` stays a LiteralWord, `$HOME`
|
|
181
|
+
// a VariableWord, `"a b"` a DoubleQuotedWord.
|
|
182
|
+
if (parts.length < 2) {
|
|
183
|
+
return failure("Not an interpolated word: nothing to interpolate.", input);
|
|
184
|
+
}
|
|
185
|
+
return success({ tag: "interpolatedVariable", parts }, result.rest);
|
|
186
|
+
});
|
|
187
|
+
export const wordParser = trace("wordParser", or(flagWordParser,
|
|
188
|
+
// Before every single-part parser: each of those would match only the
|
|
189
|
+
// FIRST part of an interpolated word and leave the rest to become a
|
|
190
|
+
// separate argument.
|
|
191
|
+
interpolatedVariableWordParser, singleQuotedWordParser, doubleQuotedWordParser, variableWordParser, pathWordParser, literalWordParser));
|
|
192
|
+
export const scriptNameParser = trace("scriptNameParser", or(pathWordParser, literalWordParser));
|
|
193
|
+
export const emptyAssignmentParser = trace("emptyAssignmentParser", seqC(set("tag", "assignment"), capture(assignmentNameParser, "name"), char("="), set("value", null)));
|
|
194
|
+
export const assignmentWithValueParser = trace("assignmentWithValueParser", seqC(set("tag", "assignment"), capture(assignmentNameParser, "name"), char("="), capture(wordParser, "value")));
|
|
195
|
+
export const assignmentParser = trace("assignmentParser", or(assignmentWithValueParser, emptyAssignmentParser));
|
|
196
|
+
// No `<<` or `<<<`: a heredoc's body lives on the lines AFTER the
|
|
197
|
+
// command, so `cat <<EOF` is not a redirect to a file named "EOF".
|
|
198
|
+
// Parsing it as one is a silent mis-parse; leaving the operator out means
|
|
199
|
+
// the `<` alternative fails on the second `<` and the command is rejected
|
|
200
|
+
// instead. Longest alternative first, so `>>` beats `>`.
|
|
201
|
+
const fdRedirectOp = or(str(">>"), str(">"), str("<"));
|
|
202
|
+
// `&>` takes NO file descriptor. Bash reads the `2` in `cmd 2&> f` as an
|
|
203
|
+
// ordinary argument, so accepting `2&>` as "fd 2" makes that argument
|
|
204
|
+
// vanish from the AST — a silent mis-parse, not a rejection.
|
|
205
|
+
const bareRedirectOp = str("&>");
|
|
206
|
+
export const redirectParser = trace("redirectParser", or(seqC(set("tag", "redirect"), optional(capture(map(num, parseInt), "fd")), capture(fdRedirectOp, "op"), blanks, capture(wordParser, "target")), seqC(set("tag", "redirect"), capture(bareRedirectOp, "op"), blanks, capture(wordParser, "target"))));
|
|
207
|
+
export const argParser = trace("argParser", or(flagWordParser, wordParser));
|
|
208
|
+
/** Run a parser, then eat any trailing whitespace.
|
|
209
|
+
*
|
|
210
|
+
* The discipline: every token-shaped parser consumes its own TRAILING
|
|
211
|
+
* space, and leading space is eaten once at the start of the command.
|
|
212
|
+
* `sepBy(spaces, ...)` cannot express this — it only puts a separator
|
|
213
|
+
* BETWEEN elements of one group, so the space between the command name
|
|
214
|
+
* and its first argument, or between the last argument and a redirect,
|
|
215
|
+
* had nothing to consume it and the rest of the command was left
|
|
216
|
+
* unparsed. */
|
|
217
|
+
function token(parser) {
|
|
218
|
+
return map(seqR(parser, blanks), (results) => results[0]);
|
|
219
|
+
}
|
|
220
|
+
/** A redirect or an argument, tried in that order.
|
|
221
|
+
*
|
|
222
|
+
* Interleaved rather than parsed as two separate groups, for two reasons.
|
|
223
|
+
* A digit attached to an operator is a FILE DESCRIPTOR, not an argument —
|
|
224
|
+
* bash reads `cmd 3> x` as "redirect fd 3, no arguments" — and args-first
|
|
225
|
+
* would always claim the `3`. And bash allows a redirect anywhere among
|
|
226
|
+
* the arguments (`cmd > out.txt arg`), which two fixed groups reject. */
|
|
227
|
+
const argOrRedirectParser = trace("argOrRedirectParser", or(redirectParser, argParser));
|
|
228
|
+
const isRedirect = (item) => item.tag === "redirect";
|
|
229
|
+
const simpleCommandShape = seqC(set("tag", "simpleCommand"), blanks, capture(many(token(assignmentParser)), "assignments"),
|
|
230
|
+
// Optional so a bare `FOO=bar` line parses. Bash requires at least one
|
|
231
|
+
// of a command name or an assignment; that check is below.
|
|
232
|
+
capture(optional(token(scriptNameParser)), "command"), capture(many(token(argOrRedirectParser)), "items"));
|
|
233
|
+
export const simpleCommandParser = trace("simpleCommandParser", (input) => {
|
|
234
|
+
const parsed = simpleCommandShape(input);
|
|
235
|
+
if (!parsed.success)
|
|
236
|
+
return parsed;
|
|
237
|
+
const { assignments, command, items } = parsed.result;
|
|
238
|
+
// Neither a command nor an assignment means nothing was parsed at all;
|
|
239
|
+
// without this every input "succeeds" as an empty command.
|
|
240
|
+
if (command === null && assignments.length === 0) {
|
|
241
|
+
return failure("Expected a command or an assignment.", input);
|
|
242
|
+
}
|
|
243
|
+
return success({
|
|
244
|
+
tag: "simpleCommand",
|
|
245
|
+
assignments,
|
|
246
|
+
command,
|
|
247
|
+
args: items.filter((item) => !isRedirect(item)),
|
|
248
|
+
redirects: items.filter(isRedirect),
|
|
249
|
+
}, parsed.rest);
|
|
250
|
+
});
|
|
251
|
+
/** An operator in a `&&` / `||` chain, absorbing the whitespace around it.
|
|
252
|
+
* `buildExpressionParser` applies this directly to the remaining input, so
|
|
253
|
+
* it has to eat its own surrounding space; optional, so `a&&b` works too. */
|
|
254
|
+
const chainOperator = (symbol) => map(seqR(blanks, str(symbol), blanks), () => symbol);
|
|
255
|
+
/**
|
|
256
|
+
* `a && b || c`, plus `( ... )` grouping.
|
|
257
|
+
*
|
|
258
|
+
* Built with `buildExpressionParser` rather than by hand because the naive
|
|
259
|
+
* shape is left-recursive: an `and` parser whose first move is to call the
|
|
260
|
+
* command parser recurses forever on the same input. Here the atom
|
|
261
|
+
* (`simpleCommandParser`) always consumes before any operator is tried.
|
|
262
|
+
*
|
|
263
|
+
* Both operators sit at ONE precedence level, left-associative, because
|
|
264
|
+
* that is what bash does: `a || b && c` is `((a || b) && c)`, not
|
|
265
|
+
* `(a || (b && c))`. Splitting them across two levels would silently
|
|
266
|
+
* change the meaning of every mixed chain.
|
|
267
|
+
*/
|
|
268
|
+
export const commandParser = trace("commandParser", buildExpressionParser(simpleCommandParser, [[
|
|
269
|
+
{
|
|
270
|
+
op: chainOperator("&&"),
|
|
271
|
+
assoc: "left",
|
|
272
|
+
apply: (left, right) => ({ tag: "and", left, right }),
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
op: chainOperator("||"),
|
|
276
|
+
assoc: "left",
|
|
277
|
+
apply: (left, right) => ({ tag: "or", left, right }),
|
|
278
|
+
},
|
|
279
|
+
]],
|
|
280
|
+
// Passed explicitly: the default paren parser returns the inner
|
|
281
|
+
// expression unwrapped, which would drop the `Parens` node.
|
|
282
|
+
lazy(() => parensParser)));
|
|
283
|
+
export const parensParser = trace("parensParser", seqC(set("tag", "parens"), char("("), capture(lazy(() => commandParser), "command"), char(")")));
|
|
284
|
+
/** What separates two commands: a `;` or a newline.
|
|
285
|
+
*
|
|
286
|
+
* The trailing run absorbs blank lines and a `;` followed by a newline,
|
|
287
|
+
* but NOT a second `;` — `echo a ;; echo b` stays a parse error. */
|
|
288
|
+
export const commandSeparator = seqR(blanks, or(char(";"), char("\n"), char("\r")), manyWithJoin(oneOf(" \t\n\r")));
|
|
289
|
+
/** Blank lines before the first command. `simpleCommandParser` only eats
|
|
290
|
+
* blanks (spaces and tabs), so without this a script starting with a
|
|
291
|
+
* newline — or any CRLF file — is rejected outright. */
|
|
292
|
+
const leadingSeparators = manyWithJoin(oneOf(" \t\n\r"));
|
|
293
|
+
/** Parse a whole script: commands separated by `;` or newlines. */
|
|
294
|
+
export function bashParser(input) {
|
|
295
|
+
const result = trace("bashParser", seqR(leadingSeparators, sepBy1(commandSeparator, commandParser)))(input);
|
|
296
|
+
if (!result.success)
|
|
297
|
+
return result;
|
|
298
|
+
const commands = result.result[1];
|
|
299
|
+
if (result.rest.trim() !== "") {
|
|
300
|
+
// `result.rest`, not `input`: tarsec derives position from
|
|
301
|
+
// originalInput.length - rest.length, so returning the whole input
|
|
302
|
+
// would report every error at column 1.
|
|
303
|
+
return failure(`Unexpected input after commands: "${result.rest}"`, result.rest);
|
|
304
|
+
}
|
|
305
|
+
return success(commands, result.rest);
|
|
306
|
+
}
|