tarsec 0.4.7 → 0.5.1

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/lexeme.js ADDED
@@ -0,0 +1,130 @@
1
+ import { sepBy, sepBy1, seq } from "./combinators.js";
2
+ import { compileCharPredicate, str } from "./parsers.js";
3
+ import { trace } from "./trace.js";
4
+ import { recordFailure } from "./rightmostFailure.js";
5
+ import { failure, success, } from "./types.js";
6
+ const LETTERS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
7
+ const DIGITS = "0123456789";
8
+ const OPERATOR_CHARS = "+-*/<>=!&|^%~?:.";
9
+ const BACKSLASH_CODE = 0x5c;
10
+ const NEWLINE = "\n";
11
+ /**
12
+ * Build a set of lexeme helpers: ordinary parsers that handle whitespace,
13
+ * comments, and keywords in one place. This is a *scannerless* lexeme layer
14
+ * (like Parsec's `makeTokenParser`), not a lexer — there is no separate pass
15
+ * and no token array.
16
+ *
17
+ * The discipline: every token-shaped parser eats its own *trailing*
18
+ * whitespace; eat *leading* whitespace once at the top with `whitespace`
19
+ * (or `skipWhitespace`).
20
+ */
21
+ export function makeLexemes(config) {
22
+ var _a, _b, _c, _d;
23
+ const isWhitespaceChar = compileCharPredicate(config.whitespace);
24
+ // Treat an empty marker as absent: `startsWith("", i)` is always true, so
25
+ // "" would make the comment branch loop forever at a newline.
26
+ const commentMarker = config.lineComment || undefined;
27
+ const allowContinuation = config.lineContinuation === true;
28
+ const skipWhitespace = (input) => {
29
+ let index = 0;
30
+ const length = input.length;
31
+ // Terminates: every branch either advances `index` or breaks.
32
+ while (index < length) {
33
+ if (isWhitespaceChar(input.charCodeAt(index))) {
34
+ index++;
35
+ continue;
36
+ }
37
+ if (allowContinuation &&
38
+ input.charCodeAt(index) === BACKSLASH_CODE &&
39
+ input[index + 1] === NEWLINE) {
40
+ index += 2;
41
+ continue;
42
+ }
43
+ if (commentMarker !== undefined && input.startsWith(commentMarker, index)) {
44
+ const newlineIndex = input.indexOf(NEWLINE, index);
45
+ if (newlineIndex === -1) {
46
+ index = length;
47
+ }
48
+ else {
49
+ index = newlineIndex; // stop AT the newline, don't consume it
50
+ }
51
+ continue;
52
+ }
53
+ break;
54
+ }
55
+ return input.slice(index);
56
+ };
57
+ const whitespace = trace("lexeme:whitespace", (input) => success(null, skipWhitespace(input)));
58
+ const lexeme = ((parser) => (input) => {
59
+ const result = parser(input);
60
+ if (!result.success)
61
+ return result;
62
+ return Object.assign(Object.assign({}, result), { rest: skipWhitespace(result.rest) });
63
+ });
64
+ const symbol = (s) => lexeme(str(s));
65
+ const isIdentStart = compileCharPredicate((_a = config.identStart) !== null && _a !== void 0 ? _a : LETTERS + "_");
66
+ const isIdentRest = compileCharPredicate((_b = config.identRest) !== null && _b !== void 0 ? _b : LETTERS + DIGITS + "_");
67
+ const keywordSet = new Set((_c = config.keywords) !== null && _c !== void 0 ? _c : []);
68
+ const identifier = lexeme(trace("lexeme:identifier", (input) => {
69
+ if (input.length === 0 || !isIdentStart(input.charCodeAt(0))) {
70
+ recordFailure(input, "an identifier");
71
+ return failure("expected an identifier", input);
72
+ }
73
+ let end = 1;
74
+ // Terminates: `end` strictly increases toward input.length.
75
+ while (end < input.length && isIdentRest(input.charCodeAt(end)))
76
+ end++;
77
+ const name = input.slice(0, end);
78
+ if (keywordSet.has(name)) {
79
+ recordFailure(input, "an identifier");
80
+ return failure(`expected an identifier, got keyword ${name}`, input);
81
+ }
82
+ return success(name, input.slice(end));
83
+ }));
84
+ /** Match `s` as a whole token: it must not be followed by a character
85
+ * from `continuationChars`, so a shorter token never steals the front of
86
+ * a longer one. Shared by `keyword` (identifier characters) and
87
+ * `operator` (operator characters). */
88
+ function wholeToken(kind, s, isContinuation) {
89
+ return lexeme(trace(`lexeme:${kind}(${s})`, (input) => {
90
+ if (!input.startsWith(s)) {
91
+ recordFailure(input, `${kind} ${s}`);
92
+ return failure(`expected ${kind} ${s}`, input);
93
+ }
94
+ const followingCode = input.charCodeAt(s.length);
95
+ // charCodeAt returns NaN past the end of input; NaN fails the
96
+ // predicate, so a token at end-of-input matches.
97
+ if (!Number.isNaN(followingCode) && isContinuation(followingCode)) {
98
+ recordFailure(input, `${kind} ${s}`);
99
+ return failure(`expected ${kind} ${s}`, input);
100
+ }
101
+ return success(s, input.slice(s.length));
102
+ }));
103
+ }
104
+ const keyword = (s) => wholeToken("keyword", s, isIdentRest);
105
+ const isOperatorChar = compileCharPredicate((_d = config.operatorChars) !== null && _d !== void 0 ? _d : OPERATOR_CHARS);
106
+ const operator = (s) => wholeToken("operator", s, isOperatorChar);
107
+ function delimited(open, close, parser) {
108
+ return seq([symbol(open), parser, symbol(close)], (results) => results[1]);
109
+ }
110
+ const parens = (parser) => delimited("(", ")", parser);
111
+ const brackets = (parser) => delimited("[", "]", parser);
112
+ const braces = (parser) => delimited("{", "}", parser);
113
+ const comma = symbol(",");
114
+ const commaSep = (parser) => sepBy(comma, parser);
115
+ const commaSep1 = (parser) => sepBy1(comma, parser);
116
+ return {
117
+ whitespace,
118
+ skipWhitespace,
119
+ lexeme,
120
+ symbol,
121
+ identifier,
122
+ keyword,
123
+ operator,
124
+ parens,
125
+ brackets,
126
+ braces,
127
+ commaSep,
128
+ commaSep1,
129
+ };
130
+ }
@@ -0,0 +1,25 @@
1
+ import type { Position } from "./position.js";
2
+ import type { ParserFailure, ParserResult } from "./types.js";
3
+ /**
4
+ * All mutable state belonging to one running parse. Everything tarsec
5
+ * used to keep in scattered module-level variables lives here, so a
6
+ * nested parse (see `runNested`) can swap in a fresh state and restore
7
+ * the old one by reassigning a single reference.
8
+ */
9
+ export type ParseState = {
10
+ inputStr: string;
11
+ rightmostFailurePos: number;
12
+ rightmostFailureExpected: string[];
13
+ /** Per-`memo`-instance caches, keyed by each memo's numeric id. */
14
+ memoCaches: Map<number, Map<string, ParserResult<any>>>;
15
+ /** Offset added to every derived position, so a nested parse reports
16
+ * positions in the enclosing parse's coordinates. Zero at top level. */
17
+ basePosition: Position;
18
+ /** The most recent committed failure of this parse, if any. Preferred
19
+ * over the rightmost record by `getErrorMessage`. See `committed`. */
20
+ committedFailure: ParserFailure | null;
21
+ };
22
+ export declare function createParseState(inputStr: string, basePosition?: Position): ParseState;
23
+ export declare function getParseState(): ParseState;
24
+ /** Replace the current parse state, returning the previous one. */
25
+ export declare function swapParseState(next: ParseState): ParseState;
@@ -0,0 +1,21 @@
1
+ const ZERO_POSITION = { offset: 0, line: 0, column: 0 };
2
+ export function createParseState(inputStr, basePosition = ZERO_POSITION) {
3
+ return {
4
+ inputStr,
5
+ rightmostFailurePos: -1,
6
+ rightmostFailureExpected: [],
7
+ memoCaches: new Map(),
8
+ basePosition: Object.assign({}, basePosition),
9
+ committedFailure: null,
10
+ };
11
+ }
12
+ let currentState = createParseState("");
13
+ export function getParseState() {
14
+ return currentState;
15
+ }
16
+ /** Replace the current parse state, returning the previous one. */
17
+ export function swapParseState(next) {
18
+ const previous = currentState;
19
+ currentState = next;
20
+ return previous;
21
+ }
@@ -0,0 +1,11 @@
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>;
@@ -0,0 +1,197 @@
1
+ /** Commands: redirects, assignments, simple commands, compound commands,
2
+ * and function definitions. */
3
+ import { lazy, many, many1, many1WithJoin, map, not, optional, or, peek, seq, } from "../../combinators.js";
4
+ import { char, oneOf, str } from "../../parsers.js";
5
+ import { trace } from "../../trace.js";
6
+ import { failure, success } from "../../types.js";
7
+ import { bashLexemes, groupClose, groupOpen, linebreak, newlineToken, wordBoundary, } from "./lexemes.js";
8
+ import { list0 as list0Import, list1 as list1Import, list1WithEnd as list1WithEndImport, } from "./lists.js";
9
+ import { commandWord as commandWordImport, identifierRun as identifierRunImport, parenBody as parenBodyImport, wordParser as wordParserImport, } from "./words.js";
10
+ const L = bashLexemes;
11
+ // The words/lists/commands modules form an import cycle. Deferring every
12
+ // cross-module reference to parse time (via lazy) keeps the cycle safe
13
+ // regardless of module evaluation order or transform.
14
+ const list0 = lazy(() => list0Import);
15
+ const list1 = lazy(() => list1Import);
16
+ const commandWord = lazy(() => commandWordImport);
17
+ const identifierRun = lazy(() => identifierRunImport);
18
+ const parenBody = lazy(() => parenBodyImport);
19
+ const wordParser = lazy(() => wordParserImport);
20
+ const list1WithEnd = lazy(() => list1WithEndImport);
21
+ // ---------------------------------------------------------------------------
22
+ // Redirects and assignments
23
+ // ---------------------------------------------------------------------------
24
+ // Longest first, so ">>" wins over ">". The bare "<" refuses a following
25
+ // "<" so heredocs (unsupported) stay unparsed rather than half-parsed.
26
+ const redirectOp = or(str("&>>"), str("&>"), str(">>"), str(">|"), str(">&"), str("<<<"), str("<&"), str("<>"), str(">"), seq([str("<"), not(char("<"))], (results) => results[0]));
27
+ const fileDescriptor = many1WithJoin(oneOf("0123456789"));
28
+ /** `> file`, `2>&1`, `<<< "$str"`, ... The file descriptor must be
29
+ * attached to the operator (`2>` redirects fd 2; `2 >` is an argument). */
30
+ export const redirectParser = trace("bash:redirect", seq([optional(fileDescriptor), L.lexeme(redirectOp), wordParser], (results) => ({
31
+ tag: "redirect",
32
+ fd: results[0] === null ? null : parseInt(results[0], 10),
33
+ op: results[1],
34
+ target: results[2],
35
+ })));
36
+ /** `name=value`, `name=`, or `name="$x"y` — value is a full word. */
37
+ export const assignmentParser = L.lexeme(trace("bash:assignment", seq([identifierRun, str("="), optional(wordParser)], (results) => ({
38
+ tag: "assignment",
39
+ name: results[0],
40
+ value: results[2],
41
+ }))));
42
+ // ---------------------------------------------------------------------------
43
+ // Simple commands
44
+ // ---------------------------------------------------------------------------
45
+ const prefixItem = or(assignmentParser, redirectParser);
46
+ function buildSimpleCommand(prefix, first, rest) {
47
+ const assignments = prefix.filter((item) => item.tag === "assignment");
48
+ const redirects = [
49
+ ...prefix.filter((item) => item.tag === "redirect"),
50
+ ...rest.filter((item) => item.tag === "redirect"),
51
+ ];
52
+ const words = [
53
+ ...(first === null ? [] : [first]),
54
+ ...rest.filter((item) => item.tag === "word"),
55
+ ];
56
+ return { tag: "simpleCommand", assignments, words, redirects };
57
+ }
58
+ // "At least one of prefix/word" expressed as the two valid shapes.
59
+ const simpleCommandParser = trace("bash:simpleCommand", or(seq([many(prefixItem), commandWord, many(or(redirectParser, wordParser))], (results) => buildSimpleCommand(results[0], results[1], results[2])), seq([many1(prefixItem)], (results) => buildSimpleCommand(results[0], null, []))));
60
+ // ---------------------------------------------------------------------------
61
+ // Compound commands
62
+ // ---------------------------------------------------------------------------
63
+ const elifClause = seq([L.keyword("elif"), list1, L.keyword("then"), list1], (results) => ({ cond: results[1], thenBody: results[3] }));
64
+ const elseClause = seq([L.keyword("else"), list1], (results) => results[1]);
65
+ const ifParser = seq([
66
+ L.keyword("if"), list1,
67
+ L.keyword("then"), list1,
68
+ many(elifClause),
69
+ optional(elseClause),
70
+ L.keyword("fi"),
71
+ ], (results) => ({
72
+ tag: "if",
73
+ cond: results[1],
74
+ thenBody: results[3],
75
+ elifs: results[4],
76
+ elseBody: results[5],
77
+ redirects: [],
78
+ }));
79
+ const loopParser = seq([
80
+ or(L.keyword("while"), L.keyword("until")), list1,
81
+ L.keyword("do"), list1, L.keyword("done"),
82
+ ], (results) => ({
83
+ tag: "loop",
84
+ kind: results[0],
85
+ cond: results[1],
86
+ body: results[3],
87
+ redirects: [],
88
+ }));
89
+ const forParser = seq([
90
+ L.keyword("for"), L.identifier,
91
+ optional(seq([L.keyword("in"), many(wordParser)], (results) => results[1])),
92
+ optional(or(L.operator(";"), newlineToken)),
93
+ linebreak,
94
+ L.keyword("do"), list1, L.keyword("done"),
95
+ ], (results) => ({
96
+ tag: "for",
97
+ variable: results[1],
98
+ words: results[2],
99
+ body: results[6],
100
+ redirects: [],
101
+ }));
102
+ // Patterns via explicit seq/many: sepBy1 silently swallows a trailing
103
+ // separator when the next parse fails, which would hide a real error.
104
+ const casePatterns = seq([
105
+ wordParser,
106
+ many(seq([L.operator("|"), wordParser], (results) => results[1])),
107
+ ], (results) => [results[0], ...results[1]]);
108
+ // Every item needs `;;` (or `;&` / `;;&`) unless `esac` follows directly.
109
+ const caseTerminator = or(seq([or(L.operator(";;&"), L.operator(";;"), L.operator(";&")), linebreak], (results) => results[0]), map(peek(L.keyword("esac")), () => null));
110
+ // Without a leading `(`, a bare `esac` cannot start a pattern — it ends
111
+ // the case statement (bash rejects `case x in esac) ...`).
112
+ const bareEsac = seq([str("esac"), wordBoundary], () => "esac");
113
+ const caseItemStart = or(seq([L.symbol("("), casePatterns], (results) => results[1]), seq([not(bareEsac), casePatterns], (results) => results[1]));
114
+ const caseItemParser = seq([caseItemStart, L.symbol(")"), list0, caseTerminator], (results) => ({
115
+ patterns: results[0],
116
+ body: results[2],
117
+ terminator: results[3],
118
+ }));
119
+ const caseParser = seq([
120
+ L.keyword("case"), wordParser, L.keyword("in"), linebreak,
121
+ many(caseItemParser),
122
+ L.keyword("esac"),
123
+ ], (results) => ({
124
+ tag: "case",
125
+ subject: results[1],
126
+ items: results[4],
127
+ redirects: [],
128
+ }));
129
+ const arithmeticCommand = L.lexeme(seq([str("(("), parenBody, str("))")], (results) => ({
130
+ tag: "arithmeticCommand",
131
+ expression: results[1],
132
+ redirects: [],
133
+ })));
134
+ const subshellParser = seq([L.symbol("("), list1, L.symbol(")")], (results) => ({
135
+ tag: "subshell",
136
+ body: results[1],
137
+ redirects: [],
138
+ }));
139
+ /** Can `}` directly follow this command, as it can in bash? True for a
140
+ * compound command with no trailing redirects (`{ { echo a; } }`), and
141
+ * for a function definition whose body qualifies. A redirect re-enters
142
+ * word context (`{ { cat; } > log }` is rejected by bash), and `(( ))`
143
+ * does not qualify. */
144
+ function closesGroup(command) {
145
+ if (command.tag === "functionDef")
146
+ return closesGroup(command.body);
147
+ const isCompound = command.tag === "if" ||
148
+ command.tag === "loop" ||
149
+ command.tag === "for" ||
150
+ command.tag === "case" ||
151
+ command.tag === "subshell" ||
152
+ command.tag === "group";
153
+ return isCompound && command.redirects.length === 0;
154
+ }
155
+ function endsAtCommandPosition({ list, endedWithSeparator }) {
156
+ if (endedWithSeparator)
157
+ return true;
158
+ const lastItem = list.items[list.items.length - 1];
159
+ const andOr = lastItem.command;
160
+ const lastPipeline = andOr.rest.length > 0 ? andOr.rest[andOr.rest.length - 1].pipeline : andOr.first;
161
+ return closesGroup(lastPipeline.commands[lastPipeline.commands.length - 1]);
162
+ }
163
+ // `}` is a reserved word, recognized only at command position: the group
164
+ // body must end with a separator or a bare compound command. Without this
165
+ // check, `{ echo a }` would fail OPEN (bash treats that `}` as an
166
+ // argument to echo and rejects the unterminated group).
167
+ const groupParser = (input) => {
168
+ const result = seq([groupOpen, list1WithEnd, groupClose], (results) => results[1])(input);
169
+ if (!result.success)
170
+ return result;
171
+ if (!endsAtCommandPosition(result.result)) {
172
+ return failure("expected ; & or newline before }", input);
173
+ }
174
+ return success({ tag: "group", body: result.result.list, redirects: [] }, result.rest);
175
+ };
176
+ const compoundParser = or(ifParser, loopParser, forParser, caseParser, arithmeticCommand, subshellParser, groupParser);
177
+ const compoundWithRedirects = seq([compoundParser, many(redirectParser)], (results) => (Object.assign(Object.assign({}, results[0]), { redirects: results[1] })));
178
+ const functionParens = seq([L.symbol("("), L.symbol(")")], () => "()");
179
+ // "Needs `function` or `()`" expressed as the two valid shapes. The body
180
+ // must be a compound command (bash rejects `f() echo hi` and nested
181
+ // definitions like `f() g() { :; }`); trailing redirects are allowed.
182
+ const functionDefParser = or(seq([L.keyword("function"), L.identifier, optional(functionParens), linebreak, compoundWithRedirects], (results) => ({
183
+ tag: "functionDef",
184
+ name: results[1],
185
+ body: results[4],
186
+ })), seq([L.identifier, functionParens, linebreak, compoundWithRedirects], (results) => ({
187
+ tag: "functionDef",
188
+ name: results[0],
189
+ body: results[3],
190
+ })));
191
+ const commandParserImpl = trace("bash:command", or(compoundWithRedirects, functionDefParser, simpleCommandParser));
192
+ /** Any single command: simple, compound (with trailing redirects), or a
193
+ * function definition. Declared as a function so the words/lists/commands
194
+ * module cycle is safe under any import order. */
195
+ export function commandParser(input) {
196
+ return commandParserImpl(input);
197
+ }
@@ -0,0 +1,57 @@
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.
6
+ *
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:
40
+ *
41
+ * ```ts
42
+ * setInputStr(input);
43
+ * resetRightmostFailure();
44
+ * const result = bashParser(input);
45
+ * if (!result.success) console.log(getErrorMessage());
46
+ * ```
47
+ */
48
+ 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>;
@@ -0,0 +1,58 @@
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.
6
+ *
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:
40
+ *
41
+ * ```ts
42
+ * setInputStr(input);
43
+ * resetRightmostFailure();
44
+ * const result = bashParser(input);
45
+ * if (!result.success) console.log(getErrorMessage());
46
+ * ```
47
+ */
48
+ 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]);
@@ -0,0 +1,27 @@
1
+ import { Parser } from "../../types.js";
2
+ /** Words that are reserved at command position (and rejected as
3
+ * identifiers). Includes constructs this parser deliberately does not
4
+ * support — `select`, `coproc`, `time`, `[[ ]]` — so they fail the parse
5
+ * instead of parsing as ordinary words. */
6
+ export declare const WORD_RESERVED: string[];
7
+ export declare const bashLexemes: import("../../lexeme.js").Lexemes;
8
+ /** Characters that end a word. `{` and `}` are metacharacters here
9
+ * (unlike bash) because brace expansion is unsupported: a brace can only
10
+ * be a group delimiter or quoted. */
11
+ export declare const METACHARACTERS = " \t\n|&;()<>{}";
12
+ /** The next character (unconsumed) would end a word, or input is done. */
13
+ export declare const wordBoundary: Parser<string | null>;
14
+ /** A reserved word as a whole token. Order doesn't matter: each
15
+ * alternative carries its own boundary check, so `do` cannot steal the
16
+ * front of `done`. */
17
+ export declare const reservedToken: Parser<string>;
18
+ /** `{`, `}`, and `!` must be followed by whitespace. This is stricter
19
+ * than bash on purpose: `!(` is extglob syntax (unsupported), not
20
+ * negation. */
21
+ export declare const groupOpen: Parser<string>;
22
+ export declare const groupClose: Parser<string>;
23
+ export declare const bangToken: Parser<string>;
24
+ /** A significant newline, with trailing blanks and comments skipped. */
25
+ export declare const newlineToken: Parser<"\n">;
26
+ /** Zero or more blank (or comment-only) lines. */
27
+ export declare const linebreak: Parser<"\n"[]>;
@@ -0,0 +1,44 @@
1
+ /** Lexeme layer and shared tokens for the bash grammar. */
2
+ import { many, or, peek, seq } from "../../combinators.js";
3
+ import { makeLexemes } from "../../lexeme.js";
4
+ import { char, eof, oneOf, str } from "../../parsers.js";
5
+ /** Words that are reserved at command position (and rejected as
6
+ * identifiers). Includes constructs this parser deliberately does not
7
+ * support — `select`, `coproc`, `time`, `[[ ]]` — so they fail the parse
8
+ * instead of parsing as ordinary words. */
9
+ export const WORD_RESERVED = [
10
+ "if", "then", "elif", "else", "fi",
11
+ "do", "done", "while", "until", "for", "in",
12
+ "case", "esac", "function",
13
+ "select", "coproc", "time", "[[", "]]",
14
+ ];
15
+ const RESERVED_TOKENS = [...WORD_RESERVED, "{", "}", "!"];
16
+ export const bashLexemes = makeLexemes({
17
+ // Newlines are command separators, never skippable whitespace.
18
+ whitespace: " \t",
19
+ lineComment: "#",
20
+ lineContinuation: true,
21
+ operatorChars: "&|;<>",
22
+ keywords: WORD_RESERVED,
23
+ });
24
+ /** Characters that end a word. `{` and `}` are metacharacters here
25
+ * (unlike bash) because brace expansion is unsupported: a brace can only
26
+ * be a group delimiter or quoted. */
27
+ export const METACHARACTERS = " \t\n|&;()<>{}";
28
+ /** The next character (unconsumed) would end a word, or input is done. */
29
+ export const wordBoundary = or(peek(oneOf(METACHARACTERS)), eof);
30
+ /** A reserved word as a whole token. Order doesn't matter: each
31
+ * alternative carries its own boundary check, so `do` cannot steal the
32
+ * front of `done`. */
33
+ export const reservedToken = or(...RESERVED_TOKENS.map((word) => seq([str(word), wordBoundary], (results) => results[0])));
34
+ const whitespaceOrEnd = or(peek(oneOf(" \t\n")), eof);
35
+ /** `{`, `}`, and `!` must be followed by whitespace. This is stricter
36
+ * than bash on purpose: `!(` is extglob syntax (unsupported), not
37
+ * negation. */
38
+ export const groupOpen = bashLexemes.lexeme(seq([str("{"), whitespaceOrEnd], () => "{"));
39
+ export const groupClose = bashLexemes.lexeme(seq([str("}"), wordBoundary], () => "}"));
40
+ export const bangToken = bashLexemes.lexeme(seq([str("!"), whitespaceOrEnd], () => "!"));
41
+ /** A significant newline, with trailing blanks and comments skipped. */
42
+ export const newlineToken = bashLexemes.lexeme(char("\n"));
43
+ /** Zero or more blank (or comment-only) lines. */
44
+ export const linebreak = many(newlineToken);
@@ -0,0 +1,18 @@
1
+ import { ParserResult } from "../../types.js";
2
+ import { List } from "./types.js";
3
+ /** A parsed list plus whether it ended with an explicit separator —
4
+ * group parsing needs this to decide whether `}` is at command position. */
5
+ export type ListWithEnd = {
6
+ list: List;
7
+ endedWithSeparator: boolean;
8
+ };
9
+ /** A possibly-empty command list. Empty lists are only legal where bash
10
+ * allows them: the top level, `$()`, and case-item bodies. Declared as a
11
+ * function so the words/lists/commands module cycle is safe under any
12
+ * import order. */
13
+ export declare function list0(input: string): ParserResult<List>;
14
+ /** A command list with at least one command — the body of every compound
15
+ * command, subshell, and group. */
16
+ export declare function list1(input: string): ParserResult<List>;
17
+ /** `list1`, but also reporting whether it ended with a separator. */
18
+ export declare function list1WithEnd(input: string): ParserResult<ListWithEnd>;