@cube-drone/marquee-parser 0.3.0 → 0.4.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/inlines.ts CHANGED
@@ -5,20 +5,34 @@
5
5
  //
6
6
  // Works on code points (Array.from), mirroring Rust's Vec<char>, so the two
7
7
  // implementations scan identical units.
8
+ //
9
+ // Positions (SPEC.md, "Source positions"): when a PosCtx rides along, every
10
+ // node gets a [start, end) span in the side-table - UTF-16 offsets into the
11
+ // normalized source, translated from code-point indices via `map`. The AST
12
+ // itself is untouched either way: spans live outside the wire contract.
8
13
 
9
14
  import type { Attrs, Node } from "./ast.ts";
10
15
  import { isName, nameLen, parseAttrs, parseValue, utf8Len } from "./attrs.ts";
11
- import { MAX_TARGET_BYTES } from "./blocks.ts";
16
+ import { MAX_TARGET_BYTES, type Span } from "./blocks.ts";
12
17
 
13
18
  export const MAX_INLINE_DEPTH = 16;
14
19
  export const MAX_EMOJI_SLUG_BYTES = 64;
15
20
 
21
+ /** Positions context: map[cpIndex] = source UTF-16 offset of that code
22
+ * point in the logical text (one extra entry for the end). */
23
+ export interface PosCtx {
24
+ map: number[];
25
+ spans: WeakMap<Node, Span>;
26
+ }
27
+
16
28
  type DelimKind = "em" | "strong" | "strike";
17
29
 
18
30
  interface Delim {
19
31
  kind: DelimKind;
20
32
  idx: number;
21
33
  raw: string;
34
+ /** Code-point index of the delimiter run in the logical text. */
35
+ cp: number;
22
36
  }
23
37
 
24
38
  interface Frame {
@@ -28,10 +42,13 @@ interface Frame {
28
42
  attrs: Attrs;
29
43
  children: Node[];
30
44
  delims: Delim[];
45
+ /** Code-point range of the opener in the logical text. */
46
+ openerCp: number;
47
+ openerCpEnd: number;
31
48
  }
32
49
 
33
50
  function rootFrame(): Frame {
34
- return { openerRaw: "", name: "", attrs: {}, children: [], delims: [] };
51
+ return { openerRaw: "", name: "", attrs: {}, children: [], delims: [], openerCp: 0, openerCpEnd: 0 };
35
52
  }
36
53
 
37
54
  const ASCII_PUNCT = new Set("!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~");
@@ -46,14 +63,22 @@ function isSlugChar(c: string): boolean {
46
63
  return /^[a-z0-9_+-]$/.test(c);
47
64
  }
48
65
 
49
- export function parseInlines(text: string): Node[] {
50
- return parseInlinesAt(text, 0);
66
+ export function parseInlines(text: string, P: PosCtx | null = null): Node[] {
67
+ return parseInlinesAt(text, 0, P);
68
+ }
69
+
70
+ /** Record a node's span from code-point range; returns the node. */
71
+ function mk(P: PosCtx | null, node: Node, cpStart: number, cpEnd: number): Node {
72
+ if (P !== null) {
73
+ P.spans.set(node, { start: P.map[cpStart]!, end: P.map[cpEnd]! });
74
+ }
75
+ return node;
51
76
  }
52
77
 
53
78
  /** `base` is the inline depth already spent by enclosing link text: spans,
54
79
  * delimiters, and link nesting share the one <= 16 cap (a per-construct cap
55
80
  * would let composition multiply past it). */
56
- function parseInlinesAt(text: string, base: number): Node[] {
81
+ function parseInlinesAt(text: string, base: number, P: PosCtx | null): Node[] {
57
82
  const chars = Array.from(text);
58
83
  const frames: Frame[] = [rootFrame()];
59
84
  let i = 0;
@@ -63,29 +88,31 @@ function parseInlinesAt(text: string, base: number): Node[] {
63
88
  if (c === "\\") {
64
89
  const n = chars[i + 1];
65
90
  if (n === "\n") {
66
- top(frames).children.push({ type: "hard_break" });
91
+ top(frames).children.push(mk(P, { type: "hard_break" }, i, i + 2));
67
92
  i += 2;
68
93
  } else if (n !== undefined && ASCII_PUNCT.has(n)) {
69
- pushStr(frames, n);
94
+ pushStr(frames, n, P, i, i + 2);
70
95
  i += 2;
71
96
  } else {
72
- pushStr(frames, "\\");
97
+ pushStr(frames, "\\", P, i, i + 1);
73
98
  i += 1;
74
99
  }
75
100
  } else if (c === "`") {
76
101
  const n = runLen(chars, i, "`");
77
102
  const k = findBacktickCloser(chars, i + n, n);
78
103
  if (k !== null) {
79
- top(frames).children.push({ type: "code_span", text: chars.slice(i + n, k).join("") });
104
+ top(frames).children.push(
105
+ mk(P, { type: "code_span", text: chars.slice(i + n, k).join("") }, i, k + n),
106
+ );
80
107
  i = k + n;
81
108
  } else {
82
- pushStr(frames, "`".repeat(n));
109
+ pushStr(frames, "`".repeat(n), P, i, i + n);
83
110
  i += n;
84
111
  }
85
112
  } else if (c === "[") {
86
- i = bracket(chars, i, false, frames, base);
113
+ i = bracket(chars, i, false, frames, base, P);
87
114
  } else if (c === "!" && chars[i + 1] === "[") {
88
- i = bracket(chars, i + 1, true, frames, base);
115
+ i = bracket(chars, i + 1, true, frames, base, P);
89
116
  } else if (c === ":") {
90
117
  let k = i + 1;
91
118
  while (k < chars.length && isSlugChar(chars[k]!)) {
@@ -93,32 +120,34 @@ function parseInlinesAt(text: string, base: number): Node[] {
93
120
  }
94
121
  const slugLen = k - (i + 1);
95
122
  if (slugLen >= 1 && slugLen <= MAX_EMOJI_SLUG_BYTES && chars[k] === ":") {
96
- top(frames).children.push({ type: "emoji", slug: chars.slice(i + 1, k).join("") });
123
+ top(frames).children.push(
124
+ mk(P, { type: "emoji", slug: chars.slice(i + 1, k).join("") }, i, k + 1),
125
+ );
97
126
  i = k + 1;
98
127
  } else {
99
- pushStr(frames, ":");
128
+ pushStr(frames, ":", P, i, i + 1);
100
129
  i += 1;
101
130
  }
102
131
  } else if (c === "*") {
103
132
  const n = runLen(chars, i, "*");
104
133
  if (n === 1) {
105
- i = delimiter(chars, i, n, "em", "*", frames, base);
134
+ i = delimiter(chars, i, n, "em", "*", frames, base, P);
106
135
  } else if (n === 2) {
107
- i = delimiter(chars, i, n, "strong", "**", frames, base);
136
+ i = delimiter(chars, i, n, "strong", "**", frames, base, P);
108
137
  } else {
109
- pushStr(frames, "*".repeat(n));
138
+ pushStr(frames, "*".repeat(n), P, i, i + n);
110
139
  i += n;
111
140
  }
112
141
  } else if (c === "~") {
113
142
  const n = runLen(chars, i, "~");
114
143
  if (n === 2) {
115
- i = delimiter(chars, i, n, "strike", "~~", frames, base);
144
+ i = delimiter(chars, i, n, "strike", "~~", frames, base, P);
116
145
  } else {
117
- pushStr(frames, "~".repeat(n));
146
+ pushStr(frames, "~".repeat(n), P, i, i + n);
118
147
  i += n;
119
148
  }
120
149
  } else {
121
- pushStr(frames, c);
150
+ pushStr(frames, c, P, i, i + 1);
122
151
  i += 1;
123
152
  }
124
153
  }
@@ -126,17 +155,17 @@ function parseInlinesAt(text: string, base: number): Node[] {
126
155
  // Container end: unclosed spans and delimiters revert to literal text.
127
156
  while (frames.length > 1) {
128
157
  const frame = frames.pop()!;
129
- top(frames).children.push(...revertFrame(frame));
158
+ top(frames).children.push(...revertFrame(frame, P));
130
159
  }
131
160
  const root = frames.pop()!;
132
- return normalize(revertDelims(root.children, root.delims));
161
+ return normalize(revertDelims(root.children, root.delims, P), P);
133
162
  }
134
163
 
135
164
  function top(frames: Frame[]): Frame {
136
165
  return frames[frames.length - 1]!;
137
166
  }
138
167
 
139
- function pushStr(frames: Frame[], s: string): void {
168
+ function pushStr(frames: Frame[], s: string, P: PosCtx | null, cpStart: number, cpEnd: number): void {
140
169
  const frame = top(frames);
141
170
  // An open delimiter sits (invisibly, until it closes) at its recorded
142
171
  // index: text on its far side must not merge into text before it.
@@ -146,10 +175,16 @@ function pushStr(frames: Frame[], s: string): void {
146
175
  const last = children[children.length - 1]!;
147
176
  if (last.type === "text") {
148
177
  last.value += s;
178
+ if (P !== null) {
179
+ const span = P.spans.get(last);
180
+ if (span !== undefined) {
181
+ span.end = P.map[cpEnd]!;
182
+ }
183
+ }
149
184
  return;
150
185
  }
151
186
  }
152
- children.push({ type: "text", value: s });
187
+ children.push(mk(P, { type: "text", value: s }, cpStart, cpEnd));
153
188
  }
154
189
 
155
190
  function runLen(chars: string[], i: number, c: string): number {
@@ -192,6 +227,7 @@ function delimiter(
192
227
  raw: string,
193
228
  frames: Frame[],
194
229
  base: number,
230
+ P: PosCtx | null,
195
231
  ): number {
196
232
  const canClose = i > 0 && !isWs(chars[i - 1]!);
197
233
  const next = chars[i + n];
@@ -201,18 +237,23 @@ function delimiter(
201
237
  const innermost = frame.delims[frame.delims.length - 1];
202
238
  if (canClose && innermost !== undefined && innermost.kind === kind) {
203
239
  frame.delims.pop();
204
- const inner = normalize(frame.children.splice(innermost.idx));
240
+ const inner = normalize(frame.children.splice(innermost.idx), P);
205
241
  frame.children.push(
206
- kind === "em"
207
- ? { type: "emphasis", children: inner }
208
- : kind === "strong"
209
- ? { type: "strong", children: inner }
210
- : { type: "strikethrough", children: inner },
242
+ mk(
243
+ P,
244
+ kind === "em"
245
+ ? { type: "emphasis", children: inner }
246
+ : kind === "strong"
247
+ ? { type: "strong", children: inner }
248
+ : { type: "strikethrough", children: inner },
249
+ innermost.cp,
250
+ i + n,
251
+ ),
211
252
  );
212
253
  } else if (canOpen && !deep) {
213
- frame.delims.push({ kind, idx: frame.children.length, raw });
254
+ frame.delims.push({ kind, idx: frame.children.length, raw, cp: i });
214
255
  } else {
215
- pushStr(frames, raw);
256
+ pushStr(frames, raw, P, i, i + n);
216
257
  }
217
258
  return i + n;
218
259
  }
@@ -225,10 +266,11 @@ function bracket(
225
266
  embed: boolean,
226
267
  frames: Frame[],
227
268
  base: number,
269
+ P: PosCtx | null,
228
270
  ): number {
229
271
  const bang = embed ? open - 1 : open;
230
272
  const fallback = (): number => {
231
- pushStr(frames, chars[bang]!);
273
+ pushStr(frames, chars[bang]!, P, bang, bang + 1);
232
274
  return bang + 1;
233
275
  };
234
276
 
@@ -264,10 +306,19 @@ function bracket(
264
306
  const lexed = lexTarget(chars, k + 2);
265
307
  if (lexed !== null) {
266
308
  const depth = base + totalDepth(frames) + 1;
309
+ // The link text's own positions: the interior is a contiguous slice
310
+ // of this logical text, so its map is a slice of ours.
311
+ const subP: PosCtx | null =
312
+ P === null ? null : { map: P.map.slice(open + 1, k + 1), spans: P.spans };
267
313
  top(frames).children.push(
268
- embed
269
- ? { type: "embed", target: lexed.target, alt: resolveEscapes(interior) }
270
- : { type: "link", target: lexed.target, children: parseInlinesAt(interior, depth) },
314
+ mk(
315
+ P,
316
+ embed
317
+ ? { type: "embed", target: lexed.target, alt: resolveEscapes(interior) }
318
+ : { type: "link", target: lexed.target, children: parseInlinesAt(interior, depth, subP) },
319
+ bang,
320
+ lexed.end,
321
+ ),
271
322
  );
272
323
  return lexed.end;
273
324
  }
@@ -279,15 +330,17 @@ function bracket(
279
330
  const name = interior.slice(1);
280
331
  if (isName(name)) {
281
332
  if (embed) {
282
- pushStr(frames, "!"); // the ! belongs to links/embeds, not spans
333
+ pushStr(frames, "!", P, bang, bang + 1); // the ! belongs to links/embeds, not spans
283
334
  }
284
335
  if (frames.length > 1 && top(frames).name === name) {
285
336
  const frame = frames.pop()!;
286
- const children = normalize(revertDelims(frame.children, frame.delims));
287
- top(frames).children.push({ type: "span", name: frame.name, attrs: frame.attrs, children });
337
+ const children = normalize(revertDelims(frame.children, frame.delims, P), P);
338
+ top(frames).children.push(
339
+ mk(P, { type: "span", name: frame.name, attrs: frame.attrs, children }, frame.openerCp, k + 1),
340
+ );
288
341
  } else {
289
342
  // Well-formed but mismatched or orphan: the characters back.
290
- pushStr(frames, `[/${name}]`);
343
+ pushStr(frames, `[/${name}]`, P, open, k + 1);
291
344
  }
292
345
  return k + 1;
293
346
  }
@@ -299,10 +352,10 @@ function bracket(
299
352
  const opener = parseSpanOpener(interior);
300
353
  if (opener !== null) {
301
354
  if (embed) {
302
- pushStr(frames, "!"); // the ! belongs to links/embeds, not spans
355
+ pushStr(frames, "!", P, bang, bang + 1); // the ! belongs to links/embeds, not spans
303
356
  }
304
357
  if (base + totalDepth(frames) >= MAX_INLINE_DEPTH) {
305
- pushStr(frames, chars.slice(open, k + 1).join(""));
358
+ pushStr(frames, chars.slice(open, k + 1).join(""), P, open, k + 1);
306
359
  } else {
307
360
  frames.push({
308
361
  openerRaw: chars.slice(open, k + 1).join(""),
@@ -310,6 +363,8 @@ function bracket(
310
363
  attrs: opener.attrs,
311
364
  children: [],
312
365
  delims: [],
366
+ openerCp: open,
367
+ openerCpEnd: k + 1,
313
368
  });
314
369
  }
315
370
  return k + 1;
@@ -407,21 +462,28 @@ function resolveEscapes(s: string): string {
407
462
 
408
463
  /** An unclosed span reverts: its opener text, then its children, flattened
409
464
  * into the parent (its own unmatched delimiters reverting first). */
410
- function revertFrame(frame: Frame): Node[] {
411
- const out: Node[] = [{ type: "text", value: frame.openerRaw }];
412
- out.push(...revertDelims(frame.children, frame.delims));
465
+ function revertFrame(frame: Frame, P: PosCtx | null): Node[] {
466
+ const out: Node[] = [
467
+ mk(P, { type: "text", value: frame.openerRaw }, frame.openerCp, frame.openerCpEnd),
468
+ ];
469
+ out.push(...revertDelims(frame.children, frame.delims, P));
413
470
  return out;
414
471
  }
415
472
 
416
- function revertDelims(children: Node[], delims: Delim[]): Node[] {
473
+ function revertDelims(children: Node[], delims: Delim[], P: PosCtx | null): Node[] {
417
474
  for (let d = delims.length - 1; d >= 0; d -= 1) {
418
- children.splice(delims[d]!.idx, 0, { type: "text", value: delims[d]!.raw });
475
+ const delim = delims[d]!;
476
+ children.splice(
477
+ delim.idx,
478
+ 0,
479
+ mk(P, { type: "text", value: delim.raw }, delim.cp, delim.cp + delim.raw.length),
480
+ );
419
481
  }
420
482
  return children;
421
483
  }
422
484
 
423
485
  /** Canonical text: adjacent literals merge, empty text nodes vanish. */
424
- function normalize(children: Node[]): Node[] {
486
+ function normalize(children: Node[], P: PosCtx | null): Node[] {
425
487
  const out: Node[] = [];
426
488
  for (const node of children) {
427
489
  if (node.type === "text") {
@@ -431,6 +493,13 @@ function normalize(children: Node[]): Node[] {
431
493
  const last = out[out.length - 1];
432
494
  if (last !== undefined && last.type === "text") {
433
495
  last.value += node.value;
496
+ if (P !== null) {
497
+ const lastSpan = P.spans.get(last);
498
+ const nodeSpan = P.spans.get(node);
499
+ if (lastSpan !== undefined && nodeSpan !== undefined) {
500
+ lastSpan.end = Math.max(lastSpan.end, nodeSpan.end);
501
+ }
502
+ }
434
503
  continue;
435
504
  }
436
505
  }