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.
@@ -1,57 +1,54 @@
1
1
  /**
2
- * A fail-closed bash parser: a deliberately smaller bash syntax that is
3
- * either parsed correctly or rejected with a parse error — never silently
4
- * mis-parsed. Intended for contexts where acting on a wrong parse is
5
- * worse than rejecting a valid script.
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
- * Soundness comes from two structural rules plus explicit cuts:
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
- * setInputStr(input);
43
- * resetRightmostFailure();
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 "./lexemes.js";
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 fail-closed bash parser: a deliberately smaller bash syntax that is
3
- * either parsed correctly or rejected with a parse error — never silently
4
- * mis-parsed. Intended for contexts where acting on a wrong parse is
5
- * worse than rejecting a valid script.
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
- * Soundness comes from two structural rules plus explicit cuts:
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
- * setInputStr(input);
43
- * resetRightmostFailure();
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 "./lexemes.js";
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
+ }