tarsec 0.4.6 → 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.
@@ -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
+ }
@@ -1,5 +1,5 @@
1
1
  import { Parser } from "../../types.js";
2
- import { Heading, CodeBlock, BlockQuote, Paragraph, HorizontalRule, List, Table, HTMLBlock } from "./types.js";
2
+ import { Heading, CodeBlock, BlockQuote, Paragraph, HorizontalRule, List, Table, HTMLBlock, Block } from "./types.js";
3
3
  export { imageParser } from "./inline.js";
4
4
  export declare const headingParser: Parser<Heading>;
5
5
  export declare const codeBlockParser: Parser<CodeBlock>;
@@ -12,3 +12,5 @@ export declare const htmlBlockParser: Parser<HTMLBlock>;
12
12
  export declare const tableParser: Parser<Table>;
13
13
  export declare const horizontalRuleParser: Parser<HorizontalRule>;
14
14
  export declare const paragraphParser: Parser<Paragraph>;
15
+ export declare const blockAlt: Parser<Block>;
16
+ export declare const blockEntry: Parser<Block>;
@@ -1,8 +1,10 @@
1
1
  import { seqC, seqR, capture, optional, or, manyTillStr, many1Till, exactly, many, many1, many1WithJoin, map, not, lazy, } from "../../combinators.js";
2
- import { str, spaces, char, eof, set, alphanum, oneOf, noneOf, } from "../../parsers.js";
2
+ import { str, spaces, char, eof, set, alphanum, oneOf, noneOf, newline, } from "../../parsers.js";
3
+ import { success, failure } from "../../types.js";
4
+ import { linkDefinitionParser, footnoteDefinitionParser, } from "./references.js";
3
5
  import { digit, letter } from "../../parsers.js";
4
6
  import { manyTill } from "../../combinators.js";
5
- import { inlineMarkdownParser, softBreakParser } from "./inline.js";
7
+ import { inlineMarkdownParser, imageParser, softBreakParser } from "./inline.js";
6
8
  export { imageParser } from "./inline.js";
7
9
  const languageChar = or(alphanum, oneOf("_+#.-"));
8
10
  const languageTag = many1WithJoin(languageChar);
@@ -76,39 +78,117 @@ export const setextHeadingParser = map(_setextRaw, (caps) => {
76
78
  : [{ type: "inline-text", content: caps.content }],
77
79
  };
78
80
  });
79
- const unorderedMarker = map(oneOf("-*+"), () => ({ ord: false, start: 1 }));
80
- const orderedMarker = map(seqC(capture(many1WithJoin(digit), "digits"), char(".")), ({ digits }) => ({ ord: true, start: parseInt(digits, 10) }));
81
+ const unorderedMarker = map(oneOf("-*+"), () => ({ ord: false, start: 1, width: 1 }));
82
+ const orderedMarker = map(seqC(capture(many1WithJoin(digit), "digits"), char(".")), ({ digits }) => ({
83
+ ord: true,
84
+ start: parseInt(digits, 10),
85
+ width: digits.length + 1,
86
+ }));
87
+ const anyMarker = or(unorderedMarker, orderedMarker);
81
88
  const indentOf = (n) => n > 0 ? str(" ".repeat(n)) : str("");
82
89
  /* GFM task-list checkbox: `[ ]` (unchecked), `[x]` or `[X]` (checked).
83
90
  * Must be followed by a single space (consumed) to count as a checkbox. */
84
91
  const taskCheckbox = map(seqC(char("["), capture(or(char(" "), char("x"), char("X")), "mark"), str("] ")), ({ mark }) => mark !== " ");
85
- const itemHeadOf = (indent, markerParser) => map(seqC(indentOf(indent), capture(markerParser, "marker"), char(" "), capture(optional(taskCheckbox), "checked"), capture(manyTillStr("\n"), "line"), or(char("\n"), eof)), ({ marker, checked, line }) => {
86
- const raw = { marker, line };
87
- if (checked !== null)
88
- raw.checked = checked;
89
- return raw;
90
- });
91
- const parseInline = (line) => {
92
- const inline = many1(inlineMarkdownParser)(line);
93
- return inline.success ? inline.result : [];
92
+ /* The first line of an item's content everything after the marker+space
93
+ * (and optional checkbox), up to `\n`. */
94
+ const itemFirstLine = map(seqC(capture(manyTillStr("\n"), "line"), or(char("\n"), eof)), ({ line }) => line);
95
+ /* A continuation line: exactly k spaces of indent, then the rest of the line.
96
+ * The strip IS the parse — `indentOf(k)` consumes the leading indent. */
97
+ const continuationLine = (k) => map(seqC(indentOf(k), capture(manyTillStr("\n"), "line"), or(char("\n"), eof)), ({ line }) => line);
98
+ /* A blank line at line-start. `blocks.ts` already defines `blankLine` below,
99
+ * but that variant starts with `\n` (designed for inter-block separators).
100
+ * Here we're already past the previous `\n`, so we need a line-start variant.
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"));
108
+ /* One line of item body (after the first). Heterogeneous return type:
109
+ * - continuationLine -> string (the dedented line text)
110
+ * - blankContinuation -> unknown (discardable seqR output)
111
+ * itemBody normalises blanks to "" inline so a wrapper `map` is unnecessary. */
112
+ const itemContentLine = (k) => or(continuationLine(k), blankContinuation);
113
+ /* Reparse an item's collected body as a sequence of blocks via `blockEntry`.
114
+ *
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. */
121
+ const reparseBlocks = (buf) => {
122
+ const trimmed = buf.replace(/^\n+/, "");
123
+ if (trimmed === "")
124
+ return [];
125
+ const r = many1(lazy(() => blockEntry))(trimmed);
126
+ if (!r.success) {
127
+ throw new Error(`reparseBlocks: failed on non-empty buffer: ${JSON.stringify(trimmed)}`);
128
+ }
129
+ return r.result;
94
130
  };
95
- // One list item: an item-head followed by an optional sublist at +2 indent.
96
- const itemWithSublist = (indent, markerParser) => map(seqC(capture(itemHeadOf(indent, markerParser), "raw"), capture(optional(lazy(() => listParserAt(indent + 2))), "sublist")), ({ raw, sublist }) => {
97
- const item = { content: parseInline(raw.line) };
98
- if (sublist)
99
- item.sublist = sublist;
100
- if (raw.checked !== undefined)
101
- item.checked = raw.checked;
102
- return { marker: raw.marker, item };
131
+ /* Full item content: first line + zero-or-more continuation lines, joined
132
+ * with `\n` and reparsed. */
133
+ const itemBody = (k) => map(seqC(capture(itemFirstLine, "first"), capture(many(itemContentLine(k)), "rest")), ({ first, rest }) => {
134
+ const lines = [
135
+ first,
136
+ ...rest.map((r) => (typeof r === "string" ? r : "")),
137
+ ];
138
+ return reparseBlocks(lines.join("\n"));
103
139
  });
104
- // A list of one or more items that all share a marker family.
105
- const listOf = (indent, markerParser) => map(seqC(capture(itemWithSublist(indent, markerParser), "first"), capture(many(itemWithSublist(indent, markerParser)), "rest")), ({ first, rest }) => ({
106
- type: "list",
107
- ordered: first.marker.ord,
108
- start: first.marker.start,
109
- items: [first.item, ...rest.map((r) => r.item)],
110
- }));
111
- const listParserAt = (indent) => or(listOf(indent, unorderedMarker), listOf(indent, orderedMarker));
140
+ /* Item parser at marker column c. Handwritten because `k` is derived from the
141
+ * parsed marker's width `seqC` runs its children in fixed order with no data
142
+ * flow between them, so we sequence by hand to thread `marker.width` into
143
+ * `itemBody(k)`. Same shape as `inlineCodeParser` (inline.ts:179), which
144
+ * threads the opener's tick count into its closer for the same reason. */
145
+ const itemParser = (c) => (input) => {
146
+ const head = seqC(indentOf(c), capture(anyMarker, "marker"), char(" "), capture(optional(taskCheckbox), "checked"))(input);
147
+ if (!head.success)
148
+ return head;
149
+ const { marker, checked } = head.result;
150
+ const k = c + marker.width + 1;
151
+ const body = itemBody(k)(head.rest);
152
+ if (!body.success)
153
+ return body;
154
+ const item = { content: body.result };
155
+ if (checked !== null)
156
+ item.checked = checked;
157
+ return success({ marker, item }, body.rest);
158
+ };
159
+ /* An item whose marker family matches the locked one. Fails (without
160
+ * consuming) if the family changed. */
161
+ const itemMatching = (c, ord) => (input) => {
162
+ const r = itemParser(c)(input);
163
+ if (!r.success)
164
+ return r;
165
+ if (r.result.marker.ord !== ord) {
166
+ return failure("list marker family changed", input);
167
+ }
168
+ return r;
169
+ };
170
+ /* A list at column c. Marker family locked by the first item; subsequent
171
+ * items use `itemMatching` to enforce the lock. */
172
+ const listOf = (c) => (input) => {
173
+ const first = itemParser(c)(input);
174
+ if (!first.success)
175
+ return first;
176
+ const ord = first.result.marker.ord;
177
+ const rest = many(itemMatching(c, ord))(first.rest);
178
+ if (!rest.success)
179
+ return rest;
180
+ const items = [
181
+ first.result.item,
182
+ ...rest.result.map((r) => r.item),
183
+ ];
184
+ return success({
185
+ type: "list",
186
+ ordered: ord,
187
+ start: first.result.marker.start,
188
+ items,
189
+ }, rest.rest);
190
+ };
191
+ const listParserAt = (indent) => listOf(indent);
112
192
  export const listParser = listParserAt(0);
113
193
  /* Tables.
114
194
  *
@@ -187,3 +267,11 @@ seqR(char("<"), or(letter, oneOf("/!?"))));
187
267
  const paragraphSoftBreak = map(seqR(softBreakParser, not(blockInterrupt)), () => ({ type: "inline-soft-break" }));
188
268
  const paragraphInline = map(seqC(not(blankLine), capture(or(paragraphSoftBreak, inlineMarkdownParser), "node")), ({ node }) => node);
189
269
  export const paragraphParser = map(many1(paragraphInline), (content) => ({ type: "paragraph", content: content }));
270
+ /* Top-level block dispatcher. Lives here (rather than in index.ts) so
271
+ * `listParser`'s `reparseBlocks` can recurse through it via `lazy`. */
272
+ export const blockAlt = or(setextHeadingParser, horizontalRuleParser, headingParser, codeBlockParser, indentedCodeBlockParser, tableParser, blockQuoteParser, listParser, htmlBlockParser, linkDefinitionParser, footnoteDefinitionParser, paragraphParser, imageParser);
273
+ /* A block followed by zero-or-more trailing newlines. Blocks differ in
274
+ * whether they consume their own terminating "\n" (e.g. headingParser does,
275
+ * codeBlock doesn't), so `sepBy(many1(newline), block)` won't work — it
276
+ * would fail to separate two blocks when the first already ate its newline. */
277
+ export const blockEntry = map(seqC(capture(blockAlt, "b"), many(newline)), ({ b }) => b);
@@ -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[]>;
@@ -3,18 +3,11 @@ export * from "./inline.js";
3
3
  export * from "./blocks.js";
4
4
  export * from "./references.js";
5
5
  export * from "./frontmatter.js";
6
- import { seq, or, optional, many, capture, seqC, map } from "../../combinators.js";
7
- import { spaces, newline } from "../../parsers.js";
8
- import { headingParser, codeBlockParser, blockQuoteParser, paragraphParser, imageParser, horizontalRuleParser, setextHeadingParser, indentedCodeBlockParser, listParser, tableParser, htmlBlockParser, } from "./blocks.js";
9
- import { linkDefinitionParser, footnoteDefinitionParser, resolveReferences, } from "./references.js";
6
+ import { seq, optional, many, map } from "../../combinators.js";
7
+ import { spaces } from "../../parsers.js";
8
+ import { blockEntry } from "./blocks.js";
9
+ import { resolveReferences } from "./references.js";
10
10
  import { frontmatterParser } from "./frontmatter.js";
11
- const blockAlt = or(setextHeadingParser, horizontalRuleParser, headingParser, codeBlockParser, indentedCodeBlockParser, tableParser, blockQuoteParser, listParser, htmlBlockParser, linkDefinitionParser, footnoteDefinitionParser, paragraphParser, imageParser);
12
- // A block followed by zero-or-more trailing newlines. Blocks differ in whether
13
- // they consume their own terminating "\n" (e.g. headingParser does, codeBlock
14
- // doesn't), so we can't use sepBy(many1(newline), block) — it would fail to
15
- // separate two blocks when the first already ate its newline (e.g. a heading
16
- // directly followed by a list with no intervening blank line).
17
- const blockEntry = map(seqC(capture(blockAlt, "b"), many(newline)), ({ b }) => b);
18
11
  const _markdownParser = seq([
19
12
  optional(frontmatterParser),
20
13
  optional(spaces),