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.
- package/dist/parsers/bash/astToBash.d.ts +50 -0
- package/dist/parsers/bash/astToBash.js +259 -0
- package/dist/parsers/bash/index.d.ts +48 -50
- package/dist/parsers/bash/index.js +48 -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
|
@@ -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
|
+
}
|
|
@@ -1,136 +1,85 @@
|
|
|
1
1
|
/** AST types for the bash parser. See `index.ts` for the supported subset. */
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
export type
|
|
2
|
+
export type Word = LiteralWord | PathWord | FlagWord | SingleQuotedWord | DoubleQuotedWord | VariableWord | InterpolatedVariableWord;
|
|
3
|
+
export type ScriptName = LiteralWord | PathWord;
|
|
4
|
+
export type LiteralWord = {
|
|
5
5
|
tag: "literal";
|
|
6
6
|
text: string;
|
|
7
|
-
}
|
|
7
|
+
};
|
|
8
|
+
export type PathWord = {
|
|
9
|
+
tag: "path";
|
|
10
|
+
text: string;
|
|
11
|
+
};
|
|
12
|
+
export type FlagWord = {
|
|
13
|
+
tag: "flag";
|
|
14
|
+
flagName: string;
|
|
15
|
+
flagValue?: string;
|
|
16
|
+
};
|
|
17
|
+
export type SingleQuotedWord = {
|
|
8
18
|
tag: "singleQuoted";
|
|
9
19
|
text: string;
|
|
10
|
-
}
|
|
20
|
+
};
|
|
21
|
+
export type DoubleQuotedWord = {
|
|
11
22
|
tag: "doubleQuoted";
|
|
12
|
-
parts:
|
|
13
|
-
}
|
|
23
|
+
parts: Word[];
|
|
24
|
+
};
|
|
25
|
+
export type VariableWord = {
|
|
14
26
|
tag: "variable";
|
|
15
27
|
name: string;
|
|
16
|
-
} | {
|
|
17
|
-
tag: "paramExpansion";
|
|
18
|
-
expression: string;
|
|
19
|
-
} | {
|
|
20
|
-
tag: "commandSubstitution";
|
|
21
|
-
command: List;
|
|
22
|
-
} | {
|
|
23
|
-
tag: "arithmeticExpansion";
|
|
24
|
-
expression: string;
|
|
25
|
-
};
|
|
26
|
-
export type BashWord = {
|
|
27
|
-
tag: "word";
|
|
28
|
-
parts: WordPart[];
|
|
29
28
|
};
|
|
29
|
+
/** A word built from two or more adjacent parts: `$HOME.txt`, `"a"b`,
|
|
30
|
+
* `"$HOME"/x`. These are ONE word in bash; split into separate words they
|
|
31
|
+
* become separate arguments and the command means something else.
|
|
32
|
+
*
|
|
33
|
+
* Quoted parts belong here as much as variables do — quoting a variable
|
|
34
|
+
* and appending to it (`"$HOME"/bin`) is idiomatic shell. */
|
|
35
|
+
export type InterpolatedVariableWord = {
|
|
36
|
+
tag: "interpolatedVariable";
|
|
37
|
+
parts: (LiteralWord | VariableWord | SingleQuotedWord | DoubleQuotedWord)[];
|
|
38
|
+
};
|
|
39
|
+
export declare function literalWord(text: string): LiteralWord;
|
|
30
40
|
/** `name=value` (or `name=` with a null value) before a command. */
|
|
31
41
|
export type Assignment = {
|
|
32
42
|
tag: "assignment";
|
|
33
43
|
name: string;
|
|
34
|
-
value:
|
|
44
|
+
value: Word | null;
|
|
35
45
|
};
|
|
36
|
-
/** A redirect like `> out.txt`, `2
|
|
37
|
-
* explicit file descriptor (`2` in `2>`), or
|
|
46
|
+
/** A redirect like `> out.txt`, `>> log`, `2> err.txt` or `< in.txt`.
|
|
47
|
+
* `fd` is the explicit file descriptor (`2` in `2>`), or undefined for the
|
|
48
|
+
* default. Only `>`, `>>`, `<` and `&>` are recognized; `2>&1`, heredocs
|
|
49
|
+
* and here-strings are rejected rather than parsed. */
|
|
38
50
|
export type Redirect = {
|
|
39
51
|
tag: "redirect";
|
|
40
|
-
fd
|
|
52
|
+
fd?: number;
|
|
41
53
|
op: string;
|
|
42
|
-
target:
|
|
54
|
+
target: Word;
|
|
43
55
|
};
|
|
44
56
|
export type SimpleCommand = {
|
|
45
57
|
tag: "simpleCommand";
|
|
46
58
|
assignments: Assignment[];
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
cond: List;
|
|
56
|
-
thenBody: List;
|
|
57
|
-
}[];
|
|
58
|
-
elseBody: List | null;
|
|
59
|
-
redirects: Redirect[];
|
|
60
|
-
};
|
|
61
|
-
export type LoopCommand = {
|
|
62
|
-
tag: "loop";
|
|
63
|
-
kind: "while" | "until";
|
|
64
|
-
cond: List;
|
|
65
|
-
body: List;
|
|
66
|
-
redirects: Redirect[];
|
|
67
|
-
};
|
|
68
|
-
export type ForCommand = {
|
|
69
|
-
tag: "for";
|
|
70
|
-
variable: string;
|
|
71
|
-
/** The `in word...` list, or null for the implicit `in "$@"`. */
|
|
72
|
-
words: BashWord[] | null;
|
|
73
|
-
body: List;
|
|
59
|
+
/** The command name, or null for an assignment-only line (`FOO=bar`).
|
|
60
|
+
* Bash requires at least one of a command name or an assignment. */
|
|
61
|
+
command: ScriptName | null;
|
|
62
|
+
/** Every word after the command name, in source order. There is no
|
|
63
|
+
* `subcommands` field: no syntactic rule separates `git status` from
|
|
64
|
+
* `echo status`, so splitting them would put a command's real
|
|
65
|
+
* arguments in whichever bucket the preceding word happened to pick. */
|
|
66
|
+
args: Word[];
|
|
74
67
|
redirects: Redirect[];
|
|
75
68
|
};
|
|
76
|
-
export type
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
};
|
|
82
|
-
export type CaseCommand = {
|
|
83
|
-
tag: "case";
|
|
84
|
-
subject: BashWord;
|
|
85
|
-
items: CaseItem[];
|
|
86
|
-
redirects: Redirect[];
|
|
87
|
-
};
|
|
88
|
-
export type Subshell = {
|
|
89
|
-
tag: "subshell";
|
|
90
|
-
body: List;
|
|
91
|
-
redirects: Redirect[];
|
|
92
|
-
};
|
|
93
|
-
export type Group = {
|
|
94
|
-
tag: "group";
|
|
95
|
-
body: List;
|
|
96
|
-
redirects: Redirect[];
|
|
97
|
-
};
|
|
98
|
-
/** `(( expression ))` as a command. The expression is kept as raw text. */
|
|
99
|
-
export type ArithmeticCommand = {
|
|
100
|
-
tag: "arithmeticCommand";
|
|
101
|
-
expression: string;
|
|
102
|
-
redirects: Redirect[];
|
|
103
|
-
};
|
|
104
|
-
export type FunctionDef = {
|
|
105
|
-
tag: "functionDef";
|
|
106
|
-
name: string;
|
|
107
|
-
body: Command;
|
|
108
|
-
};
|
|
109
|
-
export type Command = SimpleCommand | IfCommand | LoopCommand | ForCommand | CaseCommand | Subshell | Group | ArithmeticCommand | FunctionDef;
|
|
110
|
-
/** One or more commands joined by `|` (or `|&`), optionally negated. */
|
|
111
|
-
export type Pipeline = {
|
|
112
|
-
tag: "pipeline";
|
|
113
|
-
negated: boolean;
|
|
114
|
-
commands: Command[];
|
|
115
|
-
};
|
|
116
|
-
/** Pipelines joined by `&&` / `||`, left to right. */
|
|
117
|
-
export type AndOr = {
|
|
118
|
-
tag: "andOr";
|
|
119
|
-
first: Pipeline;
|
|
120
|
-
rest: {
|
|
121
|
-
op: "&&" | "||";
|
|
122
|
-
pipeline: Pipeline;
|
|
123
|
-
}[];
|
|
69
|
+
export type Command = SimpleCommand | And | Or | Parens;
|
|
70
|
+
export type And = {
|
|
71
|
+
tag: "and";
|
|
72
|
+
left: Command;
|
|
73
|
+
right: Command;
|
|
124
74
|
};
|
|
125
|
-
export type
|
|
126
|
-
tag: "
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
background: boolean;
|
|
75
|
+
export type Or = {
|
|
76
|
+
tag: "or";
|
|
77
|
+
left: Command;
|
|
78
|
+
right: Command;
|
|
130
79
|
};
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
tag: "list";
|
|
135
|
-
items: ListItem[];
|
|
80
|
+
export type Parens = {
|
|
81
|
+
tag: "parens";
|
|
82
|
+
command: Command;
|
|
136
83
|
};
|
|
84
|
+
export type BashNode = Command | SimpleCommand | Assignment | Redirect | Word | ScriptName;
|
|
85
|
+
export type BashAST = Command[];
|
package/package.json
CHANGED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
import { Parser, ParserResult } from "../../types.js";
|
|
2
|
-
import { Assignment, Command, Redirect } from "./types.js";
|
|
3
|
-
/** `> file`, `2>&1`, `<<< "$str"`, ... The file descriptor must be
|
|
4
|
-
* attached to the operator (`2>` redirects fd 2; `2 >` is an argument). */
|
|
5
|
-
export declare const redirectParser: Parser<Redirect>;
|
|
6
|
-
/** `name=value`, `name=`, or `name="$x"y` — value is a full word. */
|
|
7
|
-
export declare const assignmentParser: Parser<Assignment>;
|
|
8
|
-
/** Any single command: simple, compound (with trailing redirects), or a
|
|
9
|
-
* function definition. Declared as a function so the words/lists/commands
|
|
10
|
-
* module cycle is safe under any import order. */
|
|
11
|
-
export declare function commandParser(input: string): ParserResult<Command>;
|