@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,569 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DocumentEngine — RedNode (Mutable Syntax Tree)
|
|
3
|
+
*
|
|
4
|
+
* The Red Tree wraps GreenNode and adds mutable state:
|
|
5
|
+
* - Parent references
|
|
6
|
+
* - Stable Node IDs
|
|
7
|
+
* - Version tracking
|
|
8
|
+
* - Diagnostic storage
|
|
9
|
+
*
|
|
10
|
+
* Multiple Red Trees can reference the same Green Tree
|
|
11
|
+
* (different views, different versions of edits).
|
|
12
|
+
*
|
|
13
|
+
* Inspired by Roslyn's Red Tree.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { GreenNode } from './GreenNode'
|
|
17
|
+
import type { NodeId, DocumentNode, NodeKind, NodeAttributes, NodeMetadata } from '../Types/core'
|
|
18
|
+
import { createNodeId } from '../Types/core'
|
|
19
|
+
import type { Diagnostic } from '../Types/diagnostics'
|
|
20
|
+
import type { Range } from '../Types/tokens'
|
|
21
|
+
|
|
22
|
+
// ─── Red Node ──────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
export class RedNode {
|
|
25
|
+
/** The underlying immutable green node */
|
|
26
|
+
readonly green: GreenNode
|
|
27
|
+
/** Stable identity */
|
|
28
|
+
readonly id: NodeId
|
|
29
|
+
/** Parent reference (null for root) */
|
|
30
|
+
parent: RedNode | null
|
|
31
|
+
/** Children as red nodes */
|
|
32
|
+
children: RedNode[]
|
|
33
|
+
/** Version counter for change tracking */
|
|
34
|
+
version: number
|
|
35
|
+
/** Diagnostics for this node */
|
|
36
|
+
diagnostics: Diagnostic[]
|
|
37
|
+
/** The semantic kind */
|
|
38
|
+
kind: NodeKind
|
|
39
|
+
/** Additional metadata */
|
|
40
|
+
metadata: NodeMetadata
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Cached position of this node inside `parent.children`.
|
|
44
|
+
*
|
|
45
|
+
* `declare` is load-bearing — see the note on `GreenNode._hashCache`: with
|
|
46
|
+
* `target: ES2022` a plain field declaration emits a `defineProperty` on every
|
|
47
|
+
* construction, and this class is instantiated once per node per parse.
|
|
48
|
+
*
|
|
49
|
+
* The cache is *self-validating*: `index` only trusts it when
|
|
50
|
+
* `parent.children[_idxCache] === this`. That single reference compare is what
|
|
51
|
+
* makes it safe against code that mutates the `children` array directly
|
|
52
|
+
* (`treeTransformers` does `cloned.children = []`) instead of going through the
|
|
53
|
+
* mutation methods — a stale entry is detected and recomputed rather than
|
|
54
|
+
* silently returning a wrong index. `-1` never validates, so it is a safe
|
|
55
|
+
* initial value.
|
|
56
|
+
*/
|
|
57
|
+
private declare _idxCache: number
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Absolute start offset in the source.
|
|
61
|
+
*
|
|
62
|
+
* This is the red tree's job now: a green node knows its width, not its
|
|
63
|
+
* place, so that identical structures can be the same object (see the header
|
|
64
|
+
* of `GreenNode.ts`). The offset is accumulated on the way down during
|
|
65
|
+
* construction — one addition per node — and stored, so `range` stays the
|
|
66
|
+
* O(1) property every consumer already assumes it is.
|
|
67
|
+
*
|
|
68
|
+
* Held as the `Range` object itself rather than a number plus a getter that
|
|
69
|
+
* builds one: `range` is read from 148 call sites, and allocating there would
|
|
70
|
+
* trade a parse-time win for a read-time cost on every consumer. This is the
|
|
71
|
+
* same shape green nodes used to hold, so nothing downstream changes.
|
|
72
|
+
*
|
|
73
|
+
* `declare` for the usual reason: no `defineProperty` per construction.
|
|
74
|
+
*/
|
|
75
|
+
private declare _range: Range
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Pending offset shift for this subtree, applied on first read.
|
|
79
|
+
*
|
|
80
|
+
* `setStart` defers the walk: it records how far the subtree moved instead of
|
|
81
|
+
* adding the delta to every node's `_range` on the spot. The shift is a single
|
|
82
|
+
* integer here, on the adopted subtree ROOT — the node `setStart` was called
|
|
83
|
+
* on. Any offset read (`range`, `innerStart`, `innerEnd`, `findNodeAtOffset`)
|
|
84
|
+
* materializes the nearest pending ancestor's subtree via {@link materialize}.
|
|
85
|
+
*
|
|
86
|
+
* Why this is sound: a mid-document edit displaces every adopted block after
|
|
87
|
+
* it by the same delta, so adoption alone (thousands of `setStart` calls) was
|
|
88
|
+
* walking thousands of nodes per keystroke — measured as the dominant phase
|
|
89
|
+
* of `buildRed` (3.5-5.8 ms on the 547 KB fixture). The BlockPatcher locates
|
|
90
|
+
* the edited window by reference identity and only ever reads the few changed
|
|
91
|
+
* blocks' ranges, so the displaced-but-unchanged subtrees stay pending
|
|
92
|
+
* indefinitely — the walk never happens.
|
|
93
|
+
*
|
|
94
|
+
* Contract for future readers: app-layer interaction code (hover, links,
|
|
95
|
+
* selection ranges) DOES read `range` on user interaction. The first such
|
|
96
|
+
* read after a mid-document edit fires the deferred walk for the displaced
|
|
97
|
+
* tail — a one-time cost off the keystroke path, which is exactly the trade
|
|
98
|
+
* the laziness makes. Do not "optimize" by reading ranges back into the
|
|
99
|
+
* per-keystroke pipeline; the patcher stays churn-based by design.
|
|
100
|
+
*
|
|
101
|
+
* Composition across reparses: a node re-adopted while its ancestor is still
|
|
102
|
+
* pending must end up at the SUM of both deltas. `setStart` recomputes the
|
|
103
|
+
* delta from the untouched base `_range`, which is exactly the pending
|
|
104
|
+
* ancestor's base too, so composing during `materialize` (add the ancestor's
|
|
105
|
+
* delta into a child's pending delta) stays consistent.
|
|
106
|
+
*/
|
|
107
|
+
private declare _lazyShift: number
|
|
108
|
+
|
|
109
|
+
constructor(
|
|
110
|
+
green: GreenNode,
|
|
111
|
+
options?: {
|
|
112
|
+
id?: NodeId
|
|
113
|
+
parent?: RedNode | null
|
|
114
|
+
kind?: NodeKind
|
|
115
|
+
diagnostics?: Diagnostic[]
|
|
116
|
+
metadata?: NodeMetadata
|
|
117
|
+
/** Absolute start offset. Defaults to the parent's inner offset. */
|
|
118
|
+
start?: number
|
|
119
|
+
},
|
|
120
|
+
) {
|
|
121
|
+
this.green = green
|
|
122
|
+
this.id = options?.id ?? createNodeId()
|
|
123
|
+
this.parent = options?.parent ?? null
|
|
124
|
+
this.children = []
|
|
125
|
+
this.version = 1
|
|
126
|
+
this.diagnostics = options?.diagnostics ?? []
|
|
127
|
+
this.metadata = options?.metadata ?? {}
|
|
128
|
+
this.kind = options?.kind ?? (green.kind as NodeKind)
|
|
129
|
+
this._idxCache = -1
|
|
130
|
+
this._lazyShift = 0
|
|
131
|
+
const start = options?.start ?? 0
|
|
132
|
+
this._range = { start, end: start + green.width }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Move this node (and its subtree) to a new absolute offset.
|
|
137
|
+
*
|
|
138
|
+
* Only for the tree-building paths, which create a node before they know
|
|
139
|
+
* where its parent will put it. Callers that mutate a live tree should
|
|
140
|
+
* rebuild instead — a red node's offset must always agree with its place.
|
|
141
|
+
*
|
|
142
|
+
* The move is deferred, not walked: the delta is recorded as a pending
|
|
143
|
+
* shift and applied by any offset read the first time the subtree is
|
|
144
|
+
* actually touched. Adoption calls this once per displaced block (thousands
|
|
145
|
+
* on a mid-document edit), and almost none of those subtrees are read on the
|
|
146
|
+
* same keystroke's hot path — the BlockPatcher locates the edit window by
|
|
147
|
+
* reference identity and only reads the few changed blocks.
|
|
148
|
+
*
|
|
149
|
+
* Re-adoption composes: a node whose subtree was shifted in a previous
|
|
150
|
+
* reparse and never read still carries a pending `_lazyShift`. The new
|
|
151
|
+
* target is absolute, so the delta is measured from the CURRENT effective
|
|
152
|
+
* start (`_range.start + _lazyShift`) and accumulated, not overwritten —
|
|
153
|
+
* otherwise the earlier shift would be applied twice.
|
|
154
|
+
*/
|
|
155
|
+
setStart(start: number): void {
|
|
156
|
+
const delta = start - (this._range.start + this._lazyShift)
|
|
157
|
+
if (delta === 0) return
|
|
158
|
+
this._lazyShift += delta
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Apply `delta` to `node`'s subtree, composing with any nested pending shift.
|
|
163
|
+
*
|
|
164
|
+
* A node with its own pending shift has a base `_range` the parent's delta is
|
|
165
|
+
* relative to as well (both were computed from the same pre-shift tree), so
|
|
166
|
+
* the parent's delta can be folded into the child's pending delta instead of
|
|
167
|
+
* into its `_range` — the child's later materialization applies the sum. This
|
|
168
|
+
* is what makes nested shifts across reparses compose without double counting.
|
|
169
|
+
*/
|
|
170
|
+
private static applyShift(node: RedNode, delta: number): void {
|
|
171
|
+
if (delta === 0) return
|
|
172
|
+
if (node._lazyShift !== 0) {
|
|
173
|
+
node._lazyShift += delta
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
node._range.start += delta
|
|
177
|
+
node._range.end += delta
|
|
178
|
+
const titleNodes = node.metadata?.titleNodes as RedNode[] | undefined
|
|
179
|
+
if (titleNodes) {
|
|
180
|
+
for (let i = 0; i < titleNodes.length; i++) {
|
|
181
|
+
RedNode.applyShift(titleNodes[i], delta)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
185
|
+
RedNode.applyShift(node.children[i], delta)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Materialize every pending shift on the path from here to the root.
|
|
191
|
+
*
|
|
192
|
+
* Walks ancestors top-down (root-most first), applying each pending delta to
|
|
193
|
+
* its subtree. Top-down order matters: an ancestor's delta must land in a
|
|
194
|
+
* child's pending delta BEFORE the child's own materialization runs, or the
|
|
195
|
+
* two shifts would be applied to different bases. After the ancestors are
|
|
196
|
+
* settled, this node itself is materialized if it still carries a shift.
|
|
197
|
+
*
|
|
198
|
+
* The common case — nothing pending anywhere on the path — is one upward
|
|
199
|
+
* pointer walk that allocates nothing: `_range` is read from 148 call sites,
|
|
200
|
+
* and a per-read allocation there would show up in every phase. The chain
|
|
201
|
+
* array is only built after a pending shift is actually found.
|
|
202
|
+
*/
|
|
203
|
+
private materialize(): void {
|
|
204
|
+
// Fast path: no pending shift on this node or any ancestor.
|
|
205
|
+
let node: RedNode | null = this
|
|
206
|
+
while (node !== null && node._lazyShift === 0) {
|
|
207
|
+
node = node.parent
|
|
208
|
+
}
|
|
209
|
+
if (node === null) return
|
|
210
|
+
|
|
211
|
+
// Slow path: collect the ancestor chain, root-most last (so we pop
|
|
212
|
+
// root-first), and materialize each pending shift top-down.
|
|
213
|
+
const chain: RedNode[] = []
|
|
214
|
+
let n: RedNode | null = this
|
|
215
|
+
while (n !== null) {
|
|
216
|
+
chain.push(n)
|
|
217
|
+
n = n.parent
|
|
218
|
+
}
|
|
219
|
+
for (let i = chain.length - 1; i >= 0; i--) {
|
|
220
|
+
const ancestor = chain[i]
|
|
221
|
+
const delta = ancestor._lazyShift
|
|
222
|
+
if (delta === 0) continue
|
|
223
|
+
ancestor._lazyShift = 0
|
|
224
|
+
ancestor._range.start += delta
|
|
225
|
+
ancestor._range.end += delta
|
|
226
|
+
for (let c = 0; c < ancestor.children.length; c++) {
|
|
227
|
+
RedNode.applyShift(ancestor.children[c], delta)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ─── Mutation boundary ───────────────────────────────────
|
|
233
|
+
//
|
|
234
|
+
// What this is, stated plainly, because the documentation used to claim more:
|
|
235
|
+
// a **convention enforcer**, not an isolation mechanism and not a concurrency
|
|
236
|
+
// guarantee.
|
|
237
|
+
//
|
|
238
|
+
// It cannot be an isolation mechanism. The flag is a single static boolean, so
|
|
239
|
+
// opening a boundary anywhere opens it everywhere — there is no scoping by
|
|
240
|
+
// document, transaction or thread. Scoping it per document would mean walking
|
|
241
|
+
// to the root on every mutation and threading a root through all 26 call
|
|
242
|
+
// sites, to catch cross-document mutation: a bug class a single-threaded
|
|
243
|
+
// editor does not have.
|
|
244
|
+
//
|
|
245
|
+
// It cannot be airtight either. `children` is a public mutable array, and
|
|
246
|
+
// `treeTransformers` assigns to it directly in five places, bypassing these
|
|
247
|
+
// methods entirely. Sealing that means making `children` private, which is a
|
|
248
|
+
// large API change for the same small benefit.
|
|
249
|
+
//
|
|
250
|
+
// What it does earn: wrapping a structural edit marks intent at the call site,
|
|
251
|
+
// and mutating outside a boundary fails loudly instead of silently. That is
|
|
252
|
+
// worth one boolean check, so it stays — described honestly.
|
|
253
|
+
|
|
254
|
+
private static _isMutating = false
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Run `fn` inside a mutation boundary, so the structural mutators below are
|
|
258
|
+
* allowed to run. Nests safely; the previous state is restored on exit.
|
|
259
|
+
*
|
|
260
|
+
* Not needed for building a fresh subtree — see {@link initChildren}.
|
|
261
|
+
*/
|
|
262
|
+
static allowMutation<T>(fn: () => T): T {
|
|
263
|
+
const wasMutating = RedNode._isMutating
|
|
264
|
+
RedNode._isMutating = true
|
|
265
|
+
try {
|
|
266
|
+
return fn()
|
|
267
|
+
} finally {
|
|
268
|
+
RedNode._isMutating = wasMutating
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private assertMutating(): void {
|
|
273
|
+
if (!RedNode._isMutating) {
|
|
274
|
+
throw new Error('RedNode mutation is only allowed within a mutation boundary (use RedNode.allowMutation).')
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ─── Properties ──────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
get text(): string {
|
|
281
|
+
return this.green.text
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Absolute span in the source. */
|
|
285
|
+
get range(): Range {
|
|
286
|
+
this.materialize()
|
|
287
|
+
return this._range
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Absolute offset of this node's first child, past its opening delimiter. */
|
|
291
|
+
get innerStart(): number {
|
|
292
|
+
this.materialize()
|
|
293
|
+
return this._range.start + this.green.leadingWidth
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Absolute offset where this node's closing delimiter begins. */
|
|
297
|
+
get innerEnd(): number {
|
|
298
|
+
this.materialize()
|
|
299
|
+
return this._range.end - this.green.trailingWidth
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
get isLeaf(): boolean {
|
|
303
|
+
return this.green.isLeaf
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
get childCount(): number {
|
|
307
|
+
return this.children.length
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ─── Navigation ──────────────────────────────────────────
|
|
311
|
+
|
|
312
|
+
childAt(index: number): RedNode | undefined {
|
|
313
|
+
return this.children[index]
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Get the root of the tree */
|
|
317
|
+
get root(): RedNode {
|
|
318
|
+
let node: RedNode = this
|
|
319
|
+
while (node.parent) {
|
|
320
|
+
node = node.parent
|
|
321
|
+
}
|
|
322
|
+
return node
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Get the depth from root */
|
|
326
|
+
get depth(): number {
|
|
327
|
+
let d = 0
|
|
328
|
+
let node: RedNode | null = this.parent
|
|
329
|
+
while (node) {
|
|
330
|
+
d++
|
|
331
|
+
node = node.parent
|
|
332
|
+
}
|
|
333
|
+
return d
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Get previous sibling */
|
|
337
|
+
get previousSibling(): RedNode | null {
|
|
338
|
+
const parent = this.parent
|
|
339
|
+
if (!parent) return null
|
|
340
|
+
const idx = this.index
|
|
341
|
+
return idx > 0 ? parent.children[idx - 1] : null
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Get next sibling */
|
|
345
|
+
get nextSibling(): RedNode | null {
|
|
346
|
+
const parent = this.parent
|
|
347
|
+
if (!parent) return null
|
|
348
|
+
const idx = this.index
|
|
349
|
+
return idx >= 0 && idx < parent.children.length - 1 ? parent.children[idx + 1] : null
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Position of this node within its parent, or -1 for a root.
|
|
354
|
+
*
|
|
355
|
+
* O(1) in the steady state. The cached index is seeded by the mutation methods
|
|
356
|
+
* (so the construction path never searches at all) and verified by a single
|
|
357
|
+
* reference compare on read, falling back to `indexOf` only when it is stale.
|
|
358
|
+
*
|
|
359
|
+
* This getter is hot: `HTMLRenderer.isPrevBlockBoundary` walks siblings
|
|
360
|
+
* backwards, so an O(n) implementation here made rendering quadratic —
|
|
361
|
+
* measured 14.11 ms for 4000 root siblings, growing 2.6x per doubling.
|
|
362
|
+
*/
|
|
363
|
+
get index(): number {
|
|
364
|
+
const parent = this.parent
|
|
365
|
+
if (!parent) return -1
|
|
366
|
+
const siblings = parent.children
|
|
367
|
+
const cached = this._idxCache
|
|
368
|
+
if (siblings[cached] === this) return cached
|
|
369
|
+
const idx = siblings.indexOf(this)
|
|
370
|
+
this._idxCache = idx
|
|
371
|
+
return idx
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Walk all descendants in pre-order */
|
|
375
|
+
walk(visitor: (node: RedNode, depth: number) => void | 'skip', depth: number = 0): void {
|
|
376
|
+
const result = visitor(this, depth)
|
|
377
|
+
if (result !== 'skip') {
|
|
378
|
+
for (const child of this.children) {
|
|
379
|
+
child.walk(visitor, depth + 1)
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Find a descendant by ID */
|
|
385
|
+
findById(id: NodeId): RedNode | null {
|
|
386
|
+
if (this.id === id) return this
|
|
387
|
+
for (const child of this.children) {
|
|
388
|
+
const found = child.findById(id)
|
|
389
|
+
if (found) return found
|
|
390
|
+
}
|
|
391
|
+
return null
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Find the deepest RedNode containing the given text offset.
|
|
396
|
+
*
|
|
397
|
+
* The range is half-open — a node owns `[start, end)` — y eso no es un
|
|
398
|
+
* detalle: con el final inclusivo, un offset que cae justo en una frontera
|
|
399
|
+
* pertenecía a DOS nodos, al que termina ahí y al que empieza. Como los
|
|
400
|
+
* hijos se recorren en orden, ganaba el que termina, así que preguntar por
|
|
401
|
+
* el principio de un nodo devolvía **el nodo anterior**.
|
|
402
|
+
*
|
|
403
|
+
* Se veía en el preview: en un degradado, cada carácter es su propio nodo
|
|
404
|
+
* de color, y hacer clic en la `m` de «Welcome» resaltaba la `o`. Monaco
|
|
405
|
+
* seleccionaba bien —usa el rango del nodo, no esta búsqueda—, así que el
|
|
406
|
+
* desfase era solo del resaltado, que es justo lo que hacía difícil verlo.
|
|
407
|
+
*
|
|
408
|
+
* La única excepción es el final del documento: un cursor aparcado tras el
|
|
409
|
+
* último carácter no tiene carácter que lo contenga, y la raíz lo reclama
|
|
410
|
+
* para que quien pregunte reciba algo con sentido.
|
|
411
|
+
*/
|
|
412
|
+
findNodeAtOffset(offset: number): RedNode | null {
|
|
413
|
+
this.materialize()
|
|
414
|
+
const { start, end } = this._range
|
|
415
|
+
const isEndOfDocument = offset === end && this.parent === null
|
|
416
|
+
if ((offset < start || offset >= end) && !isEndOfDocument) return null
|
|
417
|
+
|
|
418
|
+
const titleNodes = this.metadata?.titleNodes as RedNode[] | undefined
|
|
419
|
+
if (titleNodes) {
|
|
420
|
+
for (const titleChild of titleNodes) {
|
|
421
|
+
const found = titleChild.findNodeAtOffset(offset)
|
|
422
|
+
if (found) return found
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
for (const child of this.children) {
|
|
427
|
+
const found = child.findNodeAtOffset(offset)
|
|
428
|
+
if (found) return found
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return this
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ─── Construction ────────────────────────────────────────
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Adopt a fully-built array of children in one shot.
|
|
438
|
+
*
|
|
439
|
+
* Construction-only, and deliberately outside the mutation lock. The tree
|
|
440
|
+
* builders used to call `allowMutation(() => { for (…) appendChild(…) })` once
|
|
441
|
+
* **per node**, which on a 1736-node document meant 1736 closures, 1736
|
|
442
|
+
* `try/finally` frames and 1736 static-flag saves — pure ceremony, since a
|
|
443
|
+
* node that no caller has seen yet cannot be observed mid-mutation. This
|
|
444
|
+
* populates `children`, `parent` and the index cache in a single pass instead.
|
|
445
|
+
*
|
|
446
|
+
* `version` intentionally stays at 1: a freshly built node has not been
|
|
447
|
+
* *edited*, and bumping once per child would make the initial version an
|
|
448
|
+
* accidental child count.
|
|
449
|
+
*
|
|
450
|
+
* Only safe while `this` is still unreachable from the rest of the tree. Use
|
|
451
|
+
* `appendChild` and friends for anything after that.
|
|
452
|
+
*/
|
|
453
|
+
initChildren(children: RedNode[]): void {
|
|
454
|
+
const own = this.children
|
|
455
|
+
for (let i = 0; i < children.length; i++) {
|
|
456
|
+
const child = children[i]
|
|
457
|
+
child.parent = this
|
|
458
|
+
child._idxCache = i
|
|
459
|
+
own.push(child)
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// ─── Mutation ────────────────────────────────────────────
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Append a child node.
|
|
467
|
+
* This creates a new RedNode wrapping the green node.
|
|
468
|
+
*/
|
|
469
|
+
appendChild(child: RedNode): void {
|
|
470
|
+
this.assertMutating()
|
|
471
|
+
child.parent = this
|
|
472
|
+
child._idxCache = this.children.length
|
|
473
|
+
this.children.push(child)
|
|
474
|
+
this.version++
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Refresh the cached index of every child from `from` onwards.
|
|
479
|
+
*
|
|
480
|
+
* Called after a splice, which shifts the tail. This is O(n) but so is the
|
|
481
|
+
* splice it follows, so it costs nothing asymptotically and keeps reads O(1).
|
|
482
|
+
*/
|
|
483
|
+
private reindexFrom(from: number): void {
|
|
484
|
+
const children = this.children
|
|
485
|
+
for (let i = from; i < children.length; i++) {
|
|
486
|
+
children[i]._idxCache = i
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Insert a child at a specific index.
|
|
492
|
+
*/
|
|
493
|
+
insertChildAt(index: number, child: RedNode): void {
|
|
494
|
+
this.assertMutating()
|
|
495
|
+
child.parent = this
|
|
496
|
+
this.children.splice(index, 0, child)
|
|
497
|
+
this.reindexFrom(index)
|
|
498
|
+
this.version++
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Remove a child by ID.
|
|
503
|
+
*/
|
|
504
|
+
removeChild(id: NodeId): RedNode | null {
|
|
505
|
+
this.assertMutating()
|
|
506
|
+
const idx = this.children.findIndex(c => c.id === id)
|
|
507
|
+
if (idx === -1) return null
|
|
508
|
+
const [removed] = this.children.splice(idx, 1)
|
|
509
|
+
removed.parent = null
|
|
510
|
+
removed._idxCache = -1
|
|
511
|
+
this.reindexFrom(idx)
|
|
512
|
+
this.version++
|
|
513
|
+
return removed
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Replace a child with a new one by ID.
|
|
518
|
+
*/
|
|
519
|
+
replaceChild(id: NodeId, newChild: RedNode): boolean {
|
|
520
|
+
this.assertMutating()
|
|
521
|
+
const idx = this.children.findIndex(c => c.id === id)
|
|
522
|
+
if (idx === -1) return false
|
|
523
|
+
newChild.parent = this
|
|
524
|
+
newChild._idxCache = idx
|
|
525
|
+
this.children[idx] = newChild
|
|
526
|
+
this.version++
|
|
527
|
+
return true
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Bump version (when attributes/metadata change without structural change).
|
|
532
|
+
*/
|
|
533
|
+
bumpVersion(): void {
|
|
534
|
+
this.assertMutating()
|
|
535
|
+
this.version++
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// ─── Conversion ──────────────────────────────────────────
|
|
539
|
+
|
|
540
|
+
/** Convert to the public DocumentNode interface */
|
|
541
|
+
toDocumentNode(): DocumentNode {
|
|
542
|
+
return {
|
|
543
|
+
id: this.id,
|
|
544
|
+
version: this.version,
|
|
545
|
+
kind: this.kind,
|
|
546
|
+
text: this.text,
|
|
547
|
+
attributes: {},
|
|
548
|
+
metadata: { ...this.metadata },
|
|
549
|
+
children: this.children.map(c => c.toDocumentNode()),
|
|
550
|
+
diagnostics: [...this.diagnostics],
|
|
551
|
+
sourceRange: { start: this.range.start, end: this.range.end },
|
|
552
|
+
parentId: this.parent?.id ?? null,
|
|
553
|
+
isSynthetic: this.green.kind === 'synthetic',
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** Debug string */
|
|
558
|
+
toString(depth: number = 0): string {
|
|
559
|
+
const indent = ' '.repeat(depth)
|
|
560
|
+
const rangeStr = `[${this.range.start}..${this.range.end}]`
|
|
561
|
+
const textPreview = this.text.length > 30
|
|
562
|
+
? this.text.slice(0, 30) + '...'
|
|
563
|
+
: this.text
|
|
564
|
+
return `${indent}${this.kind} #${this.id} ${rangeStr} v${this.version} "${textPreview}"` +
|
|
565
|
+
(this.children.length > 0
|
|
566
|
+
? '\n' + this.children.map(c => c.toString(depth + 1)).join('\n')
|
|
567
|
+
: '')
|
|
568
|
+
}
|
|
569
|
+
}
|