@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,324 @@
1
+ /**
2
+ * DocumentEngine — GreenNode (Immutable Syntax Tree)
3
+ *
4
+ * The Green Tree is IMMUTABLE. It never changes after creation.
5
+ * This is the "compiled" representation of the source text.
6
+ *
7
+ * Properties:
8
+ * - Fully immutable (readonly)
9
+ * - Contains only structural information (kind, text, children, widths)
10
+ * - **No absolute position** — see below
11
+ * - No parent references
12
+ * - No identity (IDs live in Red Tree)
13
+ * - Can be shared across document versions
14
+ * - Safe to cache
15
+ *
16
+ * ─── Why there is no `range` ────────────────────────────────────────────────
17
+ *
18
+ * A green node knows how WIDE it is, not WHERE it is. That is the whole point:
19
+ * the two `[color=#e8b04b]` runs in a gradient are the same structure, and if
20
+ * each carried its own offsets they could never be the same object. Measured on
21
+ * the reference document: 1736 nodes, but only 702 distinct structures — 59.6%
22
+ * of the tree is duplicate.
23
+ *
24
+ * Position is a property of a node's PLACE in a tree, so it lives on the red
25
+ * node, which is the thing that has a place. `RedNode.range` computes it once
26
+ * at construction by accumulating widths from the root and caches it, so
27
+ * consumers see exactly the same `{start, end}` they always did.
28
+ *
29
+ * This also makes incremental editing cheaper: text inserted before a subtree
30
+ * changes where it is, not what it is, so nothing about it needs rebuilding.
31
+ * `shiftGreen` — which used to deep-copy every following sibling to bump its
32
+ * offsets — no longer exists, because there is nothing left to shift.
33
+ *
34
+ * ─── Why `width` is derived and never passed ────────────────────────────────
35
+ *
36
+ * `width = leadingWidth + (children ? Σ child widths : ownWidth) + trailingWidth`
37
+ *
38
+ * The partition invariant of roadmap point 14 — every character owned exactly
39
+ * once — used to be a runtime check that a careless caller could violate. Now
40
+ * it is arithmetic the constructor performs, so a tree that does not partition
41
+ * its source cannot be built. `Syntax/partition.ts` still exists to verify the
42
+ * one thing this cannot: that the root's width matches the source length.
43
+ *
44
+ * Inspired by Roslyn's Green Tree and SwiftSyntax.
45
+ */
46
+
47
+ import { FNV_OFFSET, hashString, hashUint32 } from './hash'
48
+
49
+ /**
50
+ * Shared frozen empty array for childless nodes.
51
+ *
52
+ * Over half the nodes in a document are leaves, and each was allocating and
53
+ * freezing its own empty array — 930 of them on the reference document, for
54
+ * 17% of the parser. They cannot differ from one another; one instance does.
55
+ */
56
+ const NO_CHILDREN: readonly GreenNode[] = Object.freeze([])
57
+
58
+ /**
59
+ * Whether to freeze children lists at all.
60
+ *
61
+ * `Object.freeze` per node cost 32% of the parser, buying a runtime guard
62
+ * against a mutation that `readonly GreenNode[]` already rejects at compile
63
+ * time. Keeping it outside production means the throw still happens where
64
+ * mistakes are made and caught — in development and in the test suite — and
65
+ * production pays nothing for a check that has never once fired there.
66
+ *
67
+ * Evaluated once at module load, so the hot path sees a constant. The `typeof`
68
+ * guard is because this engine also runs inside Workers, where a bundler may
69
+ * not have shimmed `process`.
70
+ */
71
+ const FREEZE_CHILDREN =
72
+ typeof process === 'undefined' || process.env?.NODE_ENV !== 'production'
73
+
74
+ // ─── Green Node ────────────────────────────────────────────────
75
+
76
+ export class GreenNode {
77
+ /** Syntax kind — maps to TokenKind for leaves, NodeKind for internals */
78
+ readonly kind: string
79
+ /** Raw text of this node (empty for internal nodes) */
80
+ readonly text: string
81
+ /** Child nodes */
82
+ readonly children: readonly GreenNode[]
83
+ /** Total width of this node in characters, delimiters included */
84
+ readonly width: number
85
+ /** Whether this is a leaf (token) or internal node */
86
+ readonly isLeaf: boolean
87
+
88
+ /**
89
+ * Characters this node owns BEFORE its first child — its opening delimiter.
90
+ * For `[b]hola[/b]` that is the 3 characters of `[b]`.
91
+ *
92
+ * `declare` is load-bearing here for the same reason as `_hashCache` below:
93
+ * a plain field declaration would emit a `defineProperty` per construction.
94
+ */
95
+ declare readonly leadingWidth: number
96
+ /** Characters this node owns AFTER its last child — its closing delimiter. */
97
+ declare readonly trailingWidth: number
98
+
99
+ /**
100
+ * Memoized structural hash — computed on first access, never invalidated.
101
+ *
102
+ * `declare` is load-bearing: with `target: ES2022`, TypeScript defaults
103
+ * `useDefineForClassFields` to true, so a plain field declaration emits a
104
+ * per-construction `defineProperty`. Measured on a 19.6 KB document that
105
+ * cost 23% of the parse phase (0.583 ms → 0.718 ms) for a slot most nodes
106
+ * never read. `declare` emits nothing; the constructor assigns it directly,
107
+ * which keeps the object shape monomorphic without the field-init overhead.
108
+ */
109
+ private declare _hashCache: number
110
+
111
+ /**
112
+ * @param ownWidth Width of a childless node's own content: the length of a
113
+ * `text` token, or the 1-2 characters of a newline. Ignored
114
+ * when there are children, whose widths sum to the same
115
+ * thing. An empty element like `[b][/b]` passes 0 and gets
116
+ * its width from its two delimiters.
117
+ */
118
+ constructor(
119
+ kind: string,
120
+ text: string,
121
+ children: GreenNode[] = [],
122
+ leadingWidth: number = 0,
123
+ trailingWidth: number = 0,
124
+ ownWidth: number = 0,
125
+ ) {
126
+ this.kind = kind
127
+ this.text = text
128
+ const count = children.length
129
+ this.children =
130
+ count === 0
131
+ ? NO_CHILDREN
132
+ : FREEZE_CHILDREN
133
+ ? Object.freeze([...children])
134
+ : [...children]
135
+ this.isLeaf = count === 0
136
+ this.leadingWidth = leadingWidth
137
+ this.trailingWidth = trailingWidth
138
+
139
+ let inner = ownWidth
140
+ if (children.length > 0) {
141
+ inner = 0
142
+ for (let i = 0; i < children.length; i++) inner += children[i].width
143
+ }
144
+ this.width = leadingWidth + inner + trailingWidth
145
+
146
+ // -1 is the "not yet computed" sentinel: `_hash` is always `>>> 0`, so it
147
+ // can never legitimately be negative.
148
+ this._hashCache = -1
149
+ }
150
+
151
+ // ─── Structural Identity ─────────────────────────────────
152
+
153
+ /**
154
+ * Structural hash of this subtree: `(kind, text, widths, children hashes)`.
155
+ *
156
+ * Two subtrees that render identically at different offsets ARE structurally
157
+ * identical — that is the entire premise of structural sharing, and now that
158
+ * green nodes carry no position it is simply true rather than something the
159
+ * hash had to be careful to arrange.
160
+ *
161
+ * The widths ARE folded in: they are structure, not position. A `text` leaf
162
+ * of width 4 and one of width 5 are different nodes.
163
+ *
164
+ * Computed lazily: parsing paths that never intern pay nothing. Safe to
165
+ * memoize because a GreenNode is immutable once constructed.
166
+ */
167
+ get _hash(): number {
168
+ let h = this._hashCache
169
+ if (h === -1) {
170
+ h = hashString(FNV_OFFSET, this.kind)
171
+ h = hashString(h, this.text)
172
+ h = hashUint32(h, this.width)
173
+ h = hashUint32(h, this.leadingWidth)
174
+ h = hashUint32(h, this.trailingWidth)
175
+ h = hashUint32(h, this.children.length)
176
+ for (const child of this.children as GreenNode[]) {
177
+ h = hashUint32(h, child._hash)
178
+ }
179
+ this._hashCache = h
180
+ }
181
+ return h
182
+ }
183
+
184
+ // ─── Query Methods ───────────────────────────────────────
185
+
186
+ /** Get child at index */
187
+ childAt(index: number): GreenNode | undefined {
188
+ return this.children[index] as GreenNode | undefined
189
+ }
190
+
191
+ /** Find first child matching predicate */
192
+ findChild(predicate: (n: GreenNode) => boolean): GreenNode | undefined {
193
+ return (this.children as GreenNode[]).find(predicate)
194
+ }
195
+
196
+ /** Count of direct children */
197
+ get childCount(): number {
198
+ return this.children.length
199
+ }
200
+
201
+ /** Check if this node has a specific kind */
202
+ isKind(kind: string): boolean {
203
+ return this.kind === kind
204
+ }
205
+
206
+ /**
207
+ * Offset of this node's first child, relative to this node's own start.
208
+ * Equal to `leadingWidth`; named for the places that read it as a position.
209
+ */
210
+ get innerOffset(): number {
211
+ return this.leadingWidth
212
+ }
213
+
214
+ /** Width of the span between this node's delimiters. */
215
+ get innerWidth(): number {
216
+ return this.width - this.leadingWidth - this.trailingWidth
217
+ }
218
+
219
+ /** Walk all descendants in pre-order */
220
+ walk(visitor: (node: GreenNode, depth: number) => void | 'skip', depth: number = 0): void {
221
+ const result = visitor(this, depth)
222
+ if (result !== 'skip') {
223
+ for (const child of this.children as GreenNode[]) {
224
+ child.walk(visitor, depth + 1)
225
+ }
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Walk all descendants in pre-order, with each node's absolute offset.
231
+ *
232
+ * The replacement for the walks that used to read `node.range`. Offsets are
233
+ * accumulated on the way down, which is the only place they exist.
234
+ */
235
+ walkWithOffset(
236
+ visitor: (node: GreenNode, start: number, depth: number) => void | 'skip',
237
+ start: number = 0,
238
+ depth: number = 0,
239
+ ): void {
240
+ if (visitor(this, start, depth) === 'skip') return
241
+ let offset = start + this.leadingWidth
242
+ for (const child of this.children as GreenNode[]) {
243
+ child.walkWithOffset(visitor, offset, depth + 1)
244
+ offset += child.width
245
+ }
246
+ }
247
+
248
+ /** Convert to a debug string */
249
+ toString(depth: number = 0, start: number = 0): string {
250
+ const indent = ' '.repeat(depth)
251
+ const rangeStr = `[${start}..${start + this.width}]`
252
+ const textPreview = this.text.length > 20
253
+ ? this.text.slice(0, 20) + '...'
254
+ : this.text
255
+
256
+ let result = `${indent}${this.kind} ${rangeStr} "${textPreview}"`
257
+ if (this.children.length > 0) {
258
+ let offset = start + this.leadingWidth
259
+ const parts: string[] = []
260
+ for (const child of this.children as GreenNode[]) {
261
+ parts.push(child.toString(depth + 1, offset))
262
+ offset += child.width
263
+ }
264
+ result += '\n' + parts.join('\n')
265
+ }
266
+ return result
267
+ }
268
+
269
+ /** Get the source text for this node, given where it starts. */
270
+ getSourceText(source: string, start: number): string {
271
+ return source.slice(start, start + this.width)
272
+ }
273
+ }
274
+
275
+ // ─── Green Tree Factory ────────────────────────────────────────
276
+
277
+ /**
278
+ * Build an internal node. Its width comes from its children plus its own
279
+ * delimiters — there is no way to declare a width that disagrees with them.
280
+ */
281
+ export function greenNode(
282
+ kind: string,
283
+ text: string,
284
+ children: GreenNode[] = [],
285
+ leadingWidth: number = 0,
286
+ trailingWidth: number = 0,
287
+ ownWidth: number = 0,
288
+ ): GreenNode {
289
+ return new GreenNode(kind, text, children, leadingWidth, trailingWidth, ownWidth)
290
+ }
291
+
292
+ /**
293
+ * Absolute offsets of `node`'s children, given where `node` itself starts.
294
+ *
295
+ * Returns `children.length + 1` entries: entry `i` is where child `i` begins
296
+ * and entry `i + 1` is where it ends, so a run of children `[a, b)` spans
297
+ * `offsets[a] .. offsets[b]`.
298
+ *
299
+ * For code that walks the green tree and needs source positions — analyzers
300
+ * reporting diagnostics, transforms recording what they collapsed. Green nodes
301
+ * have no positions of their own, so a walk that needs them accumulates them,
302
+ * and this is the one-liner for doing that at a single level.
303
+ */
304
+ export function childOffsets(node: GreenNode, start: number): number[] {
305
+ const children = node.children as readonly GreenNode[]
306
+ const offsets = new Array<number>(children.length + 1)
307
+ let offset = start + node.leadingWidth
308
+ for (let i = 0; i < children.length; i++) {
309
+ offsets[i] = offset
310
+ offset += children[i].width
311
+ }
312
+ offsets[children.length] = offset
313
+ return offsets
314
+ }
315
+
316
+ /**
317
+ * Build a token. `width` defaults to the text's own length, which is right for
318
+ * every leaf whose text IS its source (`text` nodes). Leaves whose `text` holds
319
+ * something else — `spacing` and `empty_line` carry `''` but occupy 1-2
320
+ * characters, element nodes carry their attributes — must pass it.
321
+ */
322
+ export function greenLeaf(kind: string, text: string, width: number = text.length): GreenNode {
323
+ return new GreenNode(kind, text, [], 0, 0, width)
324
+ }
@@ -0,0 +1,269 @@
1
+ /**
2
+ * DocumentEngine — GreenNodePool (Structural Sharing via Interning)
3
+ *
4
+ * Roslyn-grade GreenNode Deduplication Pool.
5
+ *
6
+ * Ensures that structurally identical subtrees share the EXACT SAME
7
+ * GreenNode memory address in RAM. This is structural sharing:
8
+ * when the same BBCode block is pasted 10 times, the parser creates
9
+ * GreenNodes for it ONCE, and all 10 copies reference the same objects.
10
+ *
11
+ * Architecture:
12
+ * - Flyweight Pattern: GreenNodes are pure structural data without parent pointers.
13
+ * - Hash-based lookup: O(1) amortized via _hash property on GreenNode.
14
+ * - Reference-equality verification: identical children = same reference (because
15
+ * children are interned first, bottom-up construction guarantees this).
16
+ * - Per-document scope: interners are created per BBCodeDocumentModel, cleared on dispose.
17
+ *
18
+ * Key insight: because GreenNodes are immutable and children are interned bottom-up,
19
+ * two nodes with the same (kind, text, widths, children-references) are structurally
20
+ * identical. We use _hash for fast lookup, then verify with reference equality on
21
+ * children.
22
+ *
23
+ * ─── Why this used to be broken (roadmap S2) ────────────────────────────────
24
+ *
25
+ * Green nodes used to carry an absolute `range`, and the two halves of the pool
26
+ * disagreed about what to do with it:
27
+ *
28
+ * - `internLeaf` keyed on `kind:text` alone, so the second `"Hello"` in a
29
+ * document got back the node built for the FIRST one — carrying the first
30
+ * one's offsets. Enabling the interner made every position downstream lie
31
+ * silently: incremental reparse, `findNodeAtOffset`, diagnostics, the
32
+ * Monaco↔AST mapping.
33
+ * - `internNode` did the opposite and required identical start AND end before
34
+ * deduplicating, which no two distinct occurrences can have. The same block
35
+ * pasted ten times gave 0% dedup and 100% of the scanning cost.
36
+ *
37
+ * Neither was fixable while position lived on the green node, because those are
38
+ * the only two options: ignore it and lie, or respect it and never match. The
39
+ * fix was to remove position from the green tree entirely — see `GreenNode.ts`.
40
+ * Now `kind:text:width` genuinely identifies a leaf, and interning is simply
41
+ * correct.
42
+ */
43
+
44
+ import { GreenNode } from './GreenNode'
45
+
46
+ export interface GreenNodePoolStats {
47
+ size: number
48
+ hits: number
49
+ misses: number
50
+ deduplicatedCount: number
51
+ /** Number of hash collisions that were verified as true duplicates */
52
+ collisionHits: number
53
+ /** Number of hash collisions that were false positives */
54
+ collisionMisses: number
55
+ }
56
+
57
+ /**
58
+ * How much of the tree to deduplicate.
59
+ *
60
+ * Measured on the 19.6 KB reference document, against no interning at all:
61
+ *
62
+ * | mode | parse time | green tree memory |
63
+ * |----------|------------|-------------------|
64
+ * | `leaves` | +16% | −42% |
65
+ * | `full` | +110% | −56% |
66
+ *
67
+ * `leaves` is the default because it buys three quarters of the memory for a
68
+ * seventh of the time. Interning an internal node means hashing its whole
69
+ * subtree — `_hash` is lazy, so a parse that never interns never pays for it —
70
+ * and then walking its children to verify a bucket hit. Leaves cost one string
71
+ * key and one Map lookup, and they are where the duplication is: the reference
72
+ * document has 930 leaves and only 122 distinct ones.
73
+ */
74
+ export type InterningMode = 'leaves' | 'full'
75
+
76
+ export class GreenNodePool {
77
+ private static _instance: GreenNodePool | null = null
78
+ private readonly _mode: InterningMode
79
+ private _leafCache = new Map<string, GreenNode>()
80
+ private _nodeCache = new Map<number, GreenNode[]>()
81
+ private _hits = 0
82
+ private _misses = 0
83
+ private _collisionHits = 0
84
+ private _collisionMisses = 0
85
+
86
+ /** Singleton instance accessor (for backward compatibility) */
87
+ static get instance(): GreenNodePool {
88
+ if (!GreenNodePool._instance) {
89
+ GreenNodePool._instance = new GreenNodePool()
90
+ }
91
+ return GreenNodePool._instance
92
+ }
93
+
94
+ constructor(mode: InterningMode = 'leaves') {
95
+ this._mode = mode
96
+ }
97
+
98
+ /**
99
+ * Create a new isolated interner (for per-document use).
100
+ * The returned pool shares no cache with the singleton.
101
+ */
102
+ static create(mode: InterningMode = 'leaves'): GreenNodePool {
103
+ return new GreenNodePool(mode)
104
+ }
105
+
106
+ /**
107
+ * Intern (deduplicate) a leaf GreenNode.
108
+ * If an identical leaf exists in the pool, returns the cached instance.
109
+ * Leaf key: `kind:text` — deterministic, no collision possible.
110
+ */
111
+ internLeaf(kind: string, text: string, width = text.length): GreenNode {
112
+ // `width` belongs in the key: `spacing` carries no text but occupies one or
113
+ // two characters depending on whether the source used `\n` or `\r\n`, and
114
+ // those are different tokens.
115
+ const key = `${kind}:${width}:${text}`
116
+ const existing = this._leafCache.get(key)
117
+ if (existing) {
118
+ this._hits++
119
+ return existing
120
+ }
121
+
122
+ this._misses++
123
+ const newNode = new GreenNode(kind, text, [], 0, 0, width)
124
+ this._leafCache.set(key, newNode)
125
+ return newNode
126
+ }
127
+
128
+ /**
129
+ * Intern an internal GreenNode (structural sharing).
130
+ *
131
+ * Strategy:
132
+ * 1. Compute hash from (kind, text, children's _hash values) — O(n) where n = children count
133
+ * 2. Look up hash in bucket — O(1) amortized
134
+ * 3. Verify with reference equality on children — O(n) but cache-friendly
135
+ * 4. If verified, return existing (structural sharing achieved)
136
+ * 5. If not, store and return new node
137
+ *
138
+ * Because children are interned bottom-up, identical children are the same reference.
139
+ * This makes reference equality a perfect structural equality check.
140
+ */
141
+ internNode(
142
+ kind: string,
143
+ text: string,
144
+ children: GreenNode[],
145
+ leadingWidth = 0,
146
+ trailingWidth = 0,
147
+ ): GreenNode {
148
+ if (this._mode === 'leaves') {
149
+ return new GreenNode(kind, text, children, leadingWidth, trailingWidth)
150
+ }
151
+ // Fast path: check if we already have this exact node by hash
152
+ // For nodes with same kind+text+children-count, the hash is likely unique.
153
+ // We use the hash as the bucket key, then verify all children by reference.
154
+ const bucketKey = this.computeBucketKey(kind, text, children, leadingWidth, trailingWidth)
155
+ const bucket = this._nodeCache.get(bucketKey)
156
+ if (bucket) {
157
+ for (const existing of bucket) {
158
+ if (existing.kind === kind
159
+ && existing.text === text
160
+ && existing.children.length === children.length
161
+ && existing.leadingWidth === leadingWidth
162
+ && existing.trailingWidth === trailingWidth) {
163
+ // Verify children by reference (O(n) but cache-linear)
164
+ let allMatch = true
165
+ for (let i = 0; i < children.length; i++) {
166
+ if (existing.children[i] !== children[i]) {
167
+ allMatch = false
168
+ break
169
+ }
170
+ }
171
+ if (allMatch) {
172
+ this._hits++
173
+ this._collisionHits++
174
+ return existing
175
+ }
176
+ }
177
+ }
178
+ // Hash collision — different node with same bucket key
179
+ this._collisionMisses++
180
+ }
181
+
182
+ this._misses++
183
+ const newNode = new GreenNode(kind, text, children, leadingWidth, trailingWidth)
184
+
185
+ if (bucket) {
186
+ bucket.push(newNode)
187
+ } else {
188
+ this._nodeCache.set(bucketKey, [newNode])
189
+ }
190
+
191
+ return newNode
192
+ }
193
+
194
+ /**
195
+ * Compute a bucket key for internal nodes.
196
+ * Uses (kind, text, children-count, first-child-hash) for fast bucketing.
197
+ * This gives O(1) bucket lookup in the common case.
198
+ */
199
+ private computeBucketKey(
200
+ kind: string,
201
+ text: string,
202
+ children: GreenNode[],
203
+ leadingWidth: number,
204
+ trailingWidth: number,
205
+ ): number {
206
+ let h = this.fnv1a(kind)
207
+ h = this.hashCombine(h, this.fnv1a(text))
208
+ h = this.hashCombine(h, leadingWidth)
209
+ h = this.hashCombine(h, trailingWidth)
210
+ h = this.hashCombine(h, children.length)
211
+ if (children.length > 0) {
212
+ h = this.hashCombine(h, children[0]._hash)
213
+ }
214
+ return h
215
+ }
216
+
217
+ private fnv1a(str: string): number {
218
+ let hash = 0x811c9dc5
219
+ for (let i = 0; i < str.length; i++) {
220
+ hash ^= str.charCodeAt(i)
221
+ hash = Math.imul(hash, 0x01000193)
222
+ }
223
+ return hash >>> 0
224
+ }
225
+
226
+ private hashCombine(seed: number, value: number): number {
227
+ seed ^= value
228
+ seed = Math.imul(seed, 0x01000193)
229
+ return seed >>> 0
230
+ }
231
+
232
+ /** Clear the pool cache */
233
+ clear(): void {
234
+ this._leafCache.clear()
235
+ this._nodeCache.clear()
236
+ this._hits = 0
237
+ this._misses = 0
238
+ this._collisionHits = 0
239
+ this._collisionMisses = 0
240
+ }
241
+
242
+ /** Get current pool statistics */
243
+ get stats(): GreenNodePoolStats {
244
+ return {
245
+ size: this._leafCache.size + this._nodeCache.size,
246
+ hits: this._hits,
247
+ misses: this._misses,
248
+ deduplicatedCount: this._hits,
249
+ collisionHits: this._collisionHits,
250
+ collisionMisses: this._collisionMisses,
251
+ }
252
+ }
253
+ }
254
+
255
+ /** Convenience helper for interning green leaves */
256
+ export function internGreenLeaf(kind: string, text: string, width = text.length): GreenNode {
257
+ return GreenNodePool.instance.internLeaf(kind, text, width)
258
+ }
259
+
260
+ /** Convenience helper for interning green nodes */
261
+ export function internGreenNode(
262
+ kind: string,
263
+ text: string,
264
+ children: GreenNode[],
265
+ leadingWidth = 0,
266
+ trailingWidth = 0,
267
+ ): GreenNode {
268
+ return GreenNodePool.instance.internNode(kind, text, children, leadingWidth, trailingWidth)
269
+ }