@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,694 @@
1
+ /**
2
+ * DocumentEngine — DocumentModel
3
+ *
4
+ * THE CORE of the Language Platform.
5
+ *
6
+ * The DocumentModel is the single source of truth for the document.
7
+ * BBCode is just ONE representation of this model.
8
+ * The model is language-agnostic and format-agnostic.
9
+ *
10
+ * Responsibilities:
11
+ * - Hold the current Red Tree
12
+ * - Manage incremental parsing
13
+ * - Coordinate transactions (undo/redo)
14
+ * - Emit change events
15
+ * - Provide query/find capabilities
16
+ * - Manage diagnostics
17
+ *
18
+ * Inspired by ProseMirror's EditorState and Roslyn's Document.
19
+ */
20
+
21
+ import { RedNode } from '../Syntax/RedNode'
22
+ import { GreenNode } from '../Syntax/GreenNode'
23
+ import { preserveNodeIds } from '../Syntax/preserveNodeIds'
24
+ import { NodeMatcher } from '../Syntax/NodeMatcher'
25
+ import type { NodeMatch } from '../Syntax/NodeMatcher'
26
+ import {
27
+ IncrementalParser,
28
+ type ReparseParseOptions,
29
+ type FallbackReason,
30
+ } from '../Incremental/IncrementalParser'
31
+ import { ChangeTracker } from '../Incremental/ChangeTracker'
32
+ import type { TextChange, TextChangeRange } from '../Incremental/ChangeTracker'
33
+ import { SemanticAnalyzer } from '../Semantic/SemanticAnalyzer'
34
+ import type { AnalyzeResult, Validator } from '../Semantic/SemanticAnalyzer'
35
+ import { Transaction } from '../Transactions/Transaction'
36
+ import type { Operation } from '../Types/operations'
37
+ import { UndoManager } from '../Transactions/UndoManager'
38
+ import { TreeBuilder } from '../Syntax/TreeBuilder'
39
+ import { ArrayTokenStream } from '../Types/tokens'
40
+ import { QueryEngine } from '../Queries/QueryEngine'
41
+ import type { Query, QueryResult } from '../Types/queries'
42
+ import type {
43
+ DocumentNode,
44
+ DocumentChangeEvent,
45
+ DocumentChangeKind,
46
+ NodeId,
47
+ NodeKind,
48
+ } from '../Types/core'
49
+ import { findById, walkPreOrder } from '../Types/core'
50
+ import type { DiagnosticCollection } from '../Types/diagnostics'
51
+ import { DocumentEventBus } from '../Events/EventBus'
52
+ import type { DocumentEvent } from '../Events/EventBus'
53
+ import { NodeFactory } from './NodeFactory'
54
+ import { TagRegistry } from './TagRegistry'
55
+
56
+ export interface DocumentModelOptions {
57
+ /** Initial source text */
58
+ source?: string
59
+ /** Language identifier */
60
+ language?: string
61
+ /** Maximum undo stack size */
62
+ maxUndo?: number
63
+ /** Whether to auto-analyze on change */
64
+ autoAnalyze?: boolean
65
+ /**
66
+ * Attempt incremental reparse on `applyChange`. Default `true`.
67
+ *
68
+ * Setting this to `false` makes every change a full rebuild. The result is
69
+ * identical either way — that is the incremental parser's contract, checked
70
+ * differentially over 22.100 edits — so this is a performance switch and a
71
+ * debugging aid, not a correctness one.
72
+ */
73
+ incremental?: boolean
74
+ /**
75
+ * Reuse red subtrees across incremental reparses. Default `true`.
76
+ *
77
+ * When the splice shares a green subtree by reference, the old red subtree
78
+ * is adopted into the new tree instead of being rebuilt — building red was
79
+ * the largest phase of a keystroke. The trade is a contract: the previous
80
+ * `redRoot` is consumed by the adoption and must not be walked afterwards.
81
+ * No engine or app code does; this switch exists for any future consumer
82
+ * that needs the superseded tree to stay intact.
83
+ */
84
+ reuseRed?: boolean
85
+ }
86
+
87
+ export class DocumentModel {
88
+ // ── Core State ──
89
+ private _source: string
90
+ private _language: string
91
+ private _redRoot: RedNode | null = null
92
+ private _greenRoot: GreenNode | null = null
93
+ private _version: number = 0
94
+
95
+ // ── Subsystems ──
96
+ readonly tagRegistry: TagRegistry
97
+ readonly nodeFactory: NodeFactory
98
+ readonly treeBuilder: TreeBuilder
99
+ readonly nodeMatcher: NodeMatcher
100
+ readonly semanticAnalyzer: SemanticAnalyzer
101
+ readonly incrementalParser: IncrementalParser
102
+ readonly changeTracker: ChangeTracker
103
+ readonly undoManager: UndoManager
104
+ readonly queryEngine: QueryEngine
105
+ readonly events: DocumentEventBus
106
+
107
+ // ── State ──
108
+ private _diagnostics: DiagnosticCollection | null = null
109
+ private _lastAnalyzeResult: AnalyzeResult | null = null
110
+ /**
111
+ * Source range of the last applied edit — see {@link TextChangeRange}.
112
+ * `null` after a full rebuild, which is not an edit.
113
+ */
114
+ private _lastChangeRange: TextChangeRange | null = null
115
+ private _options: Required<DocumentModelOptions>
116
+ private _analyzeTimeout: ReturnType<typeof setTimeout> | null = null
117
+ /** The debounced post-edit work, kept so `ensureAnalyzed` can run it early. */
118
+ private _pendingAnalyze: (() => void) | null = null
119
+
120
+ // ── Diagnostics info ──
121
+ /** Last reparse timings breakdown (exposed for debugging) */
122
+ lastReparsePath: string = ''
123
+ /** Why the last reparse fell back to a full rebuild, if it did. */
124
+ lastReparseFallbackReason: FallbackReason | null = null
125
+ lastReparseTimings: { findAffected: number; safeBoundary: number; parse: number; buildRed: number; mutate: number; other: number } | null = null
126
+
127
+ constructor(options: DocumentModelOptions = {}) {
128
+ this._options = {
129
+ source: options.source ?? '',
130
+ language: options.language ?? 'bbcode',
131
+ maxUndo: options.maxUndo ?? 50,
132
+ autoAnalyze: options.autoAnalyze ?? true,
133
+ incremental: options.incremental ?? true,
134
+ reuseRed: options.reuseRed ?? true,
135
+ }
136
+
137
+ this._source = this._options.source
138
+ this._language = this._options.language
139
+
140
+ // Initialize subsystems
141
+ this.tagRegistry = new TagRegistry()
142
+ this.nodeFactory = new NodeFactory(this.tagRegistry)
143
+ this.treeBuilder = new TreeBuilder()
144
+ this.nodeMatcher = new NodeMatcher()
145
+ this.semanticAnalyzer = new SemanticAnalyzer()
146
+ this.incrementalParser = new IncrementalParser()
147
+ this.changeTracker = new ChangeTracker()
148
+ this.undoManager = new UndoManager(this._options.maxUndo)
149
+ this.queryEngine = new QueryEngine()
150
+ this.events = new DocumentEventBus()
151
+
152
+ // Bootstrap: parse initial source
153
+ if (this._source) {
154
+ this.rebuild(this._source)
155
+ }
156
+ }
157
+
158
+ // ── Properties ──
159
+
160
+ get source(): string {
161
+ return this._source
162
+ }
163
+
164
+ get language(): string {
165
+ return this._language
166
+ }
167
+
168
+ get version(): number {
169
+ return this._version
170
+ }
171
+
172
+ get redRoot(): RedNode | null {
173
+ return this._redRoot
174
+ }
175
+
176
+ get greenRoot(): GreenNode | null {
177
+ return this._greenRoot
178
+ }
179
+
180
+ get diagnostics(): DiagnosticCollection | null {
181
+ return this._diagnostics
182
+ }
183
+
184
+ get lastAnalyze(): AnalyzeResult | null {
185
+ return this._lastAnalyzeResult
186
+ }
187
+
188
+ /**
189
+ * The source range of the last applied edit, in new-source coordinates.
190
+ *
191
+ * `null` after a `rebuild` (there is no incremental edit to point at). The
192
+ * BlockPatcher reads this to reconcile only the blocks around the edit
193
+ * instead of walking the whole document. The same range is also attached to
194
+ * the current `redRoot` (`__changeRange`), so a consumer that only holds the
195
+ * AST — like the live preview — can find it without plumbing the model.
196
+ */
197
+ get lastChangeRange(): TextChangeRange | null {
198
+ return this._lastChangeRange
199
+ }
200
+
201
+ // ── Document Operations ──
202
+
203
+ /**
204
+ * Full rebuild from source text.
205
+ * Used for initial load or when incremental parsing isn't possible.
206
+ */
207
+ rebuild(source: string): void {
208
+ const oldRoot = this._redRoot
209
+
210
+ // Parse via the tree builder (overridable by language-specific parser)
211
+ this._source = source
212
+ this._greenRoot = this.parseToGreen(source)
213
+ this._redRoot = this.buildRedFromGreen(this._greenRoot)
214
+ // A rebuild is not an edit: there is no "changed region" to reconcile
215
+ // incrementally, so the range is cleared and the preview falls back to its
216
+ // full reconcile (which is what it must do for undo/redo/load anyway).
217
+ this._lastChangeRange = null
218
+ this._attachChangeRange(this._redRoot)
219
+ // Unchanged nodes keep the identity they had before the rebuild, so the
220
+ // HTML of untouched subtrees stays byte-identical between renders and the
221
+ // DOMMorpher's isEqualNode fast path actually fires.
222
+ if (oldRoot) {
223
+ preserveNodeIds(oldRoot, this._redRoot)
224
+ }
225
+ this._version++
226
+
227
+ // Run semantic analysis
228
+ if (this._options.autoAnalyze) {
229
+ this.analyze()
230
+ }
231
+
232
+ // Emit event — building the payload (and the lazy nodeMatch accessor) is
233
+ // pointless when nobody subscribed, which is the common case. An opted-in
234
+ // history counts as a subscriber.
235
+ if (this.events.hasListeners('document_changed') || this.events.recordHistory) {
236
+ const event = {
237
+ type: 'document_changed' as const,
238
+ kind: 'full_rebuild' as const,
239
+ version: this._version,
240
+ source: this._source,
241
+ timestamp: Date.now(),
242
+ }
243
+ this.defineLazyNodeMatch(event, oldRoot, this._redRoot)
244
+ this.events.emit(event as DocumentEvent)
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Install `nodeMatch` on an event as an accessor, so the match is only
250
+ * computed if a subscriber actually reads it.
251
+ *
252
+ * Matching two 1736-node trees costs ~3.5 ms and allocates a result entry per
253
+ * node plus five Maps/Sets — and it ran on *every* keystroke to populate a
254
+ * field that no subscriber in this codebase reads. The capability is real
255
+ * (preserving selection and scroll across re-parses is what it is for), so it
256
+ * stays available; it just stops running speculatively.
257
+ *
258
+ * Callers still write `event.nodeMatch` and see a `NodeMatch | null`. Note the
259
+ * closure keeps `oldRoot` alive for as long as the event object is retained.
260
+ */
261
+ private defineLazyNodeMatch(
262
+ event: object,
263
+ oldRoot: RedNode | null,
264
+ newRoot: RedNode | null,
265
+ ): void {
266
+ if (!oldRoot || !newRoot) {
267
+ Object.defineProperty(event, 'nodeMatch', { value: null, enumerable: true })
268
+ return
269
+ }
270
+
271
+ const matcher = this.nodeMatcher
272
+ let memo: NodeMatch | null = null
273
+
274
+ Object.defineProperty(event, 'nodeMatch', {
275
+ enumerable: true,
276
+ configurable: true,
277
+ get(): NodeMatch {
278
+ if (memo === null) memo = matcher.match(oldRoot, newRoot)
279
+ return memo
280
+ },
281
+ })
282
+ }
283
+
284
+ /**
285
+ * Attach the last change range to a root node.
286
+ *
287
+ * The range travels with the AST so the preview can read it from the root it
288
+ * is about to patch — no prop drilling through workspace → window → preview.
289
+ * `__changeRange` is deliberately not part of RedNode's API; it is a runtime
290
+ * marker owned by the model (the BlockPatcher reads it with a cast).
291
+ */
292
+ private _attachChangeRange(root: RedNode | null): void {
293
+ if (root) {
294
+ ;(root as RedNode & { __changeRange?: TextChangeRange | null }).__changeRange = this._lastChangeRange
295
+ }
296
+ }
297
+
298
+ /**
299
+ * Apply a source text change (e.g. from Monaco editor input).
300
+ * Uses incremental parsing when possible.
301
+ *
302
+ * `origin` tags where the change came from — `'local'` (default) for the
303
+ * user's own editing, anything else for programmatic or synced sources
304
+ * (`'remote'`, `'sync'`, a peer id…). It travels on the emitted events, so
305
+ * a collaboration layer can tell its own echo apart from user edits — the
306
+ * classic infinite-loop bug when wiring a CRDT. See `QuasarCollab.MD`.
307
+ */
308
+ applyChange(change: TextChange, origin: string = 'local'): void {
309
+ const oldRoot = this._redRoot
310
+
311
+ // Track the change
312
+ this.changeTracker.track(change)
313
+
314
+ // The edit range, in both coordinate systems. `change.end` is the OLD
315
+ // end; `start + change.text.length` is the NEW end after the splice.
316
+ this._lastChangeRange = {
317
+ start: change.start,
318
+ end: change.start + change.text.length,
319
+ endOld: change.end,
320
+ }
321
+
322
+ // Apply to source
323
+ const before = this._source.slice(0, change.start)
324
+ const after = this._source.slice(change.end)
325
+ this._source = before + change.text + after
326
+
327
+ // Note: this._source is later overwritten with the flat `newSource`
328
+ // from the textarea (in applyTextUpdate) to prevent ConsString build-up.
329
+
330
+ // Incremental parse (or fall back to full rebuild)
331
+ if (this._redRoot && this._greenRoot && this._options.incremental) {
332
+ try {
333
+ // With `reuseRed`, subtrees whose green is shared by reference are
334
+ // adopted from the old red tree instead of rebuilt. This CONSUMES
335
+ // `oldRoot` (adopted nodes are reparented into the new tree) — safe
336
+ // because nothing walks a superseded red tree, and `oldRoot` is only
337
+ // used below for id preservation and the lazy nodeMatch, both of
338
+ // which tolerate shared nodes.
339
+ // `adopted` counts nodes carried over from the old tree. It decides
340
+ // below which mechanism preserved identity — the two must never both
341
+ // run over the same tree.
342
+ const reuse = { adopted: 0 }
343
+ const buildRed = this._options.reuseRed && oldRoot
344
+ ? (green: GreenNode) => this.buildRedFromGreenReusing(green, oldRoot, reuse)
345
+ : (green: GreenNode) => this.buildRedFromGreen(green)
346
+ const result = this.incrementalParser.reparse(
347
+ this._redRoot,
348
+ this._greenRoot,
349
+ change,
350
+ this._source,
351
+ (text: string, opts?: ReparseParseOptions) => this.parseToGreen(text, opts),
352
+ buildRed,
353
+ )
354
+ // `reparse` always returns a result now: when it cannot splice safely
355
+ // it does the full rebuild itself rather than handing back a null the
356
+ // caller has to interpret.
357
+ this.lastReparsePath = result.path
358
+ this.lastReparseFallbackReason = result.reason ?? null
359
+ this.lastReparseTimings = result.timings
360
+ this._greenRoot = result.green
361
+ this._redRoot = result.red
362
+ // Identity across the edit, by whichever mechanism applies.
363
+ //
364
+ // When subtrees were reused, they ARE the previous nodes and already
365
+ // carry their ids — and running the id walk on top would actively
366
+ // corrupt them: it pairs positionally, so inserting a block makes it
367
+ // pair each survivor with its neighbour and overwrite a live node's
368
+ // id with a different node's (duplicating ids, and changing the
369
+ // `data-node-id` of blocks that never changed). The nodes it would
370
+ // renumber are the re-parsed ones, which are genuinely new.
371
+ //
372
+ // Without reuse every node is fresh, so the walk is what carries
373
+ // identity across — same as in `rebuild`. Note `reparse` can decide
374
+ // on a full rebuild internally, and then nothing is adopted even
375
+ // though reuse was enabled: the count, not the option, is what tells
376
+ // the two situations apart.
377
+ if (oldRoot && reuse.adopted === 0) {
378
+ preserveNodeIds(oldRoot, this._redRoot)
379
+ }
380
+ this._version++
381
+ // The incremental path resolved: the range stays attached to the new
382
+ // root so the preview can find the edited region.
383
+ this._attachChangeRange(this._redRoot)
384
+ } catch {
385
+ this.lastReparsePath = 'full_rebuild'
386
+ this.lastReparseTimings = null
387
+ this.rebuild(this._source)
388
+ return
389
+ }
390
+ } else {
391
+ this.rebuild(this._source)
392
+ return
393
+ }
394
+
395
+ // Emit event immediately
396
+ if (this.events.hasListeners('document_changed') || this.events.recordHistory) {
397
+ this.events.emit({
398
+ type: 'document_changed',
399
+ kind: 'text_changed',
400
+ version: this._version,
401
+ source: this._source,
402
+ nodeMatch: null,
403
+ change,
404
+ origin,
405
+ timestamp: Date.now(),
406
+ })
407
+ }
408
+
409
+ // Debounced background task for heavy phases
410
+ if (this._analyzeTimeout) {
411
+ clearTimeout(this._analyzeTimeout)
412
+ }
413
+ const runPendingAnalyze = () => {
414
+ this._analyzeTimeout = null
415
+ this._pendingAnalyze = null
416
+
417
+ // Background Phase 1: Semantic Analysis
418
+ if (this._options.autoAnalyze) {
419
+ this.analyze()
420
+ }
421
+
422
+ // Background Phase 2: Notify UI that semantics are ready — if some UI
423
+ // is listening. `nodeMatch` is lazy here too — see `defineLazyNodeMatch`.
424
+ if (this.events.hasListeners('diagnostics_updated') || this.events.recordHistory) {
425
+ const diagnosticsEvent = {
426
+ type: 'diagnostics_updated' as const,
427
+ version: this._version,
428
+ source: this._source,
429
+ diagnostics: this._diagnostics,
430
+ timestamp: Date.now()
431
+ }
432
+ this.defineLazyNodeMatch(diagnosticsEvent, oldRoot, this._redRoot)
433
+ this.events.emit(diagnosticsEvent as DocumentEvent)
434
+ }
435
+ }
436
+ this._pendingAnalyze = runPendingAnalyze
437
+ this._analyzeTimeout = setTimeout(runPendingAnalyze, 10)
438
+ }
439
+
440
+ /**
441
+ * Flush the debounced post-edit analysis, so `diagnostics` describes the
442
+ * *current* tree.
443
+ *
444
+ * `applyChange` defers analysis by a few milliseconds to keep the keystroke
445
+ * path lean. A caller that reads `diagnostics` synchronously right after an
446
+ * edit would otherwise be looking at the previous document's errors — which
447
+ * is exactly what the editor's error panel did.
448
+ */
449
+ ensureAnalyzed(): void {
450
+ const pending = this._pendingAnalyze
451
+ if (!pending) return
452
+ if (this._analyzeTimeout) {
453
+ clearTimeout(this._analyzeTimeout)
454
+ }
455
+ pending()
456
+ }
457
+
458
+ /**
459
+ * Calculate a simple diff between the current source and the new source,
460
+ * then apply the change.
461
+ */
462
+ applyTextUpdate(newSource: string, origin: string = 'local'): void {
463
+ if (this._source === newSource) return
464
+
465
+ // One forward pass and one backward pass, and that is the whole diff.
466
+ //
467
+ // There used to be two `startsWith` fast paths in front of this, for "pure
468
+ // append" and "pure delete at the end". They were not shortcuts: both are
469
+ // just this scan with a particular answer, and reaching that answer took a
470
+ // full comparison of the shared prefix — which the general case then
471
+ // repeated from scratch. An edit in the middle of a document paid for the
472
+ // same prefix twice, once to rule out the fast path and once to compute
473
+ // the diff it was going to compute anyway.
474
+ //
475
+ // The two cases still come out right, they are simply not special: an
476
+ // append leaves `start === oldLen` and the backward pass stops on the spot,
477
+ // a delete at the end leaves `start === newLen` and likewise.
478
+ const oldSource = this._source
479
+ const oldLen = oldSource.length
480
+ const newLen = newSource.length
481
+ const shared = oldLen < newLen ? oldLen : newLen
482
+
483
+ let start = 0
484
+ while (start < shared && oldSource.charCodeAt(start) === newSource.charCodeAt(start)) {
485
+ start++
486
+ }
487
+
488
+ let oldEnd = oldLen
489
+ let newEnd = newLen
490
+ while (
491
+ oldEnd > start &&
492
+ newEnd > start &&
493
+ oldSource.charCodeAt(oldEnd - 1) === newSource.charCodeAt(newEnd - 1)
494
+ ) {
495
+ oldEnd--
496
+ newEnd--
497
+ }
498
+
499
+ this.applyChange({ start, end: oldEnd, text: newSource.slice(start, newEnd) }, origin)
500
+
501
+ // The diff computed here is authoritative: `newEnd`/`oldEnd` are the exact
502
+ // boundary in each coordinate system (applyChange derived the same values
503
+ // from the TextChange — this just re-states them and re-attaches, since
504
+ // the range must ride the current root).
505
+ this._lastChangeRange = { start, end: newEnd, endOld: oldEnd }
506
+ this._attachChangeRange(this._redRoot)
507
+
508
+ // Overwrite with the flat source from the textarea to prevent
509
+ // deep ConsStrings from accumulating across edits.
510
+ this._source = newSource
511
+ }
512
+
513
+ /**
514
+ * Execute operations within a transaction (undoable).
515
+ *
516
+ * Coherence contract: after a `transact` the model's `source`, green tree
517
+ * and red tree all describe the same document. The transaction mutates the
518
+ * red tree, the result is serialized back to text via `exportSource`, and
519
+ * the model rebuilds from that text — parse stays the single source of
520
+ * truth, exactly as in `applyChange`. (The previous implementation swapped
521
+ * `_redRoot` and left `_source`/`_greenRoot` describing the old document,
522
+ * so the next text edit diffed against a source the tree no longer matched.)
523
+ *
524
+ * Returns `true` if the document changed.
525
+ */
526
+ transact(operations: Operation[], label?: string): boolean {
527
+ if (!this._redRoot) return false
528
+
529
+ const before = this._source
530
+ const tx = new Transaction(operations)
531
+ const applied = tx.apply(this._redRoot)
532
+ if (!applied) return false
533
+
534
+ // The transaction mutated the red tree in place, so even a no-op result
535
+ // must go through export + rebuild to restore red/green coherence.
536
+ const after = this.exportSource(applied)
537
+ this.rebuild(after)
538
+
539
+ if (after === before) return false
540
+ this.undoManager.push({ before, after }, label ?? tx.getLabel())
541
+ return true
542
+ }
543
+
544
+ /**
545
+ * Undo the last transaction.
546
+ *
547
+ * Undo/redo replay *source snapshots*, not stored tree mutations: a rebuild
548
+ * replaces every node id, so a retained `Transaction` could never be
549
+ * re-applied against the current tree — and its `invert()` was only defined
550
+ * for one of the six operation kinds anyway. Text is always replayable.
551
+ */
552
+ undo(): boolean {
553
+ const entry = this.undoManager.undo()
554
+ if (!entry) return false
555
+ this.rebuild(entry.before)
556
+ return true
557
+ }
558
+
559
+ /**
560
+ * Redo the last undone transaction. See `undo` for why this replays text.
561
+ */
562
+ redo(): boolean {
563
+ const entry = this.undoManager.redo()
564
+ if (!entry) return false
565
+ this.rebuild(entry.after)
566
+ return true
567
+ }
568
+
569
+ /**
570
+ * Run semantic analysis on the current tree.
571
+ */
572
+ analyze(): AnalyzeResult {
573
+ if (!this._redRoot) {
574
+ throw new Error('Cannot analyze: no document loaded')
575
+ }
576
+ const result = this.semanticAnalyzer.analyze(this._redRoot, this._source)
577
+ this._diagnostics = result.diagnostics
578
+
579
+ // Keep the id index out of the retained result: holding it would pin every
580
+ // RedNode of the *previous* tree alive until the next analyze — a leak in
581
+ // all but name, and measured at +1.3 ms per rebuild in GC pressure alone.
582
+ // It is a getter now, so simply not reading it also means never building it.
583
+ this._lastAnalyzeResult = {
584
+ diagnostics: result.diagnostics,
585
+ duration: result.duration,
586
+ nodesAnalyzed: result.nodesAnalyzed,
587
+ }
588
+
589
+ // Attaching diagnostics to nodes used to happen here, and needed an id→node
590
+ // Map of the whole document to do it — a second full walk plus 1736
591
+ // `Map.set` calls, 23% of `analyze()`. The analyzer attaches them during
592
+ // its own walk instead: it has the node in hand at the moment the
593
+ // diagnostic is produced, so both the index and the walk that built it were
594
+ // solving a problem created by separating the two steps.
595
+
596
+ return this._lastAnalyzeResult
597
+ }
598
+
599
+ /**
600
+ * Find a node by its ID.
601
+ */
602
+ findNode(id: NodeId): RedNode | null {
603
+ return this._redRoot?.findById(id) ?? null
604
+ }
605
+
606
+ /**
607
+ * Query the document tree.
608
+ */
609
+ query(q: Query): QueryResult {
610
+ if (!this._redRoot) {
611
+ return { matches: [], total: 0, time: 0 }
612
+ }
613
+ return this.queryEngine.execute(q, this._redRoot)
614
+ }
615
+
616
+ /**
617
+ * Register a semantic validator.
618
+ */
619
+ registerValidator(validator: Validator): void {
620
+ this.semanticAnalyzer.register(validator)
621
+ }
622
+
623
+ // ── Serialization ──
624
+
625
+ /**
626
+ * Get the current document as a DocumentNode tree.
627
+ */
628
+ toDocumentNode(): DocumentNode | null {
629
+ return this._redRoot?.toDocumentNode() ?? null
630
+ }
631
+
632
+ /**
633
+ * Get the current source text.
634
+ */
635
+ toString(): string {
636
+ return this._source
637
+ }
638
+
639
+ // ── Internal ──
640
+
641
+ /**
642
+ * Parse source text to a GreenNode tree.
643
+ * Override this for language-specific parsing.
644
+ *
645
+ * `options.normalizeParagraphs === false` means the text is the inner span
646
+ * of a container rather than a whole document, and root-level grouping must
647
+ * be skipped. Languages without such grouping can ignore it.
648
+ */
649
+ protected parseToGreen(source: string, options?: ReparseParseOptions): GreenNode {
650
+ // Default: use the TreeBuilder
651
+ // Language-specific overrides (BBCodeParser) will provide
652
+ // proper tag-aware parsing.
653
+ return this.treeBuilder.build(
654
+ new ArrayTokenStream([]),
655
+ ).green
656
+ }
657
+
658
+ /**
659
+ * Build a RedNode tree from a GreenNode.
660
+ */
661
+ protected buildRedFromGreen(green: GreenNode): RedNode {
662
+ return this.treeBuilder.buildRed(green)
663
+ }
664
+
665
+ /**
666
+ * Build a red tree for `green`, reusing subtrees of `oldRed` where the green
667
+ * is shared by reference. The base model has no reuse-aware builder, so it
668
+ * falls back to a full build; language models override this
669
+ * (BBCodeDocumentModel uses `greenToRedNodeReusing`).
670
+ */
671
+ protected buildRedFromGreenReusing(
672
+ green: GreenNode,
673
+ oldRed: RedNode,
674
+ stats?: { adopted: number },
675
+ ): RedNode {
676
+ return this.buildRedFromGreen(green)
677
+ }
678
+
679
+ /**
680
+ * Serialize a red tree back to source text. `transact`/`undo`/`redo` rebuild
681
+ * from this text to keep source, green and red coherent.
682
+ *
683
+ * The base model has no syntax to serialize to, so this throws; language
684
+ * models override it (BBCodeDocumentModel uses the BBCodeExporter).
685
+ * Failing loudly here beats the alternative: silently desynchronizing the
686
+ * model's three representations.
687
+ */
688
+ protected exportSource(root: RedNode): string {
689
+ throw new Error(
690
+ `DocumentModel.exportSource is not implemented for language '${this._language}' — ` +
691
+ 'override it in the language-specific model to enable transact/undo/redo',
692
+ )
693
+ }
694
+ }