@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,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
|
+
}
|