@miliastry/quasar 1.0.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.
Files changed (172) hide show
  1. package/LICENSE +119 -0
  2. package/README.md +45 -0
  3. package/dist/Visuals/lyne.css +1027 -0
  4. package/dist/Visuals/osu.css +257 -0
  5. package/dist/index.d.mts +4563 -0
  6. package/dist/index.d.ts +4563 -0
  7. package/dist/index.js +11291 -0
  8. package/dist/index.mjs +11197 -0
  9. package/package.json +51 -0
  10. package/src/Analysis/Contracts/AnalysisReport.ts +26 -0
  11. package/src/Analysis/Contracts/Contribution.ts +76 -0
  12. package/src/Analysis/Contracts/Pass.ts +76 -0
  13. package/src/Analysis/Contracts/PipelineContext.ts +48 -0
  14. package/src/Analysis/Passes/Analysis/GradientAnalyzer.ts +292 -0
  15. package/src/Analysis/Passes/Analysis/MergeableColorAnalyzer.ts +112 -0
  16. package/src/Analysis/Passes/Analysis/RainbowAnalyzer.ts +233 -0
  17. package/src/Analysis/Passes/Analysis/WaveAnalyzer.ts +211 -0
  18. package/src/Analysis/Passes/Analysis/__tests__/GradientAnalyzer.test.ts +135 -0
  19. package/src/Analysis/Passes/Analysis/__tests__/MergeableColorAnalyzer.test.ts +84 -0
  20. package/src/Analysis/Passes/Analysis/__tests__/RainbowAnalyzer.test.ts +99 -0
  21. package/src/Analysis/Passes/Analysis/__tests__/WaveAnalyzer.test.ts +119 -0
  22. package/src/Analysis/Passes/Decision/DefaultDecision.ts +139 -0
  23. package/src/Analysis/Passes/Decision/__tests__/DefaultDecision.test.ts +179 -0
  24. package/src/Analysis/Passes/Transform/CollapseGradientTransform.ts +176 -0
  25. package/src/Analysis/Passes/Transform/MergeColorsTransform.ts +126 -0
  26. package/src/Analysis/Passes/Transform/RainbowCollapseTransform.ts +83 -0
  27. package/src/Analysis/Passes/Transform/WaveCollapseTransform.ts +88 -0
  28. package/src/Analysis/Passes/Utility/CharacterCountAnalyzer.ts +45 -0
  29. package/src/Analysis/Pipeline/Pipeline.ts +133 -0
  30. package/src/Analysis/Pipeline/PipelineBuilder.ts +55 -0
  31. package/src/Analysis/Pipeline/PipelineStage.ts +19 -0
  32. package/src/Analysis/Utils/color-utils.ts +132 -0
  33. package/src/Analysis/__tests__/Integration.test.ts +162 -0
  34. package/src/Analysis/__tests__/Pipeline.test.ts +133 -0
  35. package/src/Analysis/index.ts +52 -0
  36. package/src/BBCode/BBCodeDocumentModel.ts +175 -0
  37. package/src/BBCode/BBCodeToGreenNode.ts +755 -0
  38. package/src/BBCode/Parser.ts +384 -0
  39. package/src/BBCode/index.ts +12 -0
  40. package/src/Collab/positions.ts +91 -0
  41. package/src/Commands/Command.ts +44 -0
  42. package/src/Commands/CommandRegistry.ts +78 -0
  43. package/src/Commands/DeleteNode.ts +20 -0
  44. package/src/Commands/InsertText.ts +21 -0
  45. package/src/Commands/SplitMerge.ts +28 -0
  46. package/src/Commands/WrapInTag.ts +21 -0
  47. package/src/Commands/index.ts +6 -0
  48. package/src/Diff/TreeDiffer.ts +264 -0
  49. package/src/Diff/__tests__/TreeDiffer.test.ts +65 -0
  50. package/src/Diff/index.ts +2 -0
  51. package/src/Events/EventBus.ts +160 -0
  52. package/src/Events/index.ts +2 -0
  53. package/src/Formatter/Formatter.ts +54 -0
  54. package/src/Formatter/index.ts +2 -0
  55. package/src/HTML/HTMLDocumentModel.ts +35 -0
  56. package/src/HTML/HTMLToGreenNode.ts +290 -0
  57. package/src/Incremental/ChangeTracker.ts +105 -0
  58. package/src/Incremental/IncrementalParser.ts +591 -0
  59. package/src/Incremental/__tests__/IncrementalParser.test.ts +164 -0
  60. package/src/Incremental/index.ts +4 -0
  61. package/src/Lexer/BBCodeLexer.ts +382 -0
  62. package/src/Lexer/Lexer.ts +181 -0
  63. package/src/Lexer/index.ts +10 -0
  64. package/src/Linter/Linter.ts +193 -0
  65. package/src/Linter/index.ts +2 -0
  66. package/src/Markdown/MarkdownAST.ts +112 -0
  67. package/src/Markdown/MarkdownDocumentModel.ts +55 -0
  68. package/src/Markdown/MarkdownLexer.ts +203 -0
  69. package/src/Markdown/MarkdownParser.ts +455 -0
  70. package/src/Markdown/MarkdownToGreenNode.ts +153 -0
  71. package/src/Model/DocumentModel.ts +694 -0
  72. package/src/Model/NodeFactory.ts +117 -0
  73. package/src/Model/TagRegistry.ts +495 -0
  74. package/src/Model/index.ts +5 -0
  75. package/src/Plugins/PluginAPI.ts +119 -0
  76. package/src/Plugins/PluginRegistry.ts +132 -0
  77. package/src/Plugins/index.ts +3 -0
  78. package/src/Queries/QueryEngine.ts +152 -0
  79. package/src/Queries/index.ts +1 -0
  80. package/src/RenderPipeline/RenderPipeline.ts +125 -0
  81. package/src/RenderPipeline/RenderTree.ts +134 -0
  82. package/src/RenderPipeline/index.ts +4 -0
  83. package/src/Semantic/SemanticAnalyzer.ts +506 -0
  84. package/src/Semantic/index.ts +2 -0
  85. package/src/Symbols/SymbolTable.ts +124 -0
  86. package/src/Symbols/index.ts +1 -0
  87. package/src/Syntax/GreenNode.ts +324 -0
  88. package/src/Syntax/GreenNodePool.ts +269 -0
  89. package/src/Syntax/NodeMatcher.ts +370 -0
  90. package/src/Syntax/RedNode.ts +569 -0
  91. package/src/Syntax/RedNodeStore.ts +184 -0
  92. package/src/Syntax/TreeBuilder.ts +214 -0
  93. package/src/Syntax/__tests__/GreenNode.test.ts +33 -0
  94. package/src/Syntax/__tests__/RedNode.test.ts +81 -0
  95. package/src/Syntax/__tests__/RedNodeStore.test.ts +104 -0
  96. package/src/Syntax/greenEdit.ts +110 -0
  97. package/src/Syntax/hash.ts +30 -0
  98. package/src/Syntax/index.ts +12 -0
  99. package/src/Syntax/partition.ts +161 -0
  100. package/src/Syntax/preserveNodeIds.ts +201 -0
  101. package/src/Tests/ASTOptimizerIdempotence.test.ts +77 -0
  102. package/src/Tests/BlockPatcher.test.ts +437 -0
  103. package/src/Tests/BlockPatcherWindowed.test.ts +364 -0
  104. package/src/Tests/BoxDrawer.test.ts +217 -0
  105. package/src/Tests/BoxRichTitle.test.ts +105 -0
  106. package/src/Tests/Chars500kBenchmark.test.ts +151 -0
  107. package/src/Tests/Chars500kEdits.test.ts +321 -0
  108. package/src/Tests/CollabPositions.test.ts +146 -0
  109. package/src/Tests/CompilerPathProfiling.test.ts +186 -0
  110. package/src/Tests/DOMMorpher.test.ts +142 -0
  111. package/src/Tests/DomPatchPerf.test.ts +60 -0
  112. package/src/Tests/EffectSegments.snapshot.json +616 -0
  113. package/src/Tests/EffectSegments.test.ts +68 -0
  114. package/src/Tests/FindNodeAtOffset.test.ts +65 -0
  115. package/src/Tests/Fuzzer.test.ts +166 -0
  116. package/src/Tests/GreenNodePool.test.ts +153 -0
  117. package/src/Tests/Lexer.test.ts +238 -0
  118. package/src/Tests/LyneMode.test.ts +187 -0
  119. package/src/Tests/ModelCoherence.test.ts +180 -0
  120. package/src/Tests/Partition.test.ts +238 -0
  121. package/src/Tests/PluginTags.test.ts +150 -0
  122. package/src/Tests/ProblematicSection.test.ts +46 -0
  123. package/src/Tests/ProblematicSectionHTML.test.ts +58 -0
  124. package/src/Tests/RedReuse.test.ts +134 -0
  125. package/src/Tests/ReproDelete20k.test.ts +62 -0
  126. package/src/Tests/SemanticValidators.test.ts +136 -0
  127. package/src/Tests/StableNodeIds.test.ts +210 -0
  128. package/src/Tests/StudioColorBloat.test.ts +25 -0
  129. package/src/Tests/StudioDebugText.test.ts +27 -0
  130. package/src/Tests/StudioTrailingChar.test.ts +25 -0
  131. package/src/Tests/StudioValidText.test.ts +25 -0
  132. package/src/Tests/UrlImgBug.test.ts +23 -0
  133. package/src/Tests/VisualBuilderFidelity.test.ts +105 -0
  134. package/src/Tests/referenceDocument.ts +119 -0
  135. package/src/Transactions/Transaction.ts +176 -0
  136. package/src/Transactions/UndoManager.ts +111 -0
  137. package/src/Transactions/index.ts +3 -0
  138. package/src/Transformers/ASTOptimizer.ts +315 -0
  139. package/src/Transformers/GradientTransformer.ts +143 -0
  140. package/src/Transformers/GrowTransformer.ts +115 -0
  141. package/src/Transformers/RainbowTransformer.ts +121 -0
  142. package/src/Transformers/SineWaveTransformer.ts +130 -0
  143. package/src/Transformers/Transformer.ts +22 -0
  144. package/src/Types/core.ts +270 -0
  145. package/src/Types/diagnostics.ts +156 -0
  146. package/src/Types/index.ts +23 -0
  147. package/src/Types/operations.ts +180 -0
  148. package/src/Types/queries.ts +121 -0
  149. package/src/Types/symbols.ts +69 -0
  150. package/src/Types/tokens.ts +186 -0
  151. package/src/Utils/BBCodeGenerator.ts +126 -0
  152. package/src/Utils/ColorMath.ts +276 -0
  153. package/src/Utils/color.ts +112 -0
  154. package/src/Utils/dom-to-svg.test.ts +86 -0
  155. package/src/Utils/dom-to-svg.ts +615 -0
  156. package/src/Utils/treeTransformers.ts +717 -0
  157. package/src/Visitors/BBBlocksExporter.ts +69 -0
  158. package/src/Visitors/BBCodeExporter.ts +318 -0
  159. package/src/Visitors/BlockPatcher.ts +963 -0
  160. package/src/Visitors/DOMMorpher.ts +134 -0
  161. package/src/Visitors/HTMLRenderer.ts +1077 -0
  162. package/src/Visitors/JSONExporter.ts +66 -0
  163. package/src/Visitors/MarkdownExporter.ts +99 -0
  164. package/src/Visitors/SVGRenderer.ts +35 -0
  165. package/src/Visitors/TiptapExporter.ts +145 -0
  166. package/src/Visitors/Visitor.ts +48 -0
  167. package/src/Visitors/index.ts +9 -0
  168. package/src/Visuals/BoxDrawer.ts +175 -0
  169. package/src/Visuals/index.ts +42 -0
  170. package/src/Visuals/lyne.css +1027 -0
  171. package/src/Visuals/osu.css +257 -0
  172. package/src/index.ts +197 -0
@@ -0,0 +1,370 @@
1
+ /**
2
+ * DocumentEngine — NodeMatcher
3
+ *
4
+ * Matches nodes between old and new syntax trees after edits.
5
+ * Uses stable IDs, fingerprints, and source ranges to determine
6
+ * which nodes were moved, inserted, deleted, or unchanged.
7
+ *
8
+ * Critical for:
9
+ * - Maintaining selection across re-parses
10
+ * - Preserving scroll position
11
+ * - Smooth animations in visual blocks mode
12
+ * - Undo/redo integrity
13
+ */
14
+
15
+ import { RedNode } from './RedNode'
16
+ import type { NodeId } from '../Types/core'
17
+ import { FNV_OFFSET, FNV_OFFSET_ALT, hashString, hashUint32 } from './hash'
18
+
19
+ // ─── Match Result ──────────────────────────────────────────────
20
+
21
+ export type MatchStatus =
22
+ | 'exact' // Same ID, same fingerprint
23
+ | 'moved' // Same ID, different position
24
+ | 'updated' // Same ID, different content
25
+ | 'inserted' // New node (no match)
26
+ | 'deleted' // Old node (no longer present)
27
+ | 'merged' // Two old nodes became one
28
+ | 'split' // One old node became two
29
+
30
+ export interface MatchResult {
31
+ oldNode: RedNode | null
32
+ newNode: RedNode | null
33
+ status: MatchStatus
34
+ confidence: number // 0-1
35
+ }
36
+
37
+ export interface NodeMatch {
38
+ /** Map from old node ID to new node */
39
+ oldToNew: Map<NodeId, RedNode>
40
+ /** Map from new node ID to old node */
41
+ newToOld: Map<NodeId, RedNode>
42
+ /** All matches */
43
+ matches: MatchResult[]
44
+ /** Statistics */
45
+ stats: {
46
+ exact: number
47
+ moved: number
48
+ updated: number
49
+ inserted: number
50
+ deleted: number
51
+ }
52
+ }
53
+
54
+ // ─── NodeMatcher ───────────────────────────────────────────────
55
+
56
+ export class NodeMatcher {
57
+ /**
58
+ * Match nodes between old and new trees.
59
+ *
60
+ * Strategy (in order):
61
+ * 1. Match by stable ID (if present)
62
+ * 2. Match by fingerprint (content hash)
63
+ * 3. Match by source range overlap
64
+ * 4. Fall back to positional heuristic
65
+ */
66
+ match(oldRoot: RedNode, newRoot: RedNode): NodeMatch {
67
+ const oldToNew = new Map<NodeId, RedNode>()
68
+ const newToOld = new Map<NodeId, RedNode>()
69
+ const matches: MatchResult[] = []
70
+ const stats = { exact: 0, moved: 0, updated: 0, inserted: 0, deleted: 0 }
71
+
72
+ // Flatten both trees in document order.
73
+ const oldNodes: RedNode[] = []
74
+ collect(oldRoot, oldNodes)
75
+
76
+ const newNodes: RedNode[] = []
77
+ collect(newRoot, newNodes)
78
+
79
+ const newById = new Map<NodeId, RedNode>()
80
+ for (const node of newNodes) newById.set(node.id, node)
81
+
82
+ const matchedOld = new Set<RedNode>()
83
+ const matchedNew = new Set<RedNode>()
84
+
85
+ // ── Phase 0: Pair structurally unchanged subtrees ──────────
86
+ //
87
+ // A keystroke changes one text node, so only the nodes on the path from the
88
+ // root to the edit actually differ — everything hanging off that path is
89
+ // untouched. `GreenNode._hash` already summarises (kind, text, children)
90
+ // and excludes position, so it identifies those untouched subtrees for free.
91
+ //
92
+ // Pairing them here keeps them out of Phase 2, which is the expensive one:
93
+ // it builds a fingerprint string plus several Map/Set entries per node.
94
+ this.pairUnchanged(
95
+ oldRoot, newRoot,
96
+ oldToNew, newToOld, matches, stats, matchedOld, matchedNew,
97
+ )
98
+
99
+ // ── Phase 1: Match by stable ID ────────────────────────────
100
+ for (const oldNode of oldNodes) {
101
+ if (matchedOld.has(oldNode)) continue
102
+ const newNode = newById.get(oldNode.id)
103
+ if (!newNode || matchedNew.has(newNode)) continue
104
+
105
+ // `index` is O(1) (cached on RedNode), so the traversal no longer has to
106
+ // carry a side table of positions.
107
+ const isSamePosition =
108
+ oldNode.parent?.id === newNode.parent?.id &&
109
+ oldNode.index === newNode.index
110
+ const isSameContent =
111
+ oldNode.text === newNode.text && oldNode.kind === newNode.kind
112
+
113
+ oldToNew.set(oldNode.id, newNode)
114
+ newToOld.set(newNode.id, oldNode)
115
+ matchedOld.add(oldNode)
116
+ matchedNew.add(newNode)
117
+
118
+ let status: MatchStatus = 'exact'
119
+ if (!isSameContent) status = 'updated'
120
+ else if (!isSamePosition) status = 'moved'
121
+
122
+ matches.push({ oldNode, newNode, status, confidence: 1.0 })
123
+
124
+ if (status === 'exact') stats.exact++
125
+ else if (status === 'moved') stats.moved++
126
+ else stats.updated++
127
+ }
128
+
129
+ // ── Phase 2: Match remaining by subtree fingerprint ────────
130
+ //
131
+ // Previously a nested loop over (unmatchedOld x unmatchedNew) that
132
+ // recomputed a full subtree walk on every comparison — O(n^2 * subtree),
133
+ // which cost 380 ms for a one-character edit on a 20 KB document.
134
+ //
135
+ // Fingerprints are now memoized and computed bottom-up (O(n) for the whole
136
+ // tree), then bucketed so each old node consults only the new nodes that
137
+ // could possibly match. Candidates are consumed in document order, which
138
+ // preserves the pairing the nested loop produced.
139
+ const fingerprints = newFingerprintCache()
140
+
141
+ const buckets = new Map<string, RedNode[]>()
142
+ for (const node of newNodes) {
143
+ if (matchedNew.has(node)) continue
144
+ const key = this.fingerprintInto(node, fingerprints)
145
+ const bucket = buckets.get(key)
146
+ if (bucket) bucket.push(node)
147
+ else buckets.set(key, [node])
148
+ }
149
+
150
+ // Cursor per bucket: consuming with `shift()` would reintroduce O(n^2).
151
+ const cursors = new Map<string, number>()
152
+
153
+ for (const oldNode of oldNodes) {
154
+ if (matchedOld.has(oldNode)) continue
155
+
156
+ const key = this.fingerprintInto(oldNode, fingerprints)
157
+ const bucket = buckets.get(key)
158
+ if (!bucket) continue
159
+
160
+ const cursor = cursors.get(key) ?? 0
161
+ if (cursor >= bucket.length) continue
162
+
163
+ const newNode = bucket[cursor]
164
+ cursors.set(key, cursor + 1)
165
+
166
+ oldToNew.set(oldNode.id, newNode)
167
+ newToOld.set(newNode.id, oldNode)
168
+ matchedOld.add(oldNode)
169
+ matchedNew.add(newNode)
170
+
171
+ matches.push({ oldNode, newNode, status: 'moved', confidence: 0.8 })
172
+ stats.moved++
173
+ }
174
+
175
+ // ── Phase 3: Remaining unmatched = deleted / inserted ──────
176
+ // The previous implementation never pruned matched nodes from the "old"
177
+ // set, so every node was reported as moved AND deleted simultaneously.
178
+ for (const node of oldNodes) {
179
+ if (matchedOld.has(node)) continue
180
+ matches.push({ oldNode: node, newNode: null, status: 'deleted', confidence: 1.0 })
181
+ stats.deleted++
182
+ }
183
+
184
+ for (const node of newNodes) {
185
+ if (matchedNew.has(node)) continue
186
+ matches.push({ oldNode: null, newNode: node, status: 'inserted', confidence: 1.0 })
187
+ stats.inserted++
188
+ }
189
+
190
+ return { oldToNew, newToOld, matches, stats }
191
+ }
192
+
193
+ /**
194
+ * Phase 0 — descend in lockstep, pairing every subtree that did not change.
195
+ *
196
+ * At each level the common prefix and common suffix are trimmed by comparing
197
+ * `green._hash`, and only the window between them is descended into. That is
198
+ * what makes this robust to an edit that changes the number of children:
199
+ * requiring equal child counts before recursing sounds reasonable but means a
200
+ * single inserted node near the root disables the fast path for the whole
201
+ * tree — measured, it left `match` at 5.11 ms instead of 0.96 ms.
202
+ *
203
+ * Whatever is left in the middle window is handed to the fingerprint
204
+ * heuristic, which is the part that can cope with reordering.
205
+ */
206
+ private pairUnchanged(
207
+ oldNode: RedNode,
208
+ newNode: RedNode,
209
+ oldToNew: Map<NodeId, RedNode>,
210
+ newToOld: Map<NodeId, RedNode>,
211
+ matches: MatchResult[],
212
+ stats: NodeMatch['stats'],
213
+ matchedOld: Set<RedNode>,
214
+ matchedNew: Set<RedNode>,
215
+ ): void {
216
+ if (oldNode.green._hash === newNode.green._hash) {
217
+ const pairs: RedNode[] = []
218
+ if (verifyIdentical(oldNode, newNode, pairs)) {
219
+ for (let i = 0; i < pairs.length; i += 2) {
220
+ const o = pairs[i]
221
+ const n = pairs[i + 1]
222
+ oldToNew.set(o.id, n)
223
+ newToOld.set(n.id, o)
224
+ matchedOld.add(o)
225
+ matchedNew.add(n)
226
+ matches.push({ oldNode: o, newNode: n, status: 'exact', confidence: 1.0 })
227
+ stats.exact++
228
+ }
229
+ return
230
+ }
231
+ // Hash collision. `_hash` is 32-bit, so this is astronomically rare, but
232
+ // "rare" is not "never" and a false pair would show stale content in the
233
+ // preview. Falling through costs one wasted subtree walk.
234
+ }
235
+
236
+ const oldKids = oldNode.children
237
+ const newKids = newNode.children
238
+ const limit = Math.min(oldKids.length, newKids.length)
239
+
240
+ // Common prefix.
241
+ let lo = 0
242
+ while (lo < limit && oldKids[lo].green._hash === newKids[lo].green._hash) {
243
+ this.pairUnchanged(
244
+ oldKids[lo], newKids[lo],
245
+ oldToNew, newToOld, matches, stats, matchedOld, matchedNew,
246
+ )
247
+ lo++
248
+ }
249
+
250
+ // Common suffix, stopping before the prefix already consumed.
251
+ let oldHi = oldKids.length - 1
252
+ let newHi = newKids.length - 1
253
+ while (
254
+ oldHi >= lo && newHi >= lo &&
255
+ oldKids[oldHi].green._hash === newKids[newHi].green._hash
256
+ ) {
257
+ this.pairUnchanged(
258
+ oldKids[oldHi], newKids[newHi],
259
+ oldToNew, newToOld, matches, stats, matchedOld, matchedNew,
260
+ )
261
+ oldHi--
262
+ newHi--
263
+ }
264
+
265
+ // A single changed child on both sides is the shape a keystroke produces:
266
+ // descend so its own untouched children still get paired.
267
+ if (oldHi === lo && newHi === lo) {
268
+ this.pairUnchanged(
269
+ oldKids[lo], newKids[lo],
270
+ oldToNew, newToOld, matches, stats, matchedOld, matchedNew,
271
+ )
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Compute a content fingerprint for a node's subtree.
277
+ *
278
+ * Two nodes share a fingerprint iff their subtrees have the same pre-order
279
+ * sequence of (kind, text) pairs — the same equivalence the previous
280
+ * string-concatenating implementation tested, but as a 64-bit structural
281
+ * hash instead of an O(subtree) string built on every comparison.
282
+ */
283
+ fingerprint(node: RedNode): string {
284
+ return this.fingerprintInto(node, newFingerprintCache())
285
+ }
286
+
287
+ /** Memoized, bottom-up fingerprint. O(n) across a whole tree. */
288
+ private fingerprintInto(node: RedNode, memo: FingerprintCache): string {
289
+ const cachedKey = memo.keys.get(node)
290
+ if (cachedKey !== undefined) return cachedKey
291
+
292
+ // Two independent lanes give a 64-bit key: with ~10^4 nodes the collision
293
+ // probability is ~10^-11, far below the rate at which the heuristic itself
294
+ // mispairs nodes.
295
+ let h1 = hashString(FNV_OFFSET, node.kind)
296
+ h1 = hashString(h1, node.text)
297
+ h1 = hashUint32(h1, node.children.length)
298
+
299
+ let h2 = hashString(FNV_OFFSET_ALT, node.kind)
300
+ h2 = hashString(h2, node.text)
301
+ h2 = hashUint32(h2, node.children.length)
302
+
303
+ // Fold in each child's numeric lanes rather than re-hashing its key
304
+ // string: the string form is only needed for the bucket map, so it is
305
+ // built once per node instead of once per parent-child edge.
306
+ for (const child of node.children) {
307
+ this.fingerprintInto(child, memo)
308
+ h1 = hashUint32(h1, memo.lane1.get(child)!)
309
+ h2 = hashUint32(h2, memo.lane2.get(child)!)
310
+ }
311
+
312
+ memo.lane1.set(node, h1)
313
+ memo.lane2.set(node, h2)
314
+ const key = h1.toString(36) + ':' + h2.toString(36)
315
+ memo.keys.set(node, key)
316
+ return key
317
+ }
318
+ }
319
+
320
+ /** Per-`match()` memo. Never reused across calls: RedNodes are mutable. */
321
+ interface FingerprintCache {
322
+ lane1: Map<RedNode, number>
323
+ lane2: Map<RedNode, number>
324
+ keys: Map<RedNode, string>
325
+ }
326
+
327
+ function newFingerprintCache(): FingerprintCache {
328
+ return { lane1: new Map(), lane2: new Map(), keys: new Map() }
329
+ }
330
+
331
+ /**
332
+ * Confirm two subtrees really are identical, collecting the node pairs as it
333
+ * goes so a successful check does not need a second traversal.
334
+ *
335
+ * `pairs` is filled as a flat [old, new, old, new, …] list and is only valid
336
+ * when this returns true; callers must discard it otherwise, since the walk
337
+ * bails out the moment it finds a difference.
338
+ */
339
+ function verifyIdentical(oldNode: RedNode, newNode: RedNode, pairs: RedNode[]): boolean {
340
+ if (
341
+ oldNode.kind !== newNode.kind ||
342
+ oldNode.text !== newNode.text ||
343
+ oldNode.children.length !== newNode.children.length
344
+ ) {
345
+ return false
346
+ }
347
+
348
+ pairs.push(oldNode, newNode)
349
+
350
+ const oldKids = oldNode.children
351
+ const newKids = newNode.children
352
+ for (let i = 0; i < oldKids.length; i++) {
353
+ if (!verifyIdentical(oldKids[i], newKids[i], pairs)) return false
354
+ }
355
+ return true
356
+ }
357
+
358
+ /** Flatten a tree in pre-order. */
359
+ function collect(root: RedNode, out: RedNode[]): void {
360
+ const stack: RedNode[] = [root]
361
+ while (stack.length > 0) {
362
+ const node = stack.pop()!
363
+ out.push(node)
364
+ const children = node.children
365
+ // Reverse push so popping yields left-to-right document order.
366
+ for (let i = children.length - 1; i >= 0; i--) {
367
+ stack.push(children[i])
368
+ }
369
+ }
370
+ }