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.
@@ -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,2 @@
1
+ /** AST types for the bash parser. See `index.ts` for the supported subset. */
2
+ export {};
@@ -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
- * The matched value isn't used as data; itemBody normalises blanks to "". */
102
- const blankContinuation = seqR(many(oneOf(" \t")), or(char("\n"), eof));
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
- * No silent fallback. CommonMark guarantees any non-empty buffer produces at
111
- * least one block (worst case, a paragraph). The only legitimate empty case
112
- * is an item whose first line is empty and which has no continuations — we
113
- * handle that explicitly so the invariant `non-empty buf => at least one
114
- * block` stays a real invariant. */
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
- if (buf === "")
122
+ const trimmed = buf.replace(/^\n+/, "");
123
+ if (trimmed === "")
117
124
  return [];
118
- const r = many1(lazy(() => blockEntry))(buf);
125
+ const r = many1(lazy(() => blockEntry))(trimmed);
119
126
  if (!r.success) {
120
- throw new Error(`reparseBlocks: failed on non-empty buffer: ${JSON.stringify(buf)}`);
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<unknown[]>;
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-definitions, then rewrite ref nodes to inline
20
- * links/images and strip the definitions. Id matching is case-insensitive. */
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
- for (const node of ast) {
30
+ function collect(node) {
31
+ if (Array.isArray(node)) {
32
+ node.forEach(collect);
33
+ return;
34
+ }
25
35
  if (!isObj(node))
26
- continue;
27
- const t = node.type;
36
+ return;
37
+ const obj = node;
38
+ const t = obj.type;
28
39
  if (t === "link-definition") {
29
- const def = node;
40
+ const def = obj;
30
41
  linkDefs.set(def.id.toLowerCase(), def);
31
42
  }
32
43
  else if (t === "footnote-definition") {
33
- const def = node;
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
- // recurse into known child-bearing fields. Nested lists now live inside
79
- // a list item's `content` (alongside other block children), so no
80
- // dedicated `sublist` traversal is needed.
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 ["content", "items", "rows"]) {
99
+ for (const key of CHILD_KEYS) {
83
100
  if (Array.isArray(obj[key]))
84
- out[key] = obj[key].map(walk);
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;
@@ -1,5 +1,6 @@
1
1
  import { trace } from "../trace.js";
2
2
  import { success } from "../types.js";
3
+ import { getParseState } from "../parseState.js";
3
4
  /**
4
5
  * `within` is a funny combinator. It finds zero or more instances of `parser` within the input.
5
6
  * It always succeeds and returns an array of results, each one being a matched or unmatched string.
@@ -59,6 +60,9 @@ import { success } from "../types.js";
59
60
  */
60
61
  export function within(parser) {
61
62
  return trace("within", (input) => {
63
+ // A search is speculation: probes at every offset must not leak
64
+ // commits into the committedFailure slot.
65
+ const slotBefore = getParseState().committedFailure;
62
66
  let start = 0;
63
67
  let current = 0;
64
68
  const results = [];
@@ -89,6 +93,7 @@ export function within(parser) {
89
93
  value: input.slice(start, current),
90
94
  });
91
95
  }
96
+ getParseState().committedFailure = slotBefore;
92
97
  return success(results, "");
93
98
  });
94
99
  }
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
  *
@@ -126,8 +138,19 @@ export declare function takeWhile1(charsOrPred: string | CharPredicate, expected
126
138
  export declare const anyChar: Parser<string>;
127
139
  /**
128
140
  * Wraps a parser with a human-readable label for error reporting.
129
- * On failure, suppresses any inner failure recordings and records only the label.
130
- * This produces clean error messages like `expected a digit` instead of `expected one of "0123456789"`.
141
+ *
142
+ * If the wrapped parser fails at the label's own position (it never got
143
+ * anywhere), its internal failure records are suppressed and only the
144
+ * label is recorded — producing clean messages like `expected a digit`
145
+ * instead of `expected one of "0123456789"`.
146
+ *
147
+ * If the wrapped parser fails STRICTLY INSIDE the labeled region (it
148
+ * consumed input first), its deeper, more specific record is kept and
149
+ * the label stays out of the way — so a grammar labeled at every level
150
+ * still surfaces the deepest thing the parser actually knew.
151
+ * (Comparing against the label's own position, rather than the previous
152
+ * record, is what keeps shallow labels clean while deep knowledge
153
+ * survives.)
131
154
  *
132
155
  * @param name - human-readable description of what the parser expects
133
156
  * @param parser - the parser to wrap
package/dist/parsers.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { many1WithJoin } from "./combinators.js";
2
- import { trace } from "./trace.js";
2
+ import { trace, getInputStr } from "./trace.js";
3
3
  import { recordFailure, saveRightmostFailure, restoreRightmostFailure } from "./rightmostFailure.js";
4
4
  import { captureSuccess, failure, success, } from "./types.js";
5
5
  import { escape } from "./utils.js";
@@ -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;
@@ -241,8 +241,19 @@ export const anyChar = trace("anyChar", (input) => {
241
241
  });
242
242
  /**
243
243
  * Wraps a parser with a human-readable label for error reporting.
244
- * On failure, suppresses any inner failure recordings and records only the label.
245
- * This produces clean error messages like `expected a digit` instead of `expected one of "0123456789"`.
244
+ *
245
+ * If the wrapped parser fails at the label's own position (it never got
246
+ * anywhere), its internal failure records are suppressed and only the
247
+ * label is recorded — producing clean messages like `expected a digit`
248
+ * instead of `expected one of "0123456789"`.
249
+ *
250
+ * If the wrapped parser fails STRICTLY INSIDE the labeled region (it
251
+ * consumed input first), its deeper, more specific record is kept and
252
+ * the label stays out of the way — so a grammar labeled at every level
253
+ * still surfaces the deepest thing the parser actually knew.
254
+ * (Comparing against the label's own position, rather than the previous
255
+ * record, is what keeps shallow labels clean while deep knowledge
256
+ * survives.)
246
257
  *
247
258
  * @param name - human-readable description of what the parser expects
248
259
  * @param parser - the parser to wrap
@@ -252,6 +263,21 @@ export function label(name, parser) {
252
263
  return (input) => {
253
264
  const saved = saveRightmostFailure();
254
265
  const result = parser(input);
266
+ if (!result.success && result.committed === true) {
267
+ // Committed failures carry their own story — no re-labeling,
268
+ // no restore games.
269
+ return result;
270
+ }
271
+ const afterChild = saveRightmostFailure();
272
+ const labelPos = getInputStr().length - input.length;
273
+ const childGotStrictlyDeeper = afterChild.pos > labelPos;
274
+ if (!result.success && childGotStrictlyDeeper) {
275
+ // The child FAILED somewhere specific past this label's start —
276
+ // keep its record; adding the label here would only blur it.
277
+ // On success the restore below still runs, scrubbing speculative
278
+ // records left by backtracked alternatives inside the region.
279
+ return result;
280
+ }
255
281
  restoreRightmostFailure(saved);
256
282
  if (!result.success)
257
283
  recordFailure(input, name);
@@ -18,14 +18,24 @@ export declare function buildLineTable(source: string): number[];
18
18
  * Both line and column are 0-based.
19
19
  */
20
20
  export declare function offsetToPosition(lineTable: number[], offset: number): Position;
21
+ /**
22
+ * Compose an inner-parse position with a base position (the point in the
23
+ * enclosing input where the inner input begins). Offsets and lines add;
24
+ * the base column applies only while still on the base line (inner line 0).
25
+ */
26
+ export declare function composePosition(base: Position, pos: Position): Position;
21
27
  /**
22
28
  * A zero-width parser that returns the current offset into the input string.
23
29
  * Requires `setInputStr` to have been called with the full input.
30
+ * Inside `runNested`, the offset is reported in the enclosing parse's
31
+ * coordinates (the state's `basePosition` is added).
24
32
  */
25
33
  export declare const getOffset: Parser<number>;
26
34
  /**
27
35
  * A zero-width parser that returns the current position (offset, line, column).
28
36
  * Requires `setInputStr` to have been called with the full input.
37
+ * Inside `runNested`, the position is reported in the enclosing parse's
38
+ * coordinates (composed with the state's `basePosition`).
29
39
  */
30
40
  export declare const getPosition: Parser<Position>;
31
41
  /**