@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.
- package/LICENSE +119 -0
- package/README.md +45 -0
- package/dist/Visuals/lyne.css +1027 -0
- package/dist/Visuals/osu.css +257 -0
- package/dist/index.d.mts +4563 -0
- package/dist/index.d.ts +4563 -0
- package/dist/index.js +11291 -0
- package/dist/index.mjs +11197 -0
- package/package.json +51 -0
- package/src/Analysis/Contracts/AnalysisReport.ts +26 -0
- package/src/Analysis/Contracts/Contribution.ts +76 -0
- package/src/Analysis/Contracts/Pass.ts +76 -0
- package/src/Analysis/Contracts/PipelineContext.ts +48 -0
- package/src/Analysis/Passes/Analysis/GradientAnalyzer.ts +292 -0
- package/src/Analysis/Passes/Analysis/MergeableColorAnalyzer.ts +112 -0
- package/src/Analysis/Passes/Analysis/RainbowAnalyzer.ts +233 -0
- package/src/Analysis/Passes/Analysis/WaveAnalyzer.ts +211 -0
- package/src/Analysis/Passes/Analysis/__tests__/GradientAnalyzer.test.ts +135 -0
- package/src/Analysis/Passes/Analysis/__tests__/MergeableColorAnalyzer.test.ts +84 -0
- package/src/Analysis/Passes/Analysis/__tests__/RainbowAnalyzer.test.ts +99 -0
- package/src/Analysis/Passes/Analysis/__tests__/WaveAnalyzer.test.ts +119 -0
- package/src/Analysis/Passes/Decision/DefaultDecision.ts +139 -0
- package/src/Analysis/Passes/Decision/__tests__/DefaultDecision.test.ts +179 -0
- package/src/Analysis/Passes/Transform/CollapseGradientTransform.ts +176 -0
- package/src/Analysis/Passes/Transform/MergeColorsTransform.ts +126 -0
- package/src/Analysis/Passes/Transform/RainbowCollapseTransform.ts +83 -0
- package/src/Analysis/Passes/Transform/WaveCollapseTransform.ts +88 -0
- package/src/Analysis/Passes/Utility/CharacterCountAnalyzer.ts +45 -0
- package/src/Analysis/Pipeline/Pipeline.ts +133 -0
- package/src/Analysis/Pipeline/PipelineBuilder.ts +55 -0
- package/src/Analysis/Pipeline/PipelineStage.ts +19 -0
- package/src/Analysis/Utils/color-utils.ts +132 -0
- package/src/Analysis/__tests__/Integration.test.ts +162 -0
- package/src/Analysis/__tests__/Pipeline.test.ts +133 -0
- package/src/Analysis/index.ts +52 -0
- package/src/BBCode/BBCodeDocumentModel.ts +175 -0
- package/src/BBCode/BBCodeToGreenNode.ts +755 -0
- package/src/BBCode/Parser.ts +384 -0
- package/src/BBCode/index.ts +12 -0
- package/src/Collab/positions.ts +91 -0
- package/src/Commands/Command.ts +44 -0
- package/src/Commands/CommandRegistry.ts +78 -0
- package/src/Commands/DeleteNode.ts +20 -0
- package/src/Commands/InsertText.ts +21 -0
- package/src/Commands/SplitMerge.ts +28 -0
- package/src/Commands/WrapInTag.ts +21 -0
- package/src/Commands/index.ts +6 -0
- package/src/Diff/TreeDiffer.ts +264 -0
- package/src/Diff/__tests__/TreeDiffer.test.ts +65 -0
- package/src/Diff/index.ts +2 -0
- package/src/Events/EventBus.ts +160 -0
- package/src/Events/index.ts +2 -0
- package/src/Formatter/Formatter.ts +54 -0
- package/src/Formatter/index.ts +2 -0
- package/src/HTML/HTMLDocumentModel.ts +35 -0
- package/src/HTML/HTMLToGreenNode.ts +290 -0
- package/src/Incremental/ChangeTracker.ts +105 -0
- package/src/Incremental/IncrementalParser.ts +591 -0
- package/src/Incremental/__tests__/IncrementalParser.test.ts +164 -0
- package/src/Incremental/index.ts +4 -0
- package/src/Lexer/BBCodeLexer.ts +382 -0
- package/src/Lexer/Lexer.ts +181 -0
- package/src/Lexer/index.ts +10 -0
- package/src/Linter/Linter.ts +193 -0
- package/src/Linter/index.ts +2 -0
- package/src/Markdown/MarkdownAST.ts +112 -0
- package/src/Markdown/MarkdownDocumentModel.ts +55 -0
- package/src/Markdown/MarkdownLexer.ts +203 -0
- package/src/Markdown/MarkdownParser.ts +455 -0
- package/src/Markdown/MarkdownToGreenNode.ts +153 -0
- package/src/Model/DocumentModel.ts +694 -0
- package/src/Model/NodeFactory.ts +117 -0
- package/src/Model/TagRegistry.ts +495 -0
- package/src/Model/index.ts +5 -0
- package/src/Plugins/PluginAPI.ts +119 -0
- package/src/Plugins/PluginRegistry.ts +132 -0
- package/src/Plugins/index.ts +3 -0
- package/src/Queries/QueryEngine.ts +152 -0
- package/src/Queries/index.ts +1 -0
- package/src/RenderPipeline/RenderPipeline.ts +125 -0
- package/src/RenderPipeline/RenderTree.ts +134 -0
- package/src/RenderPipeline/index.ts +4 -0
- package/src/Semantic/SemanticAnalyzer.ts +506 -0
- package/src/Semantic/index.ts +2 -0
- package/src/Symbols/SymbolTable.ts +124 -0
- package/src/Symbols/index.ts +1 -0
- package/src/Syntax/GreenNode.ts +324 -0
- package/src/Syntax/GreenNodePool.ts +269 -0
- package/src/Syntax/NodeMatcher.ts +370 -0
- package/src/Syntax/RedNode.ts +569 -0
- package/src/Syntax/RedNodeStore.ts +184 -0
- package/src/Syntax/TreeBuilder.ts +214 -0
- package/src/Syntax/__tests__/GreenNode.test.ts +33 -0
- package/src/Syntax/__tests__/RedNode.test.ts +81 -0
- package/src/Syntax/__tests__/RedNodeStore.test.ts +104 -0
- package/src/Syntax/greenEdit.ts +110 -0
- package/src/Syntax/hash.ts +30 -0
- package/src/Syntax/index.ts +12 -0
- package/src/Syntax/partition.ts +161 -0
- package/src/Syntax/preserveNodeIds.ts +201 -0
- package/src/Tests/ASTOptimizerIdempotence.test.ts +77 -0
- package/src/Tests/BlockPatcher.test.ts +437 -0
- package/src/Tests/BlockPatcherWindowed.test.ts +364 -0
- package/src/Tests/BoxDrawer.test.ts +217 -0
- package/src/Tests/BoxRichTitle.test.ts +105 -0
- package/src/Tests/Chars500kBenchmark.test.ts +151 -0
- package/src/Tests/Chars500kEdits.test.ts +321 -0
- package/src/Tests/CollabPositions.test.ts +146 -0
- package/src/Tests/CompilerPathProfiling.test.ts +186 -0
- package/src/Tests/DOMMorpher.test.ts +142 -0
- package/src/Tests/DomPatchPerf.test.ts +60 -0
- package/src/Tests/EffectSegments.snapshot.json +616 -0
- package/src/Tests/EffectSegments.test.ts +68 -0
- package/src/Tests/FindNodeAtOffset.test.ts +65 -0
- package/src/Tests/Fuzzer.test.ts +166 -0
- package/src/Tests/GreenNodePool.test.ts +153 -0
- package/src/Tests/Lexer.test.ts +238 -0
- package/src/Tests/LyneMode.test.ts +187 -0
- package/src/Tests/ModelCoherence.test.ts +180 -0
- package/src/Tests/Partition.test.ts +238 -0
- package/src/Tests/PluginTags.test.ts +150 -0
- package/src/Tests/ProblematicSection.test.ts +46 -0
- package/src/Tests/ProblematicSectionHTML.test.ts +58 -0
- package/src/Tests/RedReuse.test.ts +134 -0
- package/src/Tests/ReproDelete20k.test.ts +62 -0
- package/src/Tests/SemanticValidators.test.ts +136 -0
- package/src/Tests/StableNodeIds.test.ts +210 -0
- package/src/Tests/StudioColorBloat.test.ts +25 -0
- package/src/Tests/StudioDebugText.test.ts +27 -0
- package/src/Tests/StudioTrailingChar.test.ts +25 -0
- package/src/Tests/StudioValidText.test.ts +25 -0
- package/src/Tests/UrlImgBug.test.ts +23 -0
- package/src/Tests/VisualBuilderFidelity.test.ts +105 -0
- package/src/Tests/referenceDocument.ts +119 -0
- package/src/Transactions/Transaction.ts +176 -0
- package/src/Transactions/UndoManager.ts +111 -0
- package/src/Transactions/index.ts +3 -0
- package/src/Transformers/ASTOptimizer.ts +315 -0
- package/src/Transformers/GradientTransformer.ts +143 -0
- package/src/Transformers/GrowTransformer.ts +115 -0
- package/src/Transformers/RainbowTransformer.ts +121 -0
- package/src/Transformers/SineWaveTransformer.ts +130 -0
- package/src/Transformers/Transformer.ts +22 -0
- package/src/Types/core.ts +270 -0
- package/src/Types/diagnostics.ts +156 -0
- package/src/Types/index.ts +23 -0
- package/src/Types/operations.ts +180 -0
- package/src/Types/queries.ts +121 -0
- package/src/Types/symbols.ts +69 -0
- package/src/Types/tokens.ts +186 -0
- package/src/Utils/BBCodeGenerator.ts +126 -0
- package/src/Utils/ColorMath.ts +276 -0
- package/src/Utils/color.ts +112 -0
- package/src/Utils/dom-to-svg.test.ts +86 -0
- package/src/Utils/dom-to-svg.ts +615 -0
- package/src/Utils/treeTransformers.ts +717 -0
- package/src/Visitors/BBBlocksExporter.ts +69 -0
- package/src/Visitors/BBCodeExporter.ts +318 -0
- package/src/Visitors/BlockPatcher.ts +963 -0
- package/src/Visitors/DOMMorpher.ts +134 -0
- package/src/Visitors/HTMLRenderer.ts +1077 -0
- package/src/Visitors/JSONExporter.ts +66 -0
- package/src/Visitors/MarkdownExporter.ts +99 -0
- package/src/Visitors/SVGRenderer.ts +35 -0
- package/src/Visitors/TiptapExporter.ts +145 -0
- package/src/Visitors/Visitor.ts +48 -0
- package/src/Visitors/index.ts +9 -0
- package/src/Visuals/BoxDrawer.ts +175 -0
- package/src/Visuals/index.ts +42 -0
- package/src/Visuals/lyne.css +1027 -0
- package/src/Visuals/osu.css +257 -0
- 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
|
+
}
|