tarsec 0.5.1 → 0.5.3

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.
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Turn a parsed AST back into a bash command.
3
+ *
4
+ * The inverse of `bashParser`, with one guarantee that matters more than
5
+ * exact reproduction: **the output is always safe to hand to bash**. Text
6
+ * that needs quoting gets quoted, whatever the node's tag claims. The
7
+ * parser cannot produce a `LiteralWord` holding `; rm -rf /` — its word
8
+ * charset forbids `;` — but a consumer can construct or mutate one, and a
9
+ * naive emitter would hand that straight to a shell.
10
+ *
11
+ * Where quoting cannot rescue a field — a variable name, an assignment
12
+ * target, a redirect operator — an `AstToBashError` is thrown instead.
13
+ * Quoting those would stop them being a variable, a target or an operator
14
+ * at all, so refusing is the only way to keep the guarantee.
15
+ *
16
+ * ```ts
17
+ * const ast = bashParser("git commit -m 'hi'").result;
18
+ * astToBash(ast); // "git commit -m 'hi'"
19
+ * ```
20
+ */
21
+ import { BashAST, BashNode } from "./types.js";
22
+ /**
23
+ * Thrown for an AST that cannot be written as bash at all.
24
+ *
25
+ * Quoting rescues word TEXT — `'; rm -rf /'` is one harmless argument.
26
+ * It cannot rescue a variable name, an assignment target or a redirect
27
+ * operator: quoting those stops them being a variable, a target or an
28
+ * operator. Emitting them raw would let a hand-built AST inject a second
29
+ * command, so the only way to keep the safety guarantee is to refuse.
30
+ */
31
+ export declare class AstToBashError extends Error {
32
+ constructor(message: string);
33
+ }
34
+ /**
35
+ * Turn an AST back into bash source.
36
+ *
37
+ * Accepts a whole script (commands joined with `; `) or any single node,
38
+ * down to a lone word — handy in tests and while debugging.
39
+ *
40
+ * For anything the parser produced, `bashParser(astToBash(ast))` gives an
41
+ * equal AST. That is equality of ASTs, not of strings: whitespace
42
+ * normalizes, and redirects move to the end of their command.
43
+ *
44
+ * For a hand-built AST the guarantee is deliberately weaker. The output is
45
+ * always safe and always produces the words asked for, but a tag can
46
+ * change on the way back: `literal "a b"` emits `'a b'` and returns as a
47
+ * `singleQuoted`. No emission preserves both the tag and the meaning, and
48
+ * meaning is the one worth keeping.
49
+ */
50
+ export declare function astToBash(node: BashNode | BashAST): string;
@@ -0,0 +1,259 @@
1
+ const LETTERS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
2
+ const DIGITS = "0123456789";
3
+ /** The characters the parser reads as a bare word or a path. Text made
4
+ * only of these can be emitted unquoted and read back as the same word.
5
+ * This is `bareWordChars` / `bareWordStartChars` from parsers.ts plus
6
+ * `/`, since `literalWordParser` and `pathWordParser` between them cover
7
+ * both. */
8
+ const BARE_WORD_CHARS = new Set(LETTERS + DIGITS + "_-.=:+/");
9
+ const BARE_WORD_START_CHARS = new Set(LETTERS + DIGITS + "_.=:+/");
10
+ /** Characters that can continue a variable name. A variable part followed
11
+ * by one of these has to be braced. */
12
+ const VAR_NAME_CHARS = new Set(LETTERS + DIGITS + "_");
13
+ /** Characters the parser allows in a flag name or value. */
14
+ const FLAG_CHARS = new Set(LETTERS + DIGITS + "_-./");
15
+ /** The redirect operators the parser recognizes. `&>` is separate: bash
16
+ * has no fd-prefixed form of it, and the parser rejects `2&> f`. */
17
+ const FD_REDIRECT_OPS = new Set([">", ">>", "<"]);
18
+ const BARE_REDIRECT_OPS = new Set(["&>"]);
19
+ /**
20
+ * Thrown for an AST that cannot be written as bash at all.
21
+ *
22
+ * Quoting rescues word TEXT — `'; rm -rf /'` is one harmless argument.
23
+ * It cannot rescue a variable name, an assignment target or a redirect
24
+ * operator: quoting those stops them being a variable, a target or an
25
+ * operator. Emitting them raw would let a hand-built AST inject a second
26
+ * command, so the only way to keep the safety guarantee is to refuse.
27
+ */
28
+ export class AstToBashError extends Error {
29
+ constructor(message) {
30
+ super(message);
31
+ this.name = "AstToBashError";
32
+ }
33
+ }
34
+ /** `[A-Za-z_][A-Za-z0-9_]*`, matching the parser's identifier rule. */
35
+ function isIdentifier(text) {
36
+ if (text.length === 0)
37
+ return false;
38
+ if (!(LETTERS + "_").includes(text[0]))
39
+ return false;
40
+ for (const character of text) {
41
+ if (!VAR_NAME_CHARS.has(character))
42
+ return false;
43
+ }
44
+ return true;
45
+ }
46
+ /** A variable name: an identifier, or a single digit for a positional
47
+ * parameter (`$1`), matching `varNameParser`. */
48
+ function isVariableName(text) {
49
+ return isIdentifier(text) || (text.length === 1 && DIGITS.includes(text));
50
+ }
51
+ function isFlagText(text) {
52
+ for (const character of text) {
53
+ if (!FLAG_CHARS.has(character))
54
+ return false;
55
+ }
56
+ return text.length > 0;
57
+ }
58
+ /** Can this text be written without quotes and read back unchanged?
59
+ *
60
+ * A leading `-` disqualifies it: bare `-la` reads back as a flag, not a
61
+ * literal. Quoting is the safe answer in that case. */
62
+ function isBareWord(text) {
63
+ if (text.length === 0)
64
+ return false;
65
+ if (!BARE_WORD_START_CHARS.has(text[0]))
66
+ return false;
67
+ for (const character of text) {
68
+ if (!BARE_WORD_CHARS.has(character))
69
+ return false;
70
+ }
71
+ return true;
72
+ }
73
+ /** Wrap text in single quotes. Bash has no escape inside single quotes,
74
+ * so an embedded `'` closes the string, emits an escaped quote, and
75
+ * reopens: `it's` becomes `'it'\''s'`. */
76
+ function singleQuote(text) {
77
+ return `'${text.split("'").join("'\\''")}'`;
78
+ }
79
+ /** Emit bare where that reads back unchanged, quoted otherwise. */
80
+ function quoteIfNeeded(text) {
81
+ return isBareWord(text) ? text : singleQuote(text);
82
+ }
83
+ /** Escape the characters that stay special inside double quotes. */
84
+ function escapeForDoubleQuotes(text) {
85
+ let out = "";
86
+ for (const character of text) {
87
+ if (character === '"' || character === "\\" || character === "$" || character === "`") {
88
+ out += "\\";
89
+ }
90
+ out += character;
91
+ }
92
+ return out;
93
+ }
94
+ function doubleQuotedToBash(word) {
95
+ const inner = word.parts
96
+ .map((part) => part.tag === "literal" ? escapeForDoubleQuotes(part.text) : wordToBash(part))
97
+ .join("");
98
+ return `"${inner}"`;
99
+ }
100
+ /** Would `next` be read as a continuation of the variable name before it? */
101
+ function continuesVariableName(next) {
102
+ if (next === undefined)
103
+ return false;
104
+ const text = next.tag === "literal" ? next.text : "";
105
+ return text.length > 0 && VAR_NAME_CHARS.has(text[0]);
106
+ }
107
+ /**
108
+ * Adjacent parts, concatenated with no separator.
109
+ *
110
+ * The one subtlety: `[variable "A", literal "bc"]` written plainly gives
111
+ * `$Abc`, which reads back as a variable named `Abc` — a different
112
+ * command. A variable is braced whenever the next part could continue its
113
+ * name.
114
+ */
115
+ function interpolatedToBash(word) {
116
+ return word.parts
117
+ .map((part, index) => {
118
+ if (part.tag === "variable" && continuesVariableName(word.parts[index + 1])) {
119
+ return `\${${part.name}}`;
120
+ }
121
+ return wordToBash(part);
122
+ })
123
+ .join("");
124
+ }
125
+ function wordToBash(word) {
126
+ switch (word.tag) {
127
+ case "literal":
128
+ case "path":
129
+ return quoteIfNeeded(word.text);
130
+ case "singleQuoted":
131
+ return singleQuote(word.text);
132
+ case "doubleQuoted":
133
+ return doubleQuotedToBash(word);
134
+ case "variable":
135
+ if (!isVariableName(word.name)) {
136
+ throw new AstToBashError(`Not a valid variable name: ${JSON.stringify(word.name)}`);
137
+ }
138
+ return `$${word.name}`;
139
+ case "flag":
140
+ return flagToBash(word);
141
+ case "interpolatedVariable":
142
+ return interpolatedToBash(word);
143
+ }
144
+ }
145
+ /** A flag CAN be rescued by quoting: the whole token becomes a single
146
+ * argument. It reads back as a quoted word rather than a flag, which is
147
+ * the documented trade for hand-built nodes — one argument that means
148
+ * what it says beats a flag that starts a second command. */
149
+ function flagToBash(word) {
150
+ const rendered = word.flagValue === undefined
151
+ ? word.flagName
152
+ : `${word.flagName}=${word.flagValue}`;
153
+ const isSafe = word.flagName.startsWith("-") &&
154
+ isFlagText(word.flagName.replace(/^--?/, "")) &&
155
+ (word.flagValue === undefined || isFlagText(word.flagValue));
156
+ return isSafe ? rendered : singleQuote(rendered);
157
+ }
158
+ function assignmentToBash(assignment) {
159
+ if (!isIdentifier(assignment.name)) {
160
+ throw new AstToBashError(`Not a valid assignment name: ${JSON.stringify(assignment.name)}`);
161
+ }
162
+ const value = assignment.value === null ? "" : wordToBash(assignment.value);
163
+ return `${assignment.name}=${value}`;
164
+ }
165
+ function redirectToBash(redirect) {
166
+ const takesFd = FD_REDIRECT_OPS.has(redirect.op);
167
+ if (!takesFd && !BARE_REDIRECT_OPS.has(redirect.op)) {
168
+ throw new AstToBashError(`Not a supported redirect operator: ${JSON.stringify(redirect.op)}`);
169
+ }
170
+ if (redirect.fd !== undefined && !takesFd) {
171
+ throw new AstToBashError(`\`${redirect.op}\` takes no file descriptor.`);
172
+ }
173
+ const fd = redirect.fd === undefined ? "" : String(redirect.fd);
174
+ return `${fd}${redirect.op} ${wordToBash(redirect.target)}`;
175
+ }
176
+ /** Assignments, command, arguments, redirects.
177
+ *
178
+ * Redirects go last because the AST does not record where they sat among
179
+ * the arguments — `cmd > out.txt arg` comes back as `cmd arg > out.txt`,
180
+ * which is the same command. */
181
+ function simpleCommandToBash(command) {
182
+ const pieces = [
183
+ ...command.assignments.map(assignmentToBash),
184
+ ...(command.command === null ? [] : [wordToBash(command.command)]),
185
+ ...command.args.map(wordToBash),
186
+ ...command.redirects.map(redirectToBash),
187
+ ];
188
+ return pieces.join(" ");
189
+ }
190
+ /**
191
+ * A chain operand, parenthesized when it needs to be.
192
+ *
193
+ * `&&` and `||` are one precedence level and associate left, so an
194
+ * `and`/`or` nested on the RIGHT cannot be written flat: `And(a, Or(b, c))`
195
+ * as `a && b || c` reads back as `Or(And(a, b), c)`, a different tree.
196
+ *
197
+ * Bash parens are a subshell, so this is not a pure grouping — but the
198
+ * parser never builds this shape (chains are left-associative, so right
199
+ * children are always primaries), and it is the only way to preserve a
200
+ * hand-built one.
201
+ */
202
+ function chainOperandToBash(command, isRightOperand) {
203
+ const needsParens = isRightOperand && (command.tag === "and" || command.tag === "or");
204
+ const emitted = commandToBash(command);
205
+ return needsParens ? `(${emitted})` : emitted;
206
+ }
207
+ function commandToBash(command) {
208
+ switch (command.tag) {
209
+ case "simpleCommand":
210
+ return simpleCommandToBash(command);
211
+ case "and":
212
+ case "or": {
213
+ const operator = command.tag === "and" ? "&&" : "||";
214
+ const left = chainOperandToBash(command.left, false);
215
+ const right = chainOperandToBash(command.right, true);
216
+ return `${left} ${operator} ${right}`;
217
+ }
218
+ case "parens":
219
+ return `(${commandToBash(command.command)})`;
220
+ }
221
+ }
222
+ function isCommand(node) {
223
+ return (node.tag === "simpleCommand" ||
224
+ node.tag === "and" ||
225
+ node.tag === "or" ||
226
+ node.tag === "parens");
227
+ }
228
+ /**
229
+ * Turn an AST back into bash source.
230
+ *
231
+ * Accepts a whole script (commands joined with `; `) or any single node,
232
+ * down to a lone word — handy in tests and while debugging.
233
+ *
234
+ * For anything the parser produced, `bashParser(astToBash(ast))` gives an
235
+ * equal AST. That is equality of ASTs, not of strings: whitespace
236
+ * normalizes, and redirects move to the end of their command.
237
+ *
238
+ * For a hand-built AST the guarantee is deliberately weaker. The output is
239
+ * always safe and always produces the words asked for, but a tag can
240
+ * change on the way back: `literal "a b"` emits `'a b'` and returns as a
241
+ * `singleQuoted`. No emission preserves both the tag and the meaning, and
242
+ * meaning is the one worth keeping.
243
+ */
244
+ export function astToBash(node) {
245
+ if (Array.isArray(node)) {
246
+ return node.map(commandToBash).join("; ");
247
+ }
248
+ if (isCommand(node)) {
249
+ return commandToBash(node);
250
+ }
251
+ switch (node.tag) {
252
+ case "assignment":
253
+ return assignmentToBash(node);
254
+ case "redirect":
255
+ return redirectToBash(node);
256
+ default:
257
+ return wordToBash(node);
258
+ }
259
+ }
@@ -1,57 +1,55 @@
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 "./astToBash.js";
55
+ export * from "./parsers.js";
@@ -1,58 +1,55 @@
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 "./astToBash.js";
55
+ 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>;