lilmd 0.1.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/BENCHMARK.md ADDED
@@ -0,0 +1,106 @@
1
+ # Benchmark summary
2
+
3
+ Why `mdq` uses a hand-rolled scanner instead of an off-the-shelf markdown
4
+ parser. Numbers captured on Bun 1.3.11 against MDN content (`mdn/content`,
5
+ sparse-checkout of `files/en-us/web/{javascript,css,html,api}`, concatenated
6
+ into fixtures of ~100 KB, ~1 MB, and ~10 MB).
7
+
8
+ ## Parse speed (large, 10 MB)
9
+
10
+ | library | positions? | throughput |
11
+ |---|:---:|---:|
12
+ | **scanner (in `src/scan.ts`)** | ✅ | **~180 MB/s** |
13
+ | markdown-it | ✅ | ~26 MB/s |
14
+ | mdast-util-from-markdown | ✅ | ~1 MB/s (skipped — too slow) |
15
+ | marked lexer | ❌ | >90 s on a 1 MB input (unusable) |
16
+ | md4w (WASM) | ❌ | ~42 MB/s, errors on 10 MB JSON output |
17
+
18
+ ## End-to-end `mdq read` (10 MB, find a section + slice its body)
19
+
20
+ | strategy | time |
21
+ |---|---:|
22
+ | **scanner** | **55 ms** (IO-bound) |
23
+ | markdown-it | 422 ms (~7.6× slower) |
24
+ | mdast-util-from-markdown | ~60 s (~1000× slower) |
25
+
26
+ All three strategies agree on the matched section and exact body bytes —
27
+ the scanner is correct, not just fast.
28
+
29
+ ## CLI cold start
30
+
31
+ | framework | cold start |
32
+ |---|---:|
33
+ | `node:util.parseArgs` (built-in) | ~16 ms |
34
+ | cac | ~16 ms |
35
+ | citty | ~23 ms |
36
+
37
+ ## Why the scanner wins
38
+
39
+ `mdq`'s read-path commands (`toc`, `read`, `ls`, `grep`) only need two facts
40
+ from the markdown:
41
+
42
+ 1. ATX headings — level, text, line number
43
+ 2. Fenced code block boundaries (so `#` inside code doesn't become a heading)
44
+
45
+ Everything else — links, emphasis, tables, footnotes, nested lists, HTML
46
+ blocks — is irrelevant to "list the headings" and "slice the body between
47
+ line N and line M". A full CommonMark parser spends 95% of its budget on
48
+ grammar `mdq` immediately throws away. The scanner skips all of that, runs
49
+ in a single pass over character codes, and is IO-bound on 10 MB of prose.
50
+
51
+ ## The final stack
52
+
53
+ - **Parsing**: hand-rolled scanner in `src/scan.ts`. Zero dependencies.
54
+ - **CLI**: `node:util.parseArgs` + a ~20-line subcommand switch. Zero
55
+ dependencies.
56
+ - **Future write-path commands** (`set`, `insert`, `mv`, `links`, `code`)
57
+ may add `markdown-it` as the only runtime dep when they land — it's the
58
+ only position-preserving parser that scales.
59
+ - **Rejected**: `mdast-util-from-markdown` (25× slower than markdown-it
60
+ despite wrapping the same micromark tokenizer), `marked` (catastrophic
61
+ regex backtracking on prose), `md4w` (no source positions + JSON
62
+ marshaller bug at 10 MB), `citty` (~45% slower cold start than the
63
+ built-in for no meaningful feature we need), `cac` (same cold-start
64
+ class as built-in but adds a dep).
65
+
66
+ ## Reproducing
67
+
68
+ The raw benchmark scripts were removed to keep the repo minimal. To rerun
69
+ them, check out an earlier commit on this branch (look for `dev/bench/` in
70
+ git history) or rewrite them against the methodology above:
71
+
72
+ - Small/medium/large fixtures built by concatenating MDN markdown files.
73
+ - Per-(library, fixture) wall budgets of 4–8 s with hard iteration caps, so
74
+ a pathological parser (we're looking at you, marked) can't hang the run.
75
+ - Trimmed mean of the fastest 50% of iterations per combo.
76
+ - Full-throughput results written incrementally so a timeout still yields
77
+ partial data.
78
+
79
+ ## Integration-test fixture
80
+
81
+ `src/__fixtures__/mdn-array.md` is a tiny (~42 KB, 1,298 lines, 112
82
+ headings) fixture committed into the repo and exercised by
83
+ `src/integration.test.ts`. It's a concatenation of 8 MDN
84
+ `Array.prototype.*` reference pages hoisted under synthetic H1 wrappers.
85
+ Small enough to commit, big enough to catch regressions the synthetic unit
86
+ fixtures can miss (Kuma macros, JSX-flavored HTML, tables, real fenced
87
+ code, nested lists).
88
+
89
+ Licensed CC BY-SA 2.5, © Mozilla Contributors. Regenerate with:
90
+
91
+ ```bash
92
+ # 1. Sparse-clone the MDN Array docs (no blobs, no tree outside array/)
93
+ git clone --depth 1 --filter=blob:none --sparse \
94
+ https://github.com/mdn/content.git /tmp/mdn
95
+ cd /tmp/mdn
96
+ git sparse-checkout set \
97
+ files/en-us/web/javascript/reference/global_objects/array
98
+
99
+ # 2. Concatenate 8 method pages under synthetic H1s
100
+ # (see the fixture's own header comment for the exact list)
101
+ # 3. Prepend the attribution block from the existing fixture header
102
+ ```
103
+
104
+ If you change the file list or the synthetic wrappers, update the relevant
105
+ assertions in `src/integration.test.ts` — a couple of them pin exact counts
106
+ ("8 matches, showing first 3") that are tied to the 8-page choice.
package/README.md ADDED
@@ -0,0 +1,113 @@
1
+ ## `mdq` - Markdown as a Database for Agents
2
+ `mdq` is a CLI for working with large MD files designed for agents.
3
+
4
+ *Wait, but why?* Agent knowledge, docs, memory keeps growing.
5
+ MDQ allows you to dump it all in one file and effeciently read/write/navigate its contents.
6
+
7
+ Features:
8
+ - fast navigation, complex read selectors, link extraction
9
+ - complex section selectors
10
+ - designed to save as much context as possible
11
+ - can write, append, remove entire sections
12
+ - can run in Node/Bun
13
+ - optimized for speed
14
+ - can be used by humans and **agents**
15
+ - uses Bun as tooling: to test, control deps etc.
16
+
17
+ ### Help
18
+
19
+ ```bash
20
+ # start here!
21
+ # both commands print short documentation for the agent
22
+ > mdq
23
+ > mdq --help
24
+ ```
25
+
26
+ ### Overview & table of contents
27
+
28
+ First, the agent gets file overview and table of contents.
29
+
30
+ ```bash
31
+ # renders toc + stats; line ranges are inclusive, 1-indexed
32
+ # --depth=N to limit nesting, --flat for a flat list
33
+ > mdq file.md
34
+
35
+ file.md L1-450 12 headings
36
+ # MDQ L1-450
37
+ ## Getting Started L5-80
38
+ ### Installation L31-80
39
+ ## Community L301-450
40
+ ```
41
+
42
+ ### Reading sections
43
+
44
+ ```bash
45
+ > mdq read file.md "# MDQ"
46
+ > mdq file.md "# MDQ" # alias!
47
+ # prints the contents of the MDQ section
48
+
49
+ # descendant selector (any depth under the parent)
50
+ > mdq file.md "MDQ > Installation"
51
+
52
+ # direct child only
53
+ > mdq file.md "MDQ >> Installation"
54
+
55
+ # level filter (H2 only)
56
+ > mdq file.md "##Installation"
57
+
58
+ # exact match (default is fuzzy, case-insensitive)
59
+ > mdq file.md "=Installation"
60
+
61
+ # regex
62
+ > mdq file.md "/install(ation)?/"
63
+
64
+ # by default no more than 25 matches are printed; if more, mdq prints a hint
65
+ # about --max-results=N
66
+ # --max-lines=N truncates long bodies (shows "… N more lines")
67
+ # --body-only skips subsections, --no-body prints headings only
68
+ ```
69
+
70
+ ### For humans only
71
+
72
+ ```bash
73
+ # --pretty renders the section body as syntax-highlighted terminal markdown
74
+ # (for humans; piped output stays plain unless FORCE_COLOR is set)
75
+ > mdq file.md --pretty "Installation"
76
+
77
+ # nicely formatted markdown
78
+ ```
79
+
80
+ ### Searching & extracting
81
+
82
+ ```bash
83
+ > mdq ls file.md "Getting Started" # direct children of a section
84
+ > mdq grep file.md "pattern" # regex search, grouped by section
85
+ > mdq links file.md ["selector"] # extract links with section path
86
+ > mdq code file.md "Install" [--lang=ts] # extract code blocks
87
+ ```
88
+
89
+ ### Writing
90
+
91
+ `mdq` treats sections as addressable records: you can replace, append,
92
+ insert, move, or rename them without rewriting the whole file. Every write
93
+ supports `--dry-run`, which prints a unified diff instead of touching disk —
94
+ perfect for agent-authored edits that a human (or another agent) reviews
95
+ before applying.
96
+
97
+ ```bash
98
+ > mdq set file.md "Install" < body.md # replace section body
99
+ > mdq append file.md "Install" < body.md
100
+ > mdq insert file.md --after "Install" < new.md
101
+ > mdq rm file.md "Old"
102
+ > mdq mv file.md "From" "To" # re-parent, fixes heading levels
103
+ > mdq rename file.md "Old" "New"
104
+ > mdq promote|demote file.md "Section" # shift heading level ±1
105
+ ```
106
+
107
+ ### Output
108
+
109
+ ```bash
110
+ # human-readable by default; --json for machine output
111
+ # use - as filename to read from stdin
112
+ > cat big.md | mdq - "Install"
113
+ ```
package/dist/index.cjs ADDED
@@ -0,0 +1,364 @@
1
+ var import_node_module = require("node:module");
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __moduleCache = /* @__PURE__ */ new WeakMap;
7
+ var __toCommonJS = (from) => {
8
+ var entry = __moduleCache.get(from), desc;
9
+ if (entry)
10
+ return entry;
11
+ entry = __defProp({}, "__esModule", { value: true });
12
+ if (from && typeof from === "object" || typeof from === "function")
13
+ __getOwnPropNames(from).map((key) => !__hasOwnProp.call(entry, key) && __defProp(entry, key, {
14
+ get: () => from[key],
15
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
16
+ }));
17
+ __moduleCache.set(from, entry);
18
+ return entry;
19
+ };
20
+ var __export = (target, all) => {
21
+ for (var name in all)
22
+ __defProp(target, name, {
23
+ get: all[name],
24
+ enumerable: true,
25
+ configurable: true,
26
+ set: (newValue) => all[name] = () => newValue
27
+ });
28
+ };
29
+
30
+ // src/index.ts
31
+ var exports_src = {};
32
+ __export(exports_src, {
33
+ truncateBody: () => truncateBody,
34
+ scan: () => scan,
35
+ renderToc: () => renderToc,
36
+ renderSection: () => renderSection,
37
+ pathOf: () => pathOf,
38
+ parseSelector: () => parseSelector,
39
+ match: () => match,
40
+ countLines: () => countLines,
41
+ buildSections: () => buildSections
42
+ });
43
+ module.exports = __toCommonJS(exports_src);
44
+
45
+ // src/scan.ts
46
+ function scan(src) {
47
+ const out = [];
48
+ const len = src.length;
49
+ let i = 0;
50
+ let lineNo = 0;
51
+ let inFence = false;
52
+ let fenceChar = 0;
53
+ let fenceLen = 0;
54
+ while (i <= len) {
55
+ const start = i;
56
+ while (i < len && src.charCodeAt(i) !== 10)
57
+ i++;
58
+ let line = src.slice(start, i);
59
+ if (line.length > 0 && line.charCodeAt(line.length - 1) === 13) {
60
+ line = line.slice(0, line.length - 1);
61
+ }
62
+ lineNo++;
63
+ const fence = matchFence(line);
64
+ if (fence) {
65
+ if (!inFence) {
66
+ inFence = true;
67
+ fenceChar = fence.char;
68
+ fenceLen = fence.len;
69
+ } else if (fence.char === fenceChar && fence.len >= fenceLen) {
70
+ inFence = false;
71
+ }
72
+ } else if (!inFence) {
73
+ const h = matchHeading(line, lineNo);
74
+ if (h)
75
+ out.push(h);
76
+ }
77
+ if (i >= len)
78
+ break;
79
+ i++;
80
+ }
81
+ return out;
82
+ }
83
+ function matchFence(line) {
84
+ let p = 0;
85
+ while (p < 3 && line.charCodeAt(p) === 32)
86
+ p++;
87
+ const ch = line.charCodeAt(p);
88
+ if (ch !== 96 && ch !== 126)
89
+ return null;
90
+ let run = 0;
91
+ while (line.charCodeAt(p + run) === ch)
92
+ run++;
93
+ if (run < 3)
94
+ return null;
95
+ return { char: ch, len: run };
96
+ }
97
+ function matchHeading(line, lineNo) {
98
+ let p = 0;
99
+ while (p < 3 && line.charCodeAt(p) === 32)
100
+ p++;
101
+ if (line.charCodeAt(p) !== 35)
102
+ return null;
103
+ let hashes = 0;
104
+ while (line.charCodeAt(p + hashes) === 35)
105
+ hashes++;
106
+ if (hashes < 1 || hashes > 6)
107
+ return null;
108
+ const after = p + hashes;
109
+ const afterCh = line.charCodeAt(after);
110
+ if (after < line.length && afterCh !== 32 && afterCh !== 9) {
111
+ return null;
112
+ }
113
+ let contentStart = after;
114
+ while (contentStart < line.length && (line.charCodeAt(contentStart) === 32 || line.charCodeAt(contentStart) === 9)) {
115
+ contentStart++;
116
+ }
117
+ let end = line.length;
118
+ while (end > contentStart && (line.charCodeAt(end - 1) === 32 || line.charCodeAt(end - 1) === 9)) {
119
+ end--;
120
+ }
121
+ let closing = end;
122
+ while (closing > contentStart && line.charCodeAt(closing - 1) === 35)
123
+ closing--;
124
+ if (closing < end && (closing === contentStart || line.charCodeAt(closing - 1) === 32 || line.charCodeAt(closing - 1) === 9)) {
125
+ end = closing;
126
+ while (end > contentStart && (line.charCodeAt(end - 1) === 32 || line.charCodeAt(end - 1) === 9)) {
127
+ end--;
128
+ }
129
+ }
130
+ const title = line.slice(contentStart, end);
131
+ return { level: hashes, title, line: lineNo };
132
+ }
133
+ // src/sections.ts
134
+ function buildSections(headings, totalLines) {
135
+ const out = [];
136
+ const stack = [];
137
+ for (const h of headings) {
138
+ while (stack.length > 0 && stack[stack.length - 1].level >= h.level) {
139
+ const closing = stack.pop();
140
+ closing.line_end = h.line - 1;
141
+ }
142
+ const parent = stack.length > 0 ? stack[stack.length - 1] : null;
143
+ const sec = {
144
+ level: h.level,
145
+ title: h.title,
146
+ line_start: h.line,
147
+ line_end: totalLines,
148
+ parent
149
+ };
150
+ out.push(sec);
151
+ stack.push(sec);
152
+ }
153
+ return out;
154
+ }
155
+ function pathOf(sec) {
156
+ const path = [];
157
+ let cur = sec.parent;
158
+ while (cur) {
159
+ path.push(cur.title);
160
+ cur = cur.parent;
161
+ }
162
+ return path.reverse();
163
+ }
164
+ function countLines(src) {
165
+ if (src.length === 0)
166
+ return 0;
167
+ let n = 1;
168
+ for (let i = 0;i < src.length; i++) {
169
+ if (src.charCodeAt(i) === 10)
170
+ n++;
171
+ }
172
+ if (src.charCodeAt(src.length - 1) === 10)
173
+ n--;
174
+ return n;
175
+ }
176
+ // src/select.ts
177
+ function parseSelector(input) {
178
+ const trimmed = input.trim();
179
+ if (trimmed.length === 0)
180
+ return [];
181
+ const rawSegments = [];
182
+ const ops = ["descendant"];
183
+ let cur = "";
184
+ let i = 0;
185
+ let inRegex = false;
186
+ let atSegmentStart = true;
187
+ while (i < trimmed.length) {
188
+ const ch = trimmed[i];
189
+ if (ch === "/" && (atSegmentStart || inRegex)) {
190
+ inRegex = !inRegex;
191
+ cur += ch;
192
+ atSegmentStart = false;
193
+ i++;
194
+ continue;
195
+ }
196
+ if (!inRegex && ch === ">") {
197
+ rawSegments.push(cur.trim());
198
+ cur = "";
199
+ atSegmentStart = true;
200
+ if (trimmed[i + 1] === ">") {
201
+ ops.push("child");
202
+ i += 2;
203
+ } else {
204
+ ops.push("descendant");
205
+ i += 1;
206
+ }
207
+ continue;
208
+ }
209
+ cur += ch;
210
+ if (ch !== " " && ch !== "\t")
211
+ atSegmentStart = false;
212
+ i++;
213
+ }
214
+ rawSegments.push(cur.trim());
215
+ return rawSegments.map((s, idx) => parseSegment(s, ops[idx] ?? "descendant"));
216
+ }
217
+ function parseSegment(raw, op) {
218
+ let s = raw;
219
+ let level = null;
220
+ const levelMatch = /^(#{1,6})(?!#)\s*(.*)$/.exec(s);
221
+ if (levelMatch) {
222
+ level = levelMatch[1].length;
223
+ s = levelMatch[2] ?? "";
224
+ }
225
+ const regexMatch = /^\/(.+)\/([gimsuy]*)$/.exec(s);
226
+ if (regexMatch) {
227
+ const pattern = regexMatch[1];
228
+ const flags = regexMatch[2] || "i";
229
+ return {
230
+ op,
231
+ level,
232
+ kind: "regex",
233
+ value: pattern,
234
+ regex: new RegExp(pattern, flags)
235
+ };
236
+ }
237
+ if (s.startsWith("=")) {
238
+ return { op, level, kind: "exact", value: s.slice(1).trim() };
239
+ }
240
+ return { op, level, kind: "fuzzy", value: s.trim() };
241
+ }
242
+ function match(sections, selector) {
243
+ if (selector.length === 0)
244
+ return [];
245
+ const out = [];
246
+ for (const sec of sections) {
247
+ if (matches(sec, selector))
248
+ out.push(sec);
249
+ }
250
+ return out;
251
+ }
252
+ function matches(sec, segs) {
253
+ const last = segs[segs.length - 1];
254
+ if (!last || !segmentMatchesSection(last, sec))
255
+ return false;
256
+ let cursor = sec.parent;
257
+ for (let i = segs.length - 2;i >= 0; i--) {
258
+ const op = segs[i + 1].op;
259
+ const seg = segs[i];
260
+ if (op === "child") {
261
+ if (!cursor || !segmentMatchesSection(seg, cursor))
262
+ return false;
263
+ cursor = cursor.parent;
264
+ } else {
265
+ let found = null;
266
+ while (cursor) {
267
+ if (segmentMatchesSection(seg, cursor)) {
268
+ found = cursor;
269
+ break;
270
+ }
271
+ cursor = cursor.parent;
272
+ }
273
+ if (!found)
274
+ return false;
275
+ cursor = found.parent;
276
+ }
277
+ }
278
+ return true;
279
+ }
280
+ function segmentMatchesSection(seg, sec) {
281
+ if (seg.level !== null && seg.level !== sec.level)
282
+ return false;
283
+ const title = sec.title;
284
+ switch (seg.kind) {
285
+ case "exact":
286
+ return title.toLowerCase() === seg.value.toLowerCase();
287
+ case "regex":
288
+ return seg.regex.test(title);
289
+ case "fuzzy":
290
+ return title.toLowerCase().includes(seg.value.toLowerCase());
291
+ }
292
+ }
293
+ // src/render.ts
294
+ function renderToc(file, src, sections, opts) {
295
+ const totalLines = countLines(src);
296
+ const headerCount = sections.length;
297
+ const headerRange = totalLines === 0 ? "L0" : `L1-${totalLines}`;
298
+ const plural = headerCount === 1 ? "heading" : "headings";
299
+ const out = [];
300
+ out.push(`${file} ${headerRange} ${headerCount} ${plural}`);
301
+ for (const sec of sections) {
302
+ if (opts.depth != null && sec.level > opts.depth)
303
+ continue;
304
+ const indent = opts.flat ? "" : " ".repeat(Math.max(0, sec.level - 1));
305
+ const hashes = "#".repeat(sec.level);
306
+ const range = `L${sec.line_start}-${sec.line_end}`;
307
+ out.push(`${indent}${hashes} ${sec.title} ${range}`);
308
+ }
309
+ return out.join(`
310
+ `);
311
+ }
312
+ function renderSection(file, srcLines, sec, opts) {
313
+ const start = sec.line_start;
314
+ let end = sec.line_end;
315
+ if (opts.bodyOnly && opts.allSections) {
316
+ const firstChild = findFirstChild(sec, opts.allSections);
317
+ if (firstChild)
318
+ end = firstChild.line_start - 1;
319
+ }
320
+ if (opts.noBody) {
321
+ end = start;
322
+ }
323
+ const clampedEnd = Math.min(end, srcLines.length);
324
+ let body = srcLines.slice(start - 1, clampedEnd).join(`
325
+ `);
326
+ if (opts.maxLines != null && opts.maxLines > 0) {
327
+ body = truncateBody(body, opts.maxLines);
328
+ }
329
+ if (opts.pretty) {
330
+ body = opts.pretty(body);
331
+ }
332
+ if (opts.raw)
333
+ return body;
334
+ const hashes = "#".repeat(sec.level);
335
+ const header = `── ${file} L${start}-${end} ${hashes} ${sec.title} ${"─".repeat(8)}`;
336
+ const footer = `── end ${"─".repeat(40)}`;
337
+ return `${header}
338
+ ${body}
339
+ ${footer}`;
340
+ }
341
+ function truncateBody(body, maxLines) {
342
+ if (maxLines <= 0)
343
+ return body;
344
+ const lines = body.split(`
345
+ `);
346
+ if (lines.length <= maxLines)
347
+ return body;
348
+ const kept = lines.slice(0, maxLines).join(`
349
+ `);
350
+ const remaining = lines.length - maxLines;
351
+ return `${kept}
352
+
353
+ … ${remaining} more lines (use --max-lines=0 for full)`;
354
+ }
355
+ function findFirstChild(sec, all) {
356
+ for (const candidate of all) {
357
+ if (candidate.parent === sec)
358
+ return candidate;
359
+ }
360
+ return null;
361
+ }
362
+
363
+ //# debugId=F78549B744E4995264756E2164756E21
364
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Markdown heading scanner — the engine behind every read-path command.
3
+ *
4
+ * Instead of building a full CommonMark AST we walk the source line by line
5
+ * and recognize only what `mdq` actually needs: ATX headings and fenced code
6
+ * blocks (so `#` inside code doesn't count as a heading).
7
+ *
8
+ * Numbers on MDN content (see BENCHMARK.md): ~180 MB/s end-to-end on a
9
+ * 10 MB fixture, roughly 7x faster than markdown-it and ~1000x faster than
10
+ * mdast-util-from-markdown while returning the exact same section.
11
+ *
12
+ * Deliberate limitations:
13
+ * - Setext headings (`===` / `---` underlines) are NOT recognized. mdq is
14
+ * aimed at agent-authored markdown where ATX is ubiquitous.
15
+ * - HTML blocks are not detected. A `<pre>` containing an ATX-looking line
16
+ * would be misread as a heading. That's an acceptable tradeoff for 100x
17
+ * speed; a future `--strict` flag could hand off to markdown-it.
18
+ * - Fenced code blocks *inside a list item* that are indented 4+ spaces are
19
+ * not recognized as fences — we only look at the first 3 columns for the
20
+ * fence opener. A `# fake` line inside such a block would be scanned as a
21
+ * heading. Rare in practice; document-your-way-out rather than fix.
22
+ * - An unclosed fence at EOF leaves the scanner in "still in fence" state
23
+ * to the end of the file, so any `#`-looking lines after it are ignored.
24
+ * That's the conservative choice — prefer under-counting to over-counting.
25
+ */
26
+ type Heading = {
27
+ /** 1..6 */
28
+ level: number;
29
+ /** Heading text with trailing closing hashes stripped. */
30
+ title: string;
31
+ /** 1-indexed line number. */
32
+ line: number;
33
+ };
34
+ /**
35
+ * Return every ATX heading in `src`, in document order.
36
+ * Runs in a single pass; O(n) in source length, O(headings) in space.
37
+ */
38
+ declare function scan(src: string): Heading[];
39
+ type Section = {
40
+ level: number;
41
+ title: string;
42
+ /** 1-indexed line of the heading itself. */
43
+ line_start: number;
44
+ /** 1-indexed inclusive end of the subtree. */
45
+ line_end: number;
46
+ /** Nearest enclosing section, or null for top-level. */
47
+ parent: Section | null;
48
+ };
49
+ /**
50
+ * Build the section tree in a single pass. Preserves document order.
51
+ *
52
+ * Runs in O(n): every section is pushed once and popped once, and we set
53
+ * its `line_end` at pop time. Sections still on the stack when we run out
54
+ * of headings keep their provisional `line_end = totalLines`.
55
+ */
56
+ declare function buildSections(headings: Heading[], totalLines: number): Section[];
57
+ /**
58
+ * Walk `sec` up to the root, collecting ancestor titles in top-down order.
59
+ * Returns [] for a root section.
60
+ */
61
+ declare function pathOf(sec: Section): string[];
62
+ /**
63
+ * Count lines in a source string. Empty string is 0; otherwise every line
64
+ * (including the last one, whether or not it ends with a newline) is 1.
65
+ * A trailing newline does NOT add a phantom line.
66
+ */
67
+ declare function countLines(src: string): number;
68
+ type Op = "descendant" | "child";
69
+ type Kind = "fuzzy" | "exact" | "regex";
70
+ type Segment = {
71
+ /** Operator that connects this segment to the *previous* one.
72
+ * For the first segment this is always "descendant" (unused). */
73
+ op: Op;
74
+ /** Optional 1..6 level filter. */
75
+ level: number | null;
76
+ kind: Kind;
77
+ /** The raw value (without level/kind prefix). */
78
+ value: string;
79
+ /** Present only for kind === "regex". */
80
+ regex?: RegExp;
81
+ };
82
+ declare function parseSelector(input: string): Segment[];
83
+ declare function match(sections: Section[], selector: Segment[]): Section[];
84
+ /**
85
+ * Pretty printing for `mdq read --pretty`. Lazy-loads marked +
86
+ * marked-terminal on first use so the default (plain-text) path keeps its
87
+ * ~16ms cold start.
88
+ */
89
+ type PrettyFormatter = (markdown: string) => string;
90
+ type TocOptions = {
91
+ depth?: number;
92
+ flat?: boolean;
93
+ };
94
+ declare function renderToc(file: string, src: string, sections: Section[], opts: TocOptions): string;
95
+ type SectionOptions = {
96
+ bodyOnly?: boolean;
97
+ noBody?: boolean;
98
+ raw?: boolean;
99
+ maxLines?: number;
100
+ /** Required when bodyOnly is true so we can find the first child. */
101
+ allSections?: Section[];
102
+ /** Optional markdown→ANSI formatter applied to the body before delimiters. */
103
+ pretty?: PrettyFormatter;
104
+ };
105
+ declare function renderSection(file: string, srcLines: string[], sec: Section, opts: SectionOptions): string;
106
+ /**
107
+ * Cut `body` to the first `maxLines` lines. If anything was dropped, append
108
+ * a marker line telling the agent how to get the rest. `maxLines <= 0`
109
+ * disables truncation.
110
+ */
111
+ declare function truncateBody(body: string, maxLines: number): string;
112
+ export { truncateBody, scan, renderToc, renderSection, pathOf, parseSelector, match, countLines, buildSections, TocOptions, Segment, SectionOptions, Section, Op, Kind, Heading };