tarsec 0.4.7 → 0.5.0
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/README.md +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/lexeme.d.ts +71 -0
- package/dist/lexeme.js +130 -0
- package/dist/parsers/bash/commands.d.ts +11 -0
- package/dist/parsers/bash/commands.js +197 -0
- package/dist/parsers/bash/index.d.ts +57 -0
- package/dist/parsers/bash/index.js +58 -0
- package/dist/parsers/bash/lexemes.d.ts +27 -0
- package/dist/parsers/bash/lexemes.js +44 -0
- package/dist/parsers/bash/lists.d.ts +18 -0
- package/dist/parsers/bash/lists.js +85 -0
- package/dist/parsers/bash/types.d.ts +136 -0
- package/dist/parsers/bash/types.js +2 -0
- package/dist/parsers/bash/words.d.ts +22 -0
- package/dist/parsers/bash/words.js +112 -0
- package/dist/parsers/markdown/blocks.js +17 -10
- package/dist/parsers/markdown/index.d.ts +2 -1
- package/dist/parsers/markdown/references.js +32 -19
- package/dist/parsers/markdown/types.d.ts +2 -0
- package/dist/parsers.d.ts +12 -0
- package/dist/parsers.js +1 -1
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -36,6 +36,7 @@ parser("hello there"); // failure
|
|
|
36
36
|
## Learning tarsec
|
|
37
37
|
- [A five minute introduction](/tutorials/5-minute-intro.md)
|
|
38
38
|
- [The three building blocks in tarsec](/tutorials/three-building-blocks.md)
|
|
39
|
+
- [Lexemes: your grammar's token vocabulary](/tutorials/lexemes.md)
|
|
39
40
|
- [API reference](https://egonschiele.github.io/tarsec/)
|
|
40
41
|
|
|
41
42
|
## Features
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/lexeme.d.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { CharPredicate } from "./parsers.js";
|
|
2
|
+
import { CaptureParser, Parser, PlainObject } from "./types.js";
|
|
3
|
+
/** Configuration for `makeLexemes`. */
|
|
4
|
+
export type LexemeConfig = {
|
|
5
|
+
/** Characters eaten after every lexeme, e.g. " \t". */
|
|
6
|
+
whitespace: string;
|
|
7
|
+
/** Line-comment marker (e.g. "#" or "//"); comments are eaten as whitespace,
|
|
8
|
+
* up to but NOT including the newline (newlines may be significant). */
|
|
9
|
+
lineComment?: string;
|
|
10
|
+
/** When true, `\` followed by a newline is also eaten as whitespace. */
|
|
11
|
+
lineContinuation?: boolean;
|
|
12
|
+
/** Charset or predicate for an identifier's first character.
|
|
13
|
+
* Defaults to letters and "_". */
|
|
14
|
+
identStart?: string | CharPredicate;
|
|
15
|
+
/** Charset or predicate for subsequent identifier characters.
|
|
16
|
+
* Defaults to letters, digits, and "_". */
|
|
17
|
+
identRest?: string | CharPredicate;
|
|
18
|
+
/** Words `identifier` refuses to match (and `keyword` accepts). */
|
|
19
|
+
keywords?: string[];
|
|
20
|
+
/** Charset or predicate for characters that can appear in operators.
|
|
21
|
+
* `operator` uses it for longest-match rejection: `operator("<")` refuses
|
|
22
|
+
* to match the front of `<=`. Defaults to common symbol characters. */
|
|
23
|
+
operatorChars?: string | CharPredicate;
|
|
24
|
+
};
|
|
25
|
+
export type Lexemes = {
|
|
26
|
+
/** Whitespace/comment skipper as a parser. Always succeeds. Use once at the
|
|
27
|
+
* top of a grammar to eat leading whitespace; lexemes handle the rest. */
|
|
28
|
+
whitespace: Parser<null>;
|
|
29
|
+
/** Whitespace/comment skipper as a total function: returns the input with
|
|
30
|
+
* leading whitespace removed. Handy where a parser result would force
|
|
31
|
+
* callers to handle a failure that cannot happen. */
|
|
32
|
+
skipWhitespace: (input: string) => string;
|
|
33
|
+
/** Run a parser, then eat trailing whitespace/comments. Capture-preserving. */
|
|
34
|
+
lexeme: {
|
|
35
|
+
<T, C extends PlainObject>(parser: CaptureParser<T, C>): CaptureParser<T, C>;
|
|
36
|
+
<T>(parser: Parser<T>): Parser<T>;
|
|
37
|
+
};
|
|
38
|
+
/** `lexeme(str(s))` — match a literal, eat trailing whitespace. */
|
|
39
|
+
symbol: <const S extends string>(s: S) => Parser<S>;
|
|
40
|
+
/** Match an identifier (per identStart/identRest), rejecting keywords. */
|
|
41
|
+
identifier: Parser<string>;
|
|
42
|
+
/** Match `s` only when not followed by an identRest character, so
|
|
43
|
+
* `keyword("if")` rejects "ifx". */
|
|
44
|
+
keyword: (s: string) => Parser<string>;
|
|
45
|
+
/** Match `s` only when not followed by an operator character, so
|
|
46
|
+
* `operator("<")` rejects the front of "<=". The symbolic counterpart
|
|
47
|
+
* of `keyword`. */
|
|
48
|
+
operator: (s: string) => Parser<string>;
|
|
49
|
+
/** Run a parser between "(" and ")", whitespace handled. Returns the
|
|
50
|
+
* inner result. */
|
|
51
|
+
parens: <T>(parser: Parser<T>) => Parser<T>;
|
|
52
|
+
/** Like `parens`, with "[" and "]". */
|
|
53
|
+
brackets: <T>(parser: Parser<T>) => Parser<T>;
|
|
54
|
+
/** Like `parens`, with "{" and "}". */
|
|
55
|
+
braces: <T>(parser: Parser<T>) => Parser<T>;
|
|
56
|
+
/** Zero or more of `parser`, separated by commas. */
|
|
57
|
+
commaSep: <T>(parser: Parser<T>) => Parser<T[]>;
|
|
58
|
+
/** One or more of `parser`, separated by commas. */
|
|
59
|
+
commaSep1: <T>(parser: Parser<T>) => Parser<T[]>;
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* Build a set of lexeme helpers: ordinary parsers that handle whitespace,
|
|
63
|
+
* comments, and keywords in one place. This is a *scannerless* lexeme layer
|
|
64
|
+
* (like Parsec's `makeTokenParser`), not a lexer — there is no separate pass
|
|
65
|
+
* and no token array.
|
|
66
|
+
*
|
|
67
|
+
* The discipline: every token-shaped parser eats its own *trailing*
|
|
68
|
+
* whitespace; eat *leading* whitespace once at the top with `whitespace`
|
|
69
|
+
* (or `skipWhitespace`).
|
|
70
|
+
*/
|
|
71
|
+
export declare function makeLexemes(config: LexemeConfig): Lexemes;
|
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,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>;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/** Pipelines, `&&`/`||` chains, and command lists.
|
|
2
|
+
*
|
|
3
|
+
* The core fail-closed rule lives here: commands are SEPARATED by `;`,
|
|
4
|
+
* `&`, or newline. A construct that stops early leaves text no separator
|
|
5
|
+
* precedes, which fails the enclosing parse instead of becoming a
|
|
6
|
+
* phantom second command (the way `files=(a b c)` would otherwise parse
|
|
7
|
+
* as an assignment followed by a subshell). */
|
|
8
|
+
import { lazy, many, optional, or, seq } from "../../combinators.js";
|
|
9
|
+
import { trace } from "../../trace.js";
|
|
10
|
+
import { bangToken, bashLexemes, linebreak, newlineToken, } from "./lexemes.js";
|
|
11
|
+
import { commandParser as commandParserImport } from "./commands.js";
|
|
12
|
+
const L = bashLexemes;
|
|
13
|
+
// Deferred to parse time: see the module-cycle note in commands.ts.
|
|
14
|
+
const commandParser = lazy(() => commandParserImport);
|
|
15
|
+
const pipeOperator = seq([or(L.operator("|&"), L.operator("|")), linebreak], (results) => results[0]);
|
|
16
|
+
// Explicit seq/many instead of sepBy1: `echo hi |` must FAIL, not parse
|
|
17
|
+
// as `echo hi` with the `|` silently swallowed.
|
|
18
|
+
const pipelineParser = seq([
|
|
19
|
+
optional(bangToken),
|
|
20
|
+
commandParser,
|
|
21
|
+
many(seq([pipeOperator, commandParser], (results) => results[1])),
|
|
22
|
+
], (results) => ({
|
|
23
|
+
tag: "pipeline",
|
|
24
|
+
negated: results[0] !== null,
|
|
25
|
+
commands: [results[1], ...results[2]],
|
|
26
|
+
}));
|
|
27
|
+
const andOrTail = seq([or(L.operator("&&"), L.operator("||")), linebreak, pipelineParser], (results) => ({
|
|
28
|
+
op: results[0],
|
|
29
|
+
pipeline: results[2],
|
|
30
|
+
}));
|
|
31
|
+
const andOrParser = seq([pipelineParser, many(andOrTail)], (results) => ({
|
|
32
|
+
tag: "andOr",
|
|
33
|
+
first: results[0],
|
|
34
|
+
rest: results[1],
|
|
35
|
+
}));
|
|
36
|
+
const itemSeparator = or(seq([L.operator("&"), linebreak], () => ({ background: true })), seq([L.operator(";"), linebreak], () => ({ background: false })), seq([newlineToken, linebreak], () => ({ background: false })));
|
|
37
|
+
const listBody = seq([
|
|
38
|
+
andOrParser,
|
|
39
|
+
many(seq([itemSeparator, andOrParser], (results) => ({
|
|
40
|
+
separator: results[0],
|
|
41
|
+
command: results[1],
|
|
42
|
+
}))),
|
|
43
|
+
optional(itemSeparator),
|
|
44
|
+
], (results) => {
|
|
45
|
+
const pairs = results[1];
|
|
46
|
+
const trailing = results[2];
|
|
47
|
+
const commands = [results[0], ...pairs.map((pair) => pair.command)];
|
|
48
|
+
// Separator i follows command i and carries its `&` marker.
|
|
49
|
+
const separators = [
|
|
50
|
+
...pairs.map((pair) => pair.separator),
|
|
51
|
+
trailing,
|
|
52
|
+
];
|
|
53
|
+
const items = commands.map((command, index) => {
|
|
54
|
+
var _a;
|
|
55
|
+
return ({
|
|
56
|
+
tag: "listItem",
|
|
57
|
+
command,
|
|
58
|
+
background: ((_a = separators[index]) === null || _a === void 0 ? void 0 : _a.background) === true,
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
return {
|
|
62
|
+
list: { tag: "list", items },
|
|
63
|
+
endedWithSeparator: trailing !== null,
|
|
64
|
+
};
|
|
65
|
+
});
|
|
66
|
+
const EMPTY_LIST = { tag: "list", items: [] };
|
|
67
|
+
const list0Impl = trace("bash:list0", seq([L.whitespace, linebreak, optional(listBody)], (results) => { var _a, _b; return (_b = (_a = results[2]) === null || _a === void 0 ? void 0 : _a.list) !== null && _b !== void 0 ? _b : EMPTY_LIST; }));
|
|
68
|
+
const list1Impl = trace("bash:list1", seq([L.whitespace, linebreak, listBody], (results) => results[2].list));
|
|
69
|
+
const list1WithEndImpl = seq([L.whitespace, linebreak, listBody], (results) => results[2]);
|
|
70
|
+
/** A possibly-empty command list. Empty lists are only legal where bash
|
|
71
|
+
* allows them: the top level, `$()`, and case-item bodies. Declared as a
|
|
72
|
+
* function so the words/lists/commands module cycle is safe under any
|
|
73
|
+
* import order. */
|
|
74
|
+
export function list0(input) {
|
|
75
|
+
return list0Impl(input);
|
|
76
|
+
}
|
|
77
|
+
/** A command list with at least one command — the body of every compound
|
|
78
|
+
* command, subshell, and group. */
|
|
79
|
+
export function list1(input) {
|
|
80
|
+
return list1Impl(input);
|
|
81
|
+
}
|
|
82
|
+
/** `list1`, but also reporting whether it ended with a separator. */
|
|
83
|
+
export function list1WithEnd(input) {
|
|
84
|
+
return list1WithEndImpl(input);
|
|
85
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/** AST types for the bash parser. See `index.ts` for the supported subset. */
|
|
2
|
+
/** One piece of a word. Adjacent parts concatenate: `e'f'"g"$h` is a single
|
|
3
|
+
* word of four parts. */
|
|
4
|
+
export type WordPart = {
|
|
5
|
+
tag: "literal";
|
|
6
|
+
text: string;
|
|
7
|
+
} | {
|
|
8
|
+
tag: "singleQuoted";
|
|
9
|
+
text: string;
|
|
10
|
+
} | {
|
|
11
|
+
tag: "doubleQuoted";
|
|
12
|
+
parts: WordPart[];
|
|
13
|
+
} | {
|
|
14
|
+
tag: "variable";
|
|
15
|
+
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
|
+
};
|
|
30
|
+
/** `name=value` (or `name=` with a null value) before a command. */
|
|
31
|
+
export type Assignment = {
|
|
32
|
+
tag: "assignment";
|
|
33
|
+
name: string;
|
|
34
|
+
value: BashWord | null;
|
|
35
|
+
};
|
|
36
|
+
/** A redirect like `> out.txt`, `2>&1`, or `<<< "$str"`. `fd` is the
|
|
37
|
+
* explicit file descriptor (`2` in `2>`), or null for the default. */
|
|
38
|
+
export type Redirect = {
|
|
39
|
+
tag: "redirect";
|
|
40
|
+
fd: number | null;
|
|
41
|
+
op: string;
|
|
42
|
+
target: BashWord;
|
|
43
|
+
};
|
|
44
|
+
export type SimpleCommand = {
|
|
45
|
+
tag: "simpleCommand";
|
|
46
|
+
assignments: Assignment[];
|
|
47
|
+
words: BashWord[];
|
|
48
|
+
redirects: Redirect[];
|
|
49
|
+
};
|
|
50
|
+
export type IfCommand = {
|
|
51
|
+
tag: "if";
|
|
52
|
+
cond: List;
|
|
53
|
+
thenBody: List;
|
|
54
|
+
elifs: {
|
|
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;
|
|
74
|
+
redirects: Redirect[];
|
|
75
|
+
};
|
|
76
|
+
export type CaseItem = {
|
|
77
|
+
patterns: BashWord[];
|
|
78
|
+
body: List;
|
|
79
|
+
/** `;;`, `;&`, `;;&`, or null when the final item ends at `esac`. */
|
|
80
|
+
terminator: string | null;
|
|
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
|
+
}[];
|
|
124
|
+
};
|
|
125
|
+
export type ListItem = {
|
|
126
|
+
tag: "listItem";
|
|
127
|
+
command: AndOr;
|
|
128
|
+
/** True when the command was followed by `&`. */
|
|
129
|
+
background: boolean;
|
|
130
|
+
};
|
|
131
|
+
/** A sequence of commands separated by `;`, `&`, or newlines — the top
|
|
132
|
+
* level of a script, and the body of every compound command. */
|
|
133
|
+
export type List = {
|
|
134
|
+
tag: "list";
|
|
135
|
+
items: ListItem[];
|
|
136
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Parser, ParserResult } from "../../types.js";
|
|
2
|
+
import { BashWord } from "./types.js";
|
|
3
|
+
/** Balanced parens, for `$(( ))` and `(( ))`. Sound because quotes are
|
|
4
|
+
* not delimiters in bash arithmetic. Declared as a function so the
|
|
5
|
+
* words/commands module cycle is safe under any import order. */
|
|
6
|
+
export declare function parenBody(input: string): ParserResult<string>;
|
|
7
|
+
/** A word: quoting and expansions handled, trailing whitespace eaten.
|
|
8
|
+
* Declared as a function so the words/lists/commands module cycle is
|
|
9
|
+
* safe under any import order. */
|
|
10
|
+
export declare function wordParser(input: string): ParserResult<BashWord>;
|
|
11
|
+
/** A word shaped like an assignment (`name=` / `name+=`). At command
|
|
12
|
+
* position this is never a plain command word: `name=` was already taken
|
|
13
|
+
* by the assignment parser, so what reaches here is unsupported syntax
|
|
14
|
+
* (append, arrays) and must fail rather than parse as a command. */
|
|
15
|
+
export declare const assignmentLookalike: Parser<string>;
|
|
16
|
+
/** A word at command position: rejects reserved words standing alone,
|
|
17
|
+
* which is how body lists stop at `fi`/`done`/`esac`. Quoted or extended
|
|
18
|
+
* words (`"fi"`, `fi.txt`) pass, matching bash. */
|
|
19
|
+
export declare function commandWord(input: string): ParserResult<BashWord>;
|
|
20
|
+
/** A run of identifier characters, shared with the assignment parser.
|
|
21
|
+
* Declared as a function for module-cycle safety. */
|
|
22
|
+
export declare function identifierRun(input: string): ParserResult<string>;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/** Words: quoting, escapes, and expansions. A word is one or more
|
|
2
|
+
* adjacent parts; only the whole word is a lexeme, so parts never eat
|
|
3
|
+
* trailing whitespace (`$(pwd) x` must not glue `x` onto the word). */
|
|
4
|
+
import { lazy, many, many1, many1WithJoin, map, not, or, seq, } from "../../combinators.js";
|
|
5
|
+
import { anyChar, char, compileCharPredicate, label, oneOf, str, takeWhile, takeWhile1, } from "../../parsers.js";
|
|
6
|
+
import { trace } from "../../trace.js";
|
|
7
|
+
import { bashLexemes, METACHARACTERS, reservedToken } from "./lexemes.js";
|
|
8
|
+
import { list0 as list0Import } from "./lists.js";
|
|
9
|
+
function literal(text) {
|
|
10
|
+
return { tag: "literal", text };
|
|
11
|
+
}
|
|
12
|
+
/** Merge adjacent literal parts so ASTs read naturally. */
|
|
13
|
+
function mergeLiterals(parts) {
|
|
14
|
+
const merged = [];
|
|
15
|
+
for (const part of parts) {
|
|
16
|
+
const previous = merged[merged.length - 1];
|
|
17
|
+
if (part.tag === "literal" && (previous === null || previous === void 0 ? void 0 : previous.tag) === "literal") {
|
|
18
|
+
merged[merged.length - 1] = literal(previous.text + part.text);
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
merged.push(part);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return merged;
|
|
25
|
+
}
|
|
26
|
+
const WORD_STOP = METACHARACTERS + "'\"`$\\";
|
|
27
|
+
const isWordStop = compileCharPredicate(WORD_STOP);
|
|
28
|
+
const NOT_SINGLE_QUOTE = (code) => code !== 0x27; // '
|
|
29
|
+
const plainRun = map(takeWhile1((code) => !isWordStop(code), "word characters"), literal);
|
|
30
|
+
// No escapes exist inside single quotes; scanning to the next quote is
|
|
31
|
+
// exactly bash's rule.
|
|
32
|
+
const singleQuoted = label("a single-quoted string", seq([char("'"), takeWhile(NOT_SINGLE_QUOTE), char("'")], (results) => ({ tag: "singleQuoted", text: results[1] })));
|
|
33
|
+
const IDENT_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_";
|
|
34
|
+
const SPECIAL_VARIABLES = "?!#@*$-0123456789";
|
|
35
|
+
// No lone-`$` fallback: a `$` that starts no supported expansion is a
|
|
36
|
+
// parse error, which is what makes `$'...'` and `$"..."` fail loudly.
|
|
37
|
+
const variablePart = seq([char("$"), or(oneOf(SPECIAL_VARIABLES), many1WithJoin(oneOf(IDENT_CHARS)))], (results) => ({ tag: "variable", name: results[1] }));
|
|
38
|
+
// Balanced braces by recursion instead of a depth counter: a body is a
|
|
39
|
+
// sequence of non-brace runs and nested `{...}` groups.
|
|
40
|
+
const braceBody = map(many(or(takeWhile1((code) => code !== 0x7b && code !== 0x7d, "brace content"), // { }
|
|
41
|
+
seq([char("{"), lazy(() => braceBody), char("}")], (results) => "{" + results[1] + "}"))), (chunks) => chunks.join(""));
|
|
42
|
+
const paramExpansion = seq([str("${"), braceBody, char("}")], (results) => ({
|
|
43
|
+
tag: "paramExpansion",
|
|
44
|
+
expression: results[1],
|
|
45
|
+
}));
|
|
46
|
+
const parenBodyImpl = map(many(or(takeWhile1((code) => code !== 0x28 && code !== 0x29, "paren content"), // ( )
|
|
47
|
+
seq([char("("), lazy(() => parenBodyImpl), char(")")], (results) => "(" + results[1] + ")"))), (chunks) => chunks.join(""));
|
|
48
|
+
/** Balanced parens, for `$(( ))` and `(( ))`. Sound because quotes are
|
|
49
|
+
* not delimiters in bash arithmetic. Declared as a function so the
|
|
50
|
+
* words/commands module cycle is safe under any import order. */
|
|
51
|
+
export function parenBody(input) {
|
|
52
|
+
return parenBodyImpl(input);
|
|
53
|
+
}
|
|
54
|
+
const arithmeticExpansion = seq([str("$(("), parenBody, str("))")], (results) => ({
|
|
55
|
+
tag: "arithmeticExpansion",
|
|
56
|
+
expression: results[1],
|
|
57
|
+
}));
|
|
58
|
+
// `char(")")`, not `symbol(")")`: word parts must not eat trailing
|
|
59
|
+
// whitespace. `list0` is deferred to parse time: see the module-cycle
|
|
60
|
+
// note in commands.ts.
|
|
61
|
+
const commandSubstitution = seq([str("$("), lazy(() => list0Import), char(")")], (results) => ({
|
|
62
|
+
tag: "commandSubstitution",
|
|
63
|
+
command: results[1],
|
|
64
|
+
}));
|
|
65
|
+
const unquotedEscape = seq([char("\\"), anyChar],
|
|
66
|
+
// Backslash-newline inside a word disappears (line continuation).
|
|
67
|
+
(results) => literal(results[1] === "\n" ? "" : results[1]));
|
|
68
|
+
const DQ_ESCAPABLE = '"$`\\';
|
|
69
|
+
const doubleQuoteEscape = or(seq([char("\\"), oneOf(DQ_ESCAPABLE)], (results) => literal(results[1])), seq([char("\\"), char("\n")], () => literal("")),
|
|
70
|
+
// Backslash before any other character stays literal.
|
|
71
|
+
map(char("\\"), () => literal("\\")));
|
|
72
|
+
const isDoubleQuoteStop = compileCharPredicate('"$`\\');
|
|
73
|
+
const doubleQuoteLiteral = map(takeWhile1((code) => !isDoubleQuoteStop(code), "string characters"), literal);
|
|
74
|
+
// No backtick alternative: an unescaped backtick inside double quotes
|
|
75
|
+
// fails the parse (backtick substitution is unsupported; use `$()`).
|
|
76
|
+
const doubleQuoted = seq([
|
|
77
|
+
char('"'),
|
|
78
|
+
many(or(arithmeticExpansion, commandSubstitution, paramExpansion, variablePart, doubleQuoteEscape, doubleQuoteLiteral)),
|
|
79
|
+
char('"'),
|
|
80
|
+
], (results) => ({
|
|
81
|
+
tag: "doubleQuoted",
|
|
82
|
+
parts: mergeLiterals(results[1]),
|
|
83
|
+
}));
|
|
84
|
+
const wordPart = or(singleQuoted, doubleQuoted, arithmeticExpansion, commandSubstitution, paramExpansion, variablePart, unquotedEscape, plainRun);
|
|
85
|
+
const wordParserImpl = bashLexemes.lexeme(trace("bash:word", map(many1(wordPart), (parts) => ({
|
|
86
|
+
tag: "word",
|
|
87
|
+
parts: mergeLiterals(parts),
|
|
88
|
+
}))));
|
|
89
|
+
/** A word: quoting and expansions handled, trailing whitespace eaten.
|
|
90
|
+
* Declared as a function so the words/lists/commands module cycle is
|
|
91
|
+
* safe under any import order. */
|
|
92
|
+
export function wordParser(input) {
|
|
93
|
+
return wordParserImpl(input);
|
|
94
|
+
}
|
|
95
|
+
/** A word shaped like an assignment (`name=` / `name+=`). At command
|
|
96
|
+
* position this is never a plain command word: `name=` was already taken
|
|
97
|
+
* by the assignment parser, so what reaches here is unsupported syntax
|
|
98
|
+
* (append, arrays) and must fail rather than parse as a command. */
|
|
99
|
+
export const assignmentLookalike = seq([many1WithJoin(oneOf(IDENT_CHARS)), or(str("+="), str("="))], (results) => results[0]);
|
|
100
|
+
const commandWordImpl = seq([not(reservedToken), not(assignmentLookalike), wordParser], (results) => results[2]);
|
|
101
|
+
/** A word at command position: rejects reserved words standing alone,
|
|
102
|
+
* which is how body lists stop at `fi`/`done`/`esac`. Quoted or extended
|
|
103
|
+
* words (`"fi"`, `fi.txt`) pass, matching bash. */
|
|
104
|
+
export function commandWord(input) {
|
|
105
|
+
return commandWordImpl(input);
|
|
106
|
+
}
|
|
107
|
+
const identifierRunImpl = many1WithJoin(oneOf(IDENT_CHARS));
|
|
108
|
+
/** A run of identifier characters, shared with the assignment parser.
|
|
109
|
+
* Declared as a function for module-cycle safety. */
|
|
110
|
+
export function identifierRun(input) {
|
|
111
|
+
return identifierRunImpl(input);
|
|
112
|
+
}
|
|
@@ -98,8 +98,13 @@ const continuationLine = (k) => map(seqC(indentOf(k), capture(manyTillStr("\n"),
|
|
|
98
98
|
/* A blank line at line-start. `blocks.ts` already defines `blankLine` below,
|
|
99
99
|
* but that variant starts with `\n` (designed for inter-block separators).
|
|
100
100
|
* Here we're already past the previous `\n`, so we need a line-start variant.
|
|
101
|
-
*
|
|
102
|
-
|
|
101
|
+
*
|
|
102
|
+
* We do NOT allow `eof` here: at end-of-input with no trailing whitespace
|
|
103
|
+
* this would succeed without consuming anything, and `many(itemContentLine(k))`
|
|
104
|
+
* would either spin or append a phantom blank. The final line of an item body
|
|
105
|
+
* is handled by `continuationLine`'s own `or(char("\n"), eof)`. The matched
|
|
106
|
+
* value isn't used as data; itemBody normalises blanks to "". */
|
|
107
|
+
const blankContinuation = seqR(many(oneOf(" \t")), char("\n"));
|
|
103
108
|
/* One line of item body (after the first). Heterogeneous return type:
|
|
104
109
|
* - continuationLine -> string (the dedented line text)
|
|
105
110
|
* - blankContinuation -> unknown (discardable seqR output)
|
|
@@ -107,17 +112,19 @@ const blankContinuation = seqR(many(oneOf(" \t")), or(char("\n"), eof));
|
|
|
107
112
|
const itemContentLine = (k) => or(continuationLine(k), blankContinuation);
|
|
108
113
|
/* Reparse an item's collected body as a sequence of blocks via `blockEntry`.
|
|
109
114
|
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
* block`
|
|
115
|
+
* List item bodies can legitimately begin with blank lines — e.g.
|
|
116
|
+
* `- \n\n - inner` collects `"\n- inner"`, and `- ` alone collects `"\n"`.
|
|
117
|
+
* Strip leading newlines (they carry no AST content) before handing to
|
|
118
|
+
* `many1(blockEntry)`. After trimming, the invariant `non-empty buf =>
|
|
119
|
+
* at least one block` holds (CommonMark falls back to a paragraph), so a
|
|
120
|
+
* parse failure here is a genuine parser bug worth surfacing. */
|
|
115
121
|
const reparseBlocks = (buf) => {
|
|
116
|
-
|
|
122
|
+
const trimmed = buf.replace(/^\n+/, "");
|
|
123
|
+
if (trimmed === "")
|
|
117
124
|
return [];
|
|
118
|
-
const r = many1(lazy(() => blockEntry))(
|
|
125
|
+
const r = many1(lazy(() => blockEntry))(trimmed);
|
|
119
126
|
if (!r.success) {
|
|
120
|
-
throw new Error(`reparseBlocks: failed on non-empty buffer: ${JSON.stringify(
|
|
127
|
+
throw new Error(`reparseBlocks: failed on non-empty buffer: ${JSON.stringify(trimmed)}`);
|
|
121
128
|
}
|
|
122
129
|
return r.result;
|
|
123
130
|
};
|
|
@@ -3,5 +3,6 @@ export * from "./inline.js";
|
|
|
3
3
|
export * from "./blocks.js";
|
|
4
4
|
export * from "./references.js";
|
|
5
5
|
export * from "./frontmatter.js";
|
|
6
|
+
import { MarkdownNode } from "./types.js";
|
|
6
7
|
import { Parser } from "../../types.js";
|
|
7
|
-
export declare const markdownParser: Parser<
|
|
8
|
+
export declare const markdownParser: Parser<MarkdownNode[]>;
|
|
@@ -16,27 +16,44 @@ export const linkDefinitionParser = seqC(set("type", "link-definition"), char("[
|
|
|
16
16
|
export const footnoteDefinitionParser = seqC(set("type", "footnote-definition"), str("[^"), capture(many1WithJoin(noneOf("] \n\t")), "id"), str("]:"), spaces, capture(many1WithJoin(noneOf("\n")), "content"));
|
|
17
17
|
/* Resolution pass.
|
|
18
18
|
*
|
|
19
|
-
* Walk the AST. Collect link
|
|
20
|
-
*
|
|
19
|
+
* Walk the AST. Collect link/footnote definitions (deeply — they can now
|
|
20
|
+
* appear nested inside list-item block children), then rewrite ref nodes to
|
|
21
|
+
* inline links/images and strip the definitions everywhere they appear. Id
|
|
22
|
+
* matching is case-insensitive. */
|
|
23
|
+
const CHILD_KEYS = ["content", "items", "rows"];
|
|
24
|
+
const isDef = (n) => isObj(n) &&
|
|
25
|
+
(n.type === "link-definition" ||
|
|
26
|
+
n.type === "footnote-definition");
|
|
21
27
|
export function resolveReferences(ast) {
|
|
22
28
|
const linkDefs = new Map();
|
|
23
29
|
const footnoteDefs = new Map();
|
|
24
|
-
|
|
30
|
+
function collect(node) {
|
|
31
|
+
if (Array.isArray(node)) {
|
|
32
|
+
node.forEach(collect);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
25
35
|
if (!isObj(node))
|
|
26
|
-
|
|
27
|
-
const
|
|
36
|
+
return;
|
|
37
|
+
const obj = node;
|
|
38
|
+
const t = obj.type;
|
|
28
39
|
if (t === "link-definition") {
|
|
29
|
-
const def =
|
|
40
|
+
const def = obj;
|
|
30
41
|
linkDefs.set(def.id.toLowerCase(), def);
|
|
31
42
|
}
|
|
32
43
|
else if (t === "footnote-definition") {
|
|
33
|
-
const def =
|
|
44
|
+
const def = obj;
|
|
34
45
|
footnoteDefs.set(def.id.toLowerCase(), def);
|
|
35
46
|
}
|
|
47
|
+
for (const key of CHILD_KEYS) {
|
|
48
|
+
if (Array.isArray(obj[key]))
|
|
49
|
+
obj[key].forEach(collect);
|
|
50
|
+
}
|
|
36
51
|
}
|
|
52
|
+
collect(ast);
|
|
37
53
|
function walk(node) {
|
|
38
|
-
if (Array.isArray(node))
|
|
39
|
-
return node.map(walk);
|
|
54
|
+
if (Array.isArray(node)) {
|
|
55
|
+
return node.filter((n) => !isDef(n)).map(walk);
|
|
56
|
+
}
|
|
40
57
|
if (!isObj(node))
|
|
41
58
|
return node;
|
|
42
59
|
const obj = node;
|
|
@@ -75,21 +92,17 @@ export function resolveReferences(ast) {
|
|
|
75
92
|
}
|
|
76
93
|
return { type: "inline-text", content: `[^${obj.id}]` };
|
|
77
94
|
}
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
95
|
+
// Recurse into known child-bearing fields. The array path through `walk`
|
|
96
|
+
// also strips nested link/footnote definitions, so list items, blockquotes,
|
|
97
|
+
// etc. all get the same treatment as the top-level array.
|
|
81
98
|
const out = Object.assign({}, obj);
|
|
82
|
-
for (const key of
|
|
99
|
+
for (const key of CHILD_KEYS) {
|
|
83
100
|
if (Array.isArray(obj[key]))
|
|
84
|
-
out[key] = obj[key]
|
|
101
|
+
out[key] = walk(obj[key]);
|
|
85
102
|
}
|
|
86
103
|
return out;
|
|
87
104
|
}
|
|
88
|
-
return ast
|
|
89
|
-
.filter((n) => !(isObj(n) &&
|
|
90
|
-
(n.type === "link-definition" ||
|
|
91
|
-
n.type === "footnote-definition")))
|
|
92
|
-
.map(walk);
|
|
105
|
+
return walk(ast);
|
|
93
106
|
}
|
|
94
107
|
function isObj(v) {
|
|
95
108
|
return typeof v === "object" && v !== null;
|
|
@@ -81,6 +81,8 @@ export type ListItem = {
|
|
|
81
81
|
checked?: boolean;
|
|
82
82
|
};
|
|
83
83
|
export type Block = Paragraph | Heading | CodeBlock | List | BlockQuote | HorizontalRule | Table | HTMLBlock | Image | LinkDef | FootnoteDef;
|
|
84
|
+
/** Top-level node in the markdown AST (optionally prefixed by frontmatter). */
|
|
85
|
+
export type MarkdownNode = Block | Frontmatter;
|
|
84
86
|
export type List = {
|
|
85
87
|
type: "list";
|
|
86
88
|
ordered: boolean;
|
package/dist/parsers.d.ts
CHANGED
|
@@ -47,6 +47,18 @@ export declare function noneOf(chars: string): Parser<string>;
|
|
|
47
47
|
* ```
|
|
48
48
|
*/
|
|
49
49
|
export type CharPredicate = (code: number) => boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Compile a string of allowed characters or a user-supplied predicate
|
|
52
|
+
* into a fast `CharPredicate`. For string inputs whose characters are
|
|
53
|
+
* all ASCII (code points < 128), the result is a single 128-byte
|
|
54
|
+
* `Uint8Array` lookup — one array read per character test. Non-ASCII
|
|
55
|
+
* characters in the string fall back to a `Set<number>` check.
|
|
56
|
+
* Predicates pass through unchanged.
|
|
57
|
+
*
|
|
58
|
+
* @param charsOrPred - a string of allowed characters or a predicate function
|
|
59
|
+
* @returns - a `CharPredicate` suitable for use in tight scanning loops
|
|
60
|
+
*/
|
|
61
|
+
export declare function compileCharPredicate(charsOrPred: string | CharPredicate): CharPredicate;
|
|
50
62
|
/**
|
|
51
63
|
* A faster version of many/ manyWithJoin for character-class scanning.
|
|
52
64
|
*
|
package/dist/parsers.js
CHANGED
|
@@ -104,7 +104,7 @@ export function noneOf(chars) {
|
|
|
104
104
|
* @param charsOrPred - a string of allowed characters or a predicate function
|
|
105
105
|
* @returns - a `CharPredicate` suitable for use in tight scanning loops
|
|
106
106
|
*/
|
|
107
|
-
function compileCharPredicate(charsOrPred) {
|
|
107
|
+
export function compileCharPredicate(charsOrPred) {
|
|
108
108
|
if (typeof charsOrPred === "function")
|
|
109
109
|
return charsOrPred;
|
|
110
110
|
const chars = charsOrPred;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tarsec",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "A parser combinator library for TypeScript, inspired by Parsec.",
|
|
5
5
|
"homepage": "https://github.com/egonSchiele/tarsec",
|
|
6
6
|
"scripts": {
|
|
@@ -24,6 +24,11 @@
|
|
|
24
24
|
"import": "./dist/parsers/markdown/index.js",
|
|
25
25
|
"require": "./dist/parsers/markdown/index.js",
|
|
26
26
|
"types": "./dist/parsers/markdown/index.d.ts"
|
|
27
|
+
},
|
|
28
|
+
"./parsers/bash": {
|
|
29
|
+
"import": "./dist/parsers/bash/index.js",
|
|
30
|
+
"require": "./dist/parsers/bash/index.js",
|
|
31
|
+
"types": "./dist/parsers/bash/index.d.ts"
|
|
27
32
|
}
|
|
28
33
|
},
|
|
29
34
|
"type": "module",
|