@cube-drone/marquee-parser 0.3.0 → 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/src/blocks.ts CHANGED
@@ -1,15 +1,31 @@
1
1
  // Line-oriented block parser: a faithful port of rust/parser/src/blocks.rs.
2
2
  // Where this file and that file disagree, one of them has a conformance bug.
3
+ //
4
+ // Positions (SPEC.md, "Source positions"): when a PosTracker rides along,
5
+ // every node gets a [start, end) span - UTF-16 offsets into the normalized
6
+ // source - recorded in the side-table. The AST itself never changes; the
7
+ // default path pays nothing.
3
8
 
4
9
  import type { Node, Reason } from "./ast.ts";
5
10
  import { isName, nameLen, parseAttrs, utf8Len } from "./attrs.ts";
6
- import { parseInlines } from "./inlines.ts";
11
+ import { parseInlines, type PosCtx } from "./inlines.ts";
7
12
 
8
13
  export const MAX_LIST_DEPTH = 16;
9
14
  export const MAX_QUOTE_DEPTH = 16;
10
15
  export const MAX_DIRECTIVE_DEPTH = 8;
11
16
  export const MAX_TARGET_BYTES = 4096;
12
17
 
18
+ /** A node's source extent: [start, end) in UTF-16 code units over the
19
+ * front-door-normalized source (shebang line included in the count). */
20
+ export interface Span {
21
+ start: number;
22
+ end: number;
23
+ }
24
+
25
+ export interface PosTracker {
26
+ spans: WeakMap<Node, Span>;
27
+ }
28
+
13
29
  interface Ctx {
14
30
  dirDepth: number;
15
31
  listDepth: number;
@@ -18,6 +34,9 @@ interface Ctx {
18
34
 
19
35
  interface Cursor {
20
36
  lines: string[];
37
+ /** Parallel to `lines`: source offset of each line's first character
38
+ * (post any container prefix-stripping). Null when positions are off. */
39
+ starts: number[] | null;
21
40
  pos: number;
22
41
  }
23
42
 
@@ -25,15 +44,68 @@ function peek(cur: Cursor): string | undefined {
25
44
  return cur.lines[cur.pos];
26
45
  }
27
46
 
28
- export function parseBlocks(body: string): Node[] {
47
+ export function parseBlocks(body: string, T: PosTracker | null = null, baseOffset = 0): Node[] {
29
48
  const lines = body.split("\n");
49
+ let starts: number[] | null = null;
50
+ if (T !== null) {
51
+ starts = [];
52
+ let off = baseOffset;
53
+ for (const line of lines) {
54
+ starts.push(off);
55
+ off += line.length + 1;
56
+ }
57
+ }
30
58
  // A final newline terminates the last line rather than creating an empty
31
59
  // one (otherwise every trailing newline leaks a blank into fences).
32
60
  if (lines.length > 0 && lines[lines.length - 1] === "") {
33
61
  lines.pop();
62
+ starts?.pop();
63
+ }
64
+ const cur: Cursor = { lines, starts, pos: 0 };
65
+ return parseContainer(cur, { dirDepth: 0, listDepth: 0, quoteDepth: 0 }, null, T);
66
+ }
67
+
68
+ function lineStartOf(cur: Cursor, i: number): number {
69
+ return cur.starts![i]!;
70
+ }
71
+
72
+ function lineEndOf(cur: Cursor, i: number): number {
73
+ return cur.starts![i]! + cur.lines[i]!.length;
74
+ }
75
+
76
+ function mark(T: PosTracker | null, cur: Cursor, node: Node, startLine: number, endLine: number): Node {
77
+ if (T !== null && cur.starts !== null) {
78
+ T.spans.set(node, { start: lineStartOf(cur, startLine), end: lineEndOf(cur, endLine) });
79
+ }
80
+ return node;
81
+ }
82
+
83
+ /** Build the logical inline text (segments joined by real source newlines)
84
+ * plus the code-point -> source-offset map the inline parser records from. */
85
+ function inlineCtx(
86
+ T: PosTracker | null,
87
+ segs: Array<{ text: string; start: number }>,
88
+ ): { text: string; P: PosCtx | null } {
89
+ const text = segs.map((s) => s.text).join("\n");
90
+ if (T === null) {
91
+ return { text, P: null };
92
+ }
93
+ const map: number[] = [];
94
+ let end = 0;
95
+ for (let s = 0; s < segs.length; s += 1) {
96
+ if (s > 0) {
97
+ map.push(end); // the joining "\n" is the previous line's real newline
98
+ }
99
+ const seg = segs[s]!;
100
+ let off = seg.start;
101
+ for (const ch of seg.text) {
102
+ map.push(off);
103
+ off += ch.length;
104
+ }
105
+ end = off;
34
106
  }
35
- const cur: Cursor = { lines, pos: 0 };
36
- return parseContainer(cur, { dirDepth: 0, listDepth: 0, quoteDepth: 0 }, null);
107
+ map.push(end);
108
+ return { text, P: { map, spans: T.spans } };
37
109
  }
38
110
 
39
111
  /**
@@ -41,7 +113,7 @@ export function parseBlocks(body: string): Node[] {
41
113
  * directive whose body this is (null at document level and inside
42
114
  * blockquotes / list items, which are fresh containers).
43
115
  */
44
- function parseContainer(cur: Cursor, ctx: Ctx, openDir: string | null): Node[] {
116
+ function parseContainer(cur: Cursor, ctx: Ctx, openDir: string | null, T: PosTracker | null): Node[] {
45
117
  const out: Node[] = [];
46
118
  for (;;) {
47
119
  const line = peek(cur);
@@ -55,6 +127,7 @@ function parseContainer(cur: Cursor, ctx: Ctx, openDir: string | null): Node[] {
55
127
 
56
128
  // Directive open / close / error lines.
57
129
  if (line.startsWith(":::")) {
130
+ const li = cur.pos;
58
131
  cur.pos += 1;
59
132
  const dir = classifyDirective(line);
60
133
  if (dir.kind === "close") {
@@ -64,52 +137,76 @@ function parseContainer(cur: Cursor, ctx: Ctx, openDir: string | null): Node[] {
64
137
  if (openDir !== null && dir.name === openDir) {
65
138
  return out;
66
139
  }
67
- out.push(invalid(openDir !== null ? "mismatched_close" : "stray_close"));
140
+ out.push(mark(T, cur, invalid(openDir !== null ? "mismatched_close" : "stray_close"), li, li));
68
141
  } else if (dir.kind === "open") {
69
142
  if (ctx.dirDepth >= MAX_DIRECTIVE_DEPTH) {
70
- out.push(invalid("depth_exceeded"));
143
+ out.push(mark(T, cur, invalid("depth_exceeded"), li, li));
71
144
  continue;
72
145
  }
73
146
  const attrs = parseAttrs(dir.attrsSrc);
74
147
  if (!attrs.ok) {
75
- out.push(invalid(attrs.reason));
148
+ out.push(mark(T, cur, invalid(attrs.reason), li, li));
76
149
  } else {
77
150
  const children = dir.leaf
78
151
  ? []
79
- : parseContainer(cur, { ...ctx, dirDepth: ctx.dirDepth + 1 }, dir.name);
80
- out.push({ type: "directive", name: dir.name, attrs: attrs.attrs, children });
152
+ : parseContainer(cur, { ...ctx, dirDepth: ctx.dirDepth + 1 }, dir.name, T);
153
+ // A container's close line (or EOF auto-close) has been consumed
154
+ // by the child parse: the span runs to the last consumed line.
155
+ const endLi = dir.leaf ? li : Math.max(li, cur.pos - 1);
156
+ out.push(
157
+ mark(T, cur, { type: "directive", name: dir.name, attrs: attrs.attrs, children }, li, endLi),
158
+ );
81
159
  }
82
160
  } else {
83
- out.push(invalid(dir.reason));
161
+ out.push(mark(T, cur, invalid(dir.reason), li, li));
84
162
  }
85
163
  continue;
86
164
  }
87
165
 
88
166
  // Comment block: consecutive `%%` lines, raw content.
89
167
  if (line.startsWith("%%")) {
168
+ const li = cur.pos;
90
169
  const texts: string[] = [];
91
170
  for (let l = peek(cur); l !== undefined && l.startsWith("%%"); l = peek(cur)) {
92
171
  const rest = l.slice(2);
93
172
  texts.push(rest.startsWith(" ") ? rest.slice(1) : rest);
94
173
  cur.pos += 1;
95
174
  }
96
- out.push({ type: "comment", text: texts.join("\n") });
175
+ out.push(mark(T, cur, { type: "comment", text: texts.join("\n") }, li, cur.pos - 1));
97
176
  continue;
98
177
  }
99
178
 
100
179
  const heading = headingLine(line);
101
180
  if (heading !== null) {
181
+ const li = cur.pos;
102
182
  cur.pos += 1;
103
- out.push({
104
- type: "heading",
105
- level: heading.level,
106
- children: parseInlines(trimSpaceTab(heading.content)),
107
- });
183
+ const content = trimSpaceTab(heading.content);
184
+ let P: PosCtx | null = null;
185
+ let text = content;
186
+ if (T !== null && cur.starts !== null) {
187
+ // Content offset: past the #s and their space, plus leading trim
188
+ // (all ASCII, so char counts are UTF-16 counts).
189
+ const lead = heading.content.length - heading.content.replace(/^[ \t]+/, "").length;
190
+ const start = lineStartOf(cur, li) + heading.level + 1 + lead;
191
+ const built = inlineCtx(T, [{ text: content, start }]);
192
+ text = built.text;
193
+ P = built.P;
194
+ }
195
+ out.push(
196
+ mark(
197
+ T,
198
+ cur,
199
+ { type: "heading", level: heading.level, children: parseInlines(text, P) },
200
+ li,
201
+ li,
202
+ ),
203
+ );
108
204
  continue;
109
205
  }
110
206
 
111
207
  const fence = fenceOpen(line);
112
208
  if (fence !== null) {
209
+ const li = cur.pos;
113
210
  cur.pos += 1;
114
211
  const content: string[] = [];
115
212
  for (let l = peek(cur); l !== undefined; l = peek(cur)) {
@@ -121,43 +218,63 @@ function parseContainer(cur: Cursor, ctx: Ctx, openDir: string | null): Node[] {
121
218
  }
122
219
  const text = content.join("\n");
123
220
  out.push(
124
- fence.info === null
125
- ? { type: "code_block", text }
126
- : { type: "code_block", info: fence.info, text },
221
+ mark(
222
+ T,
223
+ cur,
224
+ fence.info === null
225
+ ? { type: "code_block", text }
226
+ : { type: "code_block", info: fence.info, text },
227
+ li,
228
+ cur.pos - 1,
229
+ ),
127
230
  );
128
231
  continue;
129
232
  }
130
233
 
131
234
  if (line.startsWith(">") && ctx.quoteDepth < MAX_QUOTE_DEPTH) {
235
+ const li = cur.pos;
132
236
  const innerLines: string[] = [];
237
+ const innerStarts: number[] | null = T !== null && cur.starts !== null ? [] : null;
133
238
  for (let l = peek(cur); l !== undefined && l.startsWith(">"); l = peek(cur)) {
134
239
  const rest = l.slice(1);
135
- innerLines.push(rest.startsWith(" ") ? rest.slice(1) : rest);
240
+ const stripped = rest.startsWith(" ") ? 2 : 1;
241
+ innerLines.push(l.slice(stripped));
242
+ innerStarts?.push(lineStartOf(cur, cur.pos) + stripped);
136
243
  cur.pos += 1;
137
244
  }
138
- const sub: Cursor = { lines: innerLines, pos: 0 };
139
- out.push({
140
- type: "blockquote",
141
- children: parseContainer(sub, { ...ctx, quoteDepth: ctx.quoteDepth + 1 }, null),
142
- });
245
+ const sub: Cursor = { lines: innerLines, starts: innerStarts, pos: 0 };
246
+ out.push(
247
+ mark(
248
+ T,
249
+ cur,
250
+ {
251
+ type: "blockquote",
252
+ children: parseContainer(sub, { ...ctx, quoteDepth: ctx.quoteDepth + 1 }, null, T),
253
+ },
254
+ li,
255
+ cur.pos - 1,
256
+ ),
257
+ );
143
258
  continue;
144
259
  }
145
260
 
146
261
  if (line.replace(/[ \t]+$/, "") === "---") {
262
+ const li = cur.pos;
147
263
  cur.pos += 1;
148
- out.push({ type: "thematic_break" });
264
+ out.push(mark(T, cur, { type: "thematic_break" }, li, li));
149
265
  continue;
150
266
  }
151
267
 
152
268
  if (ctx.listDepth < MAX_LIST_DEPTH) {
153
269
  const m = marker(line);
154
270
  if (m !== null) {
155
- out.push(parseList(cur, ctx, m));
271
+ out.push(parseList(cur, ctx, m, T));
156
272
  continue;
157
273
  }
158
274
  }
159
275
 
160
276
  // Paragraph: plain lines until blank / container end / block start.
277
+ const li = cur.pos;
161
278
  const plines = [line];
162
279
  cur.pos += 1;
163
280
  for (let l = peek(cur); l !== undefined; l = peek(cur)) {
@@ -170,11 +287,19 @@ function parseContainer(cur: Cursor, ctx: Ctx, openDir: string | null): Node[] {
170
287
  if (plines.length === 1) {
171
288
  const target = turbolink(plines[0]!);
172
289
  if (target !== null) {
173
- out.push({ type: "turbolink", target });
290
+ out.push(mark(T, cur, { type: "turbolink", target }, li, li));
174
291
  continue;
175
292
  }
176
293
  }
177
- out.push({ type: "paragraph", children: parseInlines(plines.join("\n")) });
294
+ let P: PosCtx | null = null;
295
+ let text = plines.join("\n");
296
+ if (T !== null && cur.starts !== null) {
297
+ const segs = plines.map((t, idx) => ({ text: t, start: lineStartOf(cur, li + idx) }));
298
+ const built = inlineCtx(T, segs);
299
+ text = built.text;
300
+ P = built.P;
301
+ }
302
+ out.push(mark(T, cur, { type: "paragraph", children: parseInlines(text, P) }, li, cur.pos - 1));
178
303
  }
179
304
  return out;
180
305
  }
@@ -262,12 +387,20 @@ function marker(line: string): Marker | null {
262
387
 
263
388
  /** One list of one kind. A same-column marker of the other kind ends this
264
389
  * list (the dispatcher immediately starts the next one). */
265
- function parseList(cur: Cursor, ctx: Ctx, first: Marker): Node {
390
+ function parseList(cur: Cursor, ctx: Ctx, first: Marker, T: PosTracker | null): Node {
391
+ const tracking = T !== null && cur.starts !== null;
266
392
  const ordered = first.ordered;
267
393
  const items: Node[] = [];
394
+ const listLi = cur.pos;
395
+ let itemStart = tracking ? lineStartOf(cur, cur.pos) + first.indent : 0;
268
396
  let buf: string[] = [peek(cur)!.slice(first.contentIdx)];
397
+ let bufStarts: number[] | null = tracking ? [lineStartOf(cur, cur.pos) + first.contentIdx] : null;
269
398
  cur.pos += 1;
270
399
 
400
+ const finish = (): void => {
401
+ items.push(finishItem(buf, bufStarts, ctx, T, itemStart));
402
+ };
403
+
271
404
  for (;;) {
272
405
  const line = peek(cur);
273
406
  if (line === undefined) {
@@ -294,6 +427,7 @@ function parseList(cur: Cursor, ctx: Ctx, first: Marker): Node {
294
427
  break;
295
428
  }
296
429
  buf.push("");
430
+ bufStarts?.push(tracking ? lineStartOf(cur, cur.pos) : 0);
297
431
  cur.pos += 1;
298
432
  continue;
299
433
  }
@@ -301,29 +435,53 @@ function parseList(cur: Cursor, ctx: Ctx, first: Marker): Node {
301
435
  // Content (or a deeper marker) inside the current item; strip the
302
436
  // content column. Off-grid extra spaces ride along (floor rule).
303
437
  buf.push(line.slice(2));
438
+ bufStarts?.push(tracking ? lineStartOf(cur, cur.pos) + 2 : 0);
304
439
  cur.pos += 1;
305
440
  continue;
306
441
  }
307
442
  // Column 0 or 1 (floors to 0).
308
443
  const m = marker(line);
309
444
  if (m !== null && m.ordered === ordered) {
310
- items.push(finishItem(buf, ctx));
445
+ finish();
446
+ itemStart = tracking ? lineStartOf(cur, cur.pos) + m.indent : 0;
311
447
  buf = [line.slice(m.contentIdx)];
448
+ bufStarts = tracking ? [lineStartOf(cur, cur.pos) + m.contentIdx] : null;
312
449
  cur.pos += 1;
313
450
  continue;
314
451
  }
315
452
  break; // column-0 block, or a kind switch
316
453
  }
317
- items.push(finishItem(buf, ctx));
318
- return { type: "list", ordered, children: items };
454
+ finish();
455
+ const list: Node = { type: "list", ordered, children: items };
456
+ if (tracking) {
457
+ T!.spans.set(list, {
458
+ start: lineStartOf(cur, listLi) + first.indent,
459
+ end: lineEndOf(cur, cur.pos - 1),
460
+ });
461
+ }
462
+ return list;
319
463
  }
320
464
 
321
- function finishItem(buf: string[], ctx: Ctx): Node {
322
- const sub: Cursor = { lines: buf, pos: 0 };
323
- return {
465
+ function finishItem(
466
+ buf: string[],
467
+ bufStarts: number[] | null,
468
+ ctx: Ctx,
469
+ T: PosTracker | null,
470
+ itemStart: number,
471
+ ): Node {
472
+ const sub: Cursor = { lines: buf, starts: bufStarts, pos: 0 };
473
+ const item: Node = {
324
474
  type: "list_item",
325
- children: parseContainer(sub, { ...ctx, listDepth: ctx.listDepth + 1 }, null),
475
+ children: parseContainer(sub, { ...ctx, listDepth: ctx.listDepth + 1 }, null, T),
326
476
  };
477
+ if (T !== null && bufStarts !== null) {
478
+ let end = itemStart;
479
+ for (let i = 0; i < buf.length; i += 1) {
480
+ end = Math.max(end, bufStarts[i]! + buf[i]!.length);
481
+ }
482
+ T.spans.set(item, { start: itemStart, end });
483
+ }
484
+ return item;
327
485
  }
328
486
 
329
487
  type DirLine =
package/src/index.ts CHANGED
@@ -4,10 +4,11 @@
4
4
  // unknown dialect version (SPEC.md, "Conformance"), surfaced as a thrown
5
5
  // error rather than a guessed parse.
6
6
 
7
- import { parseBlocks } from "./blocks.ts";
7
+ import { parseBlocks, type PosTracker, type Span } from "./blocks.ts";
8
8
  import type { Node } from "./ast.ts";
9
9
 
10
10
  export type { Attrs, Node, Reason } from "./ast.ts";
11
+ export type { Span } from "./blocks.ts";
11
12
 
12
13
  export class UnsupportedVersionError extends Error {
13
14
  readonly version: number;
@@ -31,6 +32,39 @@ export function parse(input: string): Node {
31
32
  return { type: "document", version, children: parseBlocks(body) };
32
33
  }
33
34
 
35
+ /** parse() plus source positions - the editor-tooling extra, deliberately
36
+ * OUTSIDE the AST wire contract (SPEC.md, "Source positions"):
37
+ *
38
+ * - `doc` is byte-identical to what parse() returns: nodes carry no
39
+ * position fields, so serialization, vectors, and goldens are untouched.
40
+ * - `spans` maps each node object to its [start, end) source extent, in
41
+ * UTF-16 code units (what JavaScript strings and CodeMirror speak) over
42
+ * `source` - the front-door-NORMALIZED input (\r\n -> \n; the shebang
43
+ * line, when present, is included in the offset space).
44
+ * - Node-level extents are the guarantee (a span covers its opener through
45
+ * its closer); the interiors of canonicalized text (merged literals,
46
+ * resolved escapes) are covered but not subdivided.
47
+ */
48
+ export function parseWithPositions(input: string): {
49
+ doc: Node;
50
+ spans: WeakMap<Node, Span>;
51
+ source: string;
52
+ } {
53
+ const normalized = input.replaceAll("\r\n", "\n").replaceAll("\r", "\n");
54
+ const [version, body] = stripVersion(normalized);
55
+ if (version !== 0) {
56
+ throw new UnsupportedVersionError(version);
57
+ }
58
+ const tracker: PosTracker = { spans: new WeakMap() };
59
+ const doc: Node = {
60
+ type: "document",
61
+ version,
62
+ children: parseBlocks(body, tracker, normalized.length - body.length),
63
+ };
64
+ tracker.spans.set(doc, { start: 0, end: normalized.length });
65
+ return { doc, spans: tracker.spans, source: normalized };
66
+ }
67
+
34
68
  /** The in-band version declaration: line 1, exactly `#!marquee <integer>`.
35
69
  * The default is version 0, forever. */
36
70
  function stripVersion(s: string): [number, string] {