@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,591 @@
1
+ /**
2
+ * DocumentEngine — IncrementalParser
3
+ *
4
+ * Re-parses the smallest region a text change can have affected, instead of
5
+ * the whole document.
6
+ *
7
+ * ─── Why this was rewritten (roadmap S5 / point 9) ──────────────────────────
8
+ *
9
+ * The previous implementation spliced RED nodes in place and never touched
10
+ * ranges. Three consequences, all measured:
11
+ *
12
+ * - The re-parsed subtree came from `newSource.slice(start, end)`, so its
13
+ * ranges were based at 0 and were grafted in without rebasing. Every offset
14
+ * inside the edited region was wrong by `start`.
15
+ * - Siblings after the splice and every ancestor kept their old ranges, so
16
+ * the tree silently disagreed with the text it claimed to describe.
17
+ * - It returned `newRootRed.green` — the OLD green root — so the model's
18
+ * `_greenRoot` and `_redRoot` desynchronised permanently after the first
19
+ * incremental edit.
20
+ *
21
+ * Rendering the result and comparing it against a full rebuild of the same
22
+ * final text: 3 of 6 realistic editing scenarios produced DIFFERENT HTML, and
23
+ * one grew 14 phantom nodes (1752 vs 1738).
24
+ *
25
+ * ─── How it works now ───────────────────────────────────────────────────────
26
+ *
27
+ * Everything happens on the GREEN tree, and the red tree is derived from it.
28
+ * That is not a stylistic choice: green nodes carry widths and no position, so
29
+ * a position is something only a red node has — and therefore only a red node
30
+ * can be wrong about.
31
+ *
32
+ * 1. Descend to the deepest node whose inner span (the part between its
33
+ * delimiters) contains the change, refusing to enter the kinds whose
34
+ * children depend on context outside them (see `OPAQUE_KINDS`).
35
+ * 2. Take the run of that node's children the change touches, widened by one
36
+ * on each side, and re-parse just those.
37
+ * 3. Splice the result back over that run and rebuild the ancestor spine,
38
+ * sharing every untouched subtree by reference.
39
+ *
40
+ * The unit is a RUN OF SIBLINGS, not a whole node. Re-parsing a container's
41
+ * entire contents because one character changed inside it meant typing into a
42
+ * 13 KB `[notice]` re-lexed 66% of the document per keystroke — and an edit at
43
+ * document level had no enclosing container at all, so it fell back to a full
44
+ * rebuild, which is exactly where the caret sits while you write the end of a
45
+ * post.
46
+ *
47
+ * Step 1 relies on the partition invariant from point 14: without it, "the
48
+ * part between the delimiters" is not a well-defined range, which is exactly
49
+ * why this repair was blocked on that work.
50
+ *
51
+ * When any precondition fails the parser returns a full rebuild rather than a
52
+ * plausible-looking wrong tree. `path` says which happened, `reason` says why.
53
+ */
54
+
55
+ import { RedNode } from '../Syntax/RedNode'
56
+ import { GreenNode } from '../Syntax/GreenNode'
57
+ import { spliceGreen, withChildrenSpliced, type SpineStep } from '../Syntax/greenEdit'
58
+ import { tagToNodeKind } from '../BBCode/BBCodeToGreenNode'
59
+ import type { TextChange } from './ChangeTracker'
60
+
61
+ export interface EditOperation {
62
+ kind: 'insert' | 'delete' | 'replace'
63
+ start: number
64
+ end: number
65
+ text: string
66
+ /** The minimal range that needs re-parsing */
67
+ affectedStart: number
68
+ affectedEnd: number
69
+ }
70
+
71
+ /** Options a caller's parse callback must understand. */
72
+ export interface ReparseParseOptions {
73
+ /**
74
+ * Whether the text being parsed is document-level content. Inner spans of
75
+ * containers are not, and must not be grouped into paragraphs.
76
+ */
77
+ normalizeParagraphs: boolean
78
+ }
79
+
80
+ export interface ReparseResult {
81
+ green: GreenNode
82
+ red: RedNode
83
+ /** Nodes that were affected by the change */
84
+ affectedNodes: RedNode[]
85
+ /** Time taken in ms (total) */
86
+ duration: number
87
+ /** Per-phase timing breakdown in ms */
88
+ timings: {
89
+ findAffected: number
90
+ safeBoundary: number
91
+ parse: number
92
+ buildRed: number
93
+ mutate: number
94
+ other: number
95
+ }
96
+ /** Which path was used */
97
+ path: 'incremental' | 'full_rebuild'
98
+ /** When `full_rebuild`, why the incremental path was declined. */
99
+ reason?: FallbackReason
100
+ }
101
+
102
+ export type FallbackReason =
103
+ /** No sibling window could be formed around the change. */
104
+ | 'no-window'
105
+ /** The change touches a container's own delimiter. */
106
+ | 'touches-delimiter'
107
+ /** The region cannot be lexed in isolation — see `regionIsSelfContained`. */
108
+ | 'region-not-isolated'
109
+ /** An unclosed `[` before the region could claim a `]` the edit creates. */
110
+ | 'open-bracket-before'
111
+ /** The container covers so much of the document that a rebuild is cheaper. */
112
+ | 'region-too-large'
113
+ /** The document is small enough that rebuilding it outright costs less. */
114
+ | 'document-too-small'
115
+ /** The tree's ranges disagree with the source it is supposed to describe. */
116
+ | 'stale-ranges'
117
+
118
+ /**
119
+ * Kinds the descent refuses to enter, so they can only ever be re-parsed whole.
120
+ *
121
+ * Each one is here because its children are produced by a rule that looks
122
+ * OUTSIDE them, which a window by definition cannot see:
123
+ *
124
+ * - `paragraph` — paragraph grouping happens at the ROOT and nowhere else, so
125
+ * a window inside a paragraph cannot discover that a newly typed blank line
126
+ * should have SPLIT it into two paragraphs at document level. Stopping here
127
+ * makes the paragraph a member of a window at root level instead, where the
128
+ * re-parse does see the split.
129
+ * - `code` / `inline_code` — raw blocks. Their contents are one literal token
130
+ * because the LEXER saw the opening `[code]`; re-parsing that text on its own
131
+ * lexes it as ordinary BBCode and shatters it into tags. This is what the
132
+ * differential fuzz found first: 192 divergences, and every sample was a
133
+ * `code` node that had grown children.
134
+ * - `list_item` — `[*]` self-closes the previous item, so a `[*]` typed inside
135
+ * one item is its SIBLING in a full parse but would land as its CHILD when
136
+ * the item's contents are parsed alone.
137
+ *
138
+ * Everything else is fair game — the unit of an edit is a run of siblings, not
139
+ * a whole node. There used to be an allow-list of eight block containers here;
140
+ * it meant that typing into a 13 KB `[notice]` re-parsed all 13 KB, and that an
141
+ * edit at document level matched nothing at all and fell back to a full
142
+ * rebuild.
143
+ */
144
+ const OPAQUE_KINDS = new Set(['paragraph', 'code', 'inline_code', 'list_item'])
145
+
146
+ /**
147
+ * Largest share of the document a re-parse region may cover.
148
+ *
149
+ * Above this the incremental path is doing nearly all of a rebuild's work plus
150
+ * the splice, and loses. Chosen from the measured crossover, not from taste.
151
+ */
152
+ const MAX_REGION_FRACTION = 0.7
153
+
154
+ /**
155
+ * Below this source length, don't even try.
156
+ *
157
+ * A rebuild of a small document costs less than the descent, the bracket scan
158
+ * and the splice bookkeeping needed to avoid it. Measured across documents of
159
+ * the same shape at growing sizes: at 1.8 KB / 173 nodes the incremental path
160
+ * was 9% SLOWER, at 3.5 KB / 341 nodes it was 50% faster. The threshold sits
161
+ * in that gap.
162
+ */
163
+ const MIN_SOURCE_LENGTH = 2500
164
+
165
+ /** An orphaned closing tag, as the parser preserves it: a `text` leaf of `[/tag]`. */
166
+ const ORPHAN_CLOSE_RE = /^\[\/([a-zA-Z0-9_*-]+)\]$/
167
+
168
+ /**
169
+ * Can this window be parsed on its own and mean the same thing it means in
170
+ * context? Four ways it cannot:
171
+ *
172
+ * - A closing tag with no opener inside the window, whose name matches an
173
+ * ANCESTOR. In isolation it is literal text; in the whole document it
174
+ * closes that ancestor, which moves the ancestor's own boundary. An orphan
175
+ * that matches no ancestor is text either way and is perfectly safe — being
176
+ * conservative here cost the incremental path most of its opportunities,
177
+ * since a half-typed `[/color]` is the single most common transient state
178
+ * while editing.
179
+ * - A lone `[` with no `]` after it. The lexer's bracket matching would find
180
+ * a `]` beyond the window.
181
+ * - An unclosed `[code]`. Raw blocks swallow everything up to their closing
182
+ * tag, so in isolation one stops at the window's end and in context it does
183
+ * not.
184
+ * - A tag left open at the end of the window. What it swallows next depends on
185
+ * what follows, which the window cannot see:
186
+ * · if the window does NOT reach the parent's last child, the open tag
187
+ * would swallow the FOLLOWING SIBLINGS — they are outside the window, so
188
+ * the re-parse cannot produce them nested;
189
+ * · if it does reach the end and the tag matches ANY ANCESTOR'S kind, it
190
+ * would steal that ancestor's closing delimiter and the ancestor would
191
+ * run on to the next one. Typing `[centre]` inside a `[centre]` does
192
+ * exactly this — and the thief need not be the immediate parent, which
193
+ * is what the fuzz caught with a `[centre]` inside a `[list]` inside a
194
+ * `[centre]`.
195
+ * Anything else left open at a window that reaches the end is fine: a full
196
+ * parse auto-closes it at the enclosing delimiter, which is where the window
197
+ * ends anyway.
198
+ */
199
+ function regionIsSelfContained(
200
+ region: GreenNode,
201
+ ancestorKinds: ReadonlySet<string>,
202
+ window: { reachesEnd: boolean },
203
+ ): boolean {
204
+ // Checked on the PARSED region rather than on a second token scan. Lexing the
205
+ // region twice — once to vet it, once to parse it — cost more than the whole
206
+ // incremental path saved: on a document whose region is most of its length,
207
+ // the "fast" path measured 3-4× SLOWER than a plain rebuild.
208
+ //
209
+ // Everything the guard needs survives into the tree, because the parser now
210
+ // keeps what it used to drop: a bare `[` and an orphaned `[/tag]` are both
211
+ // `text` leaves holding exactly their own source.
212
+ const stack: GreenNode[] = [region]
213
+ while (stack.length > 0) {
214
+ const node = stack.pop()!
215
+
216
+ if (node.children.length === 0) {
217
+ if (node.kind === 'text') {
218
+ // The lexer emits a bare '[' as text exactly when it found no matching
219
+ // bracket — the one case where its decision depends on what follows.
220
+ if (node.text === '[') return false
221
+
222
+ const orphan = ORPHAN_CLOSE_RE.exec(node.text)
223
+ // Compare by node kind, not tag name: `[centre]` and `[center]` are
224
+ // the same element and either spelling closes it.
225
+ if (orphan !== null && ancestorKinds.has(tagToNodeKind(orphan[1]))) return false
226
+ }
227
+ continue
228
+ }
229
+ for (const child of node.children as readonly GreenNode[]) stack.push(child)
230
+ }
231
+
232
+ // Tags still open at the end of the window are exactly the rightmost chain of
233
+ // nodes that carry no closing delimiter.
234
+ let node: GreenNode | undefined = region
235
+ while (node !== undefined) {
236
+ if (node !== region && node.leadingWidth > 0 && node.trailingWidth === 0) {
237
+ // `[code]` and `[c]` are the lexer's raw blocks — different kinds, same
238
+ // swallow-everything behaviour.
239
+ if (node.kind === 'code' || node.kind === 'inline_code') return false
240
+ if (!window.reachesEnd) return false
241
+ // Any ANCESTOR of the same kind, not just the immediate parent: every
242
+ // ancestor's closing delimiter sits after the window, so whichever one
243
+ // comes first would now close this newly opened tag instead. The fuzz
244
+ // found it with a `[centre]` typed inside a `[list]` inside a `[centre]`.
245
+ if (ancestorKinds.has(node.kind)) return false
246
+ }
247
+ node = node.children[node.children.length - 1] as GreenNode | undefined
248
+ }
249
+
250
+ return true
251
+ }
252
+
253
+ /**
254
+ * Does every `[` before `end` find its `]` before `end` too?
255
+ *
256
+ * If one does not, the lexer's bracket matching for it scans onward into the
257
+ * region we are about to re-parse — and an edit that adds a `]` there (or
258
+ * deletes a `[` that was keeping the nesting depth up) changes what that
259
+ * OUTSIDE bracket means. The region would be re-parsed correctly and the text
260
+ * before it would silently become something else.
261
+ *
262
+ * A plain depth count is enough and is exact for this question: the lexer
263
+ * pairs brackets with a stack, so a `[` is unmatched precisely when the depth
264
+ * never returns to its level.
265
+ */
266
+ function bracketsCloseBefore(source: string, end: number): boolean {
267
+ let depth = 0
268
+ for (let i = 0; i < end; i++) {
269
+ const c = source.charCodeAt(i)
270
+ if (c === 91 /* [ */) depth++
271
+ else if (c === 93 /* ] */ && depth > 0) depth--
272
+ }
273
+ return depth === 0
274
+ }
275
+
276
+ export interface IncrementalParserOptions {
277
+ /** Override `MIN_SOURCE_LENGTH`. Set to 0 to always attempt a splice. */
278
+ minSourceLength?: number
279
+ /** Override `MAX_REGION_FRACTION`. */
280
+ maxRegionFraction?: number
281
+ }
282
+
283
+ export class IncrementalParser {
284
+ private readonly minSourceLength: number
285
+ private readonly maxRegionFraction: number
286
+
287
+ /**
288
+ * The thresholds are constructor options because they are performance
289
+ * tuning, not semantics: the tree that comes out is the same either way, so
290
+ * a caller with a different document profile — or a test that wants to
291
+ * exercise the splice on a two-line document — can move them without
292
+ * changing what the parser means.
293
+ */
294
+ constructor(options: IncrementalParserOptions = {}) {
295
+ this.minSourceLength = options.minSourceLength ?? MIN_SOURCE_LENGTH
296
+ this.maxRegionFraction = options.maxRegionFraction ?? MAX_REGION_FRACTION
297
+ }
298
+
299
+ /**
300
+ * Reparse a tree after a text change.
301
+ *
302
+ * Always returns a result — either an incremental splice or a full rebuild.
303
+ * It never returns a tree whose ranges do not describe `newSource`.
304
+ */
305
+ reparse(
306
+ oldRed: RedNode,
307
+ oldGreen: GreenNode,
308
+ change: TextChange,
309
+ newSource: string,
310
+ parseCallback: (text: string, options?: ReparseParseOptions) => GreenNode,
311
+ buildRedCallback: (green: GreenNode) => RedNode,
312
+ ): ReparseResult {
313
+ const startTime = performance.now()
314
+ const delta = change.text.length - (change.end - change.start)
315
+
316
+ const fullRebuild = (reason: FallbackReason, tFind: number): ReparseResult => {
317
+ const t0 = performance.now()
318
+ const green = parseCallback(newSource)
319
+ const tParse = performance.now() - t0
320
+ const t1 = performance.now()
321
+ const red = buildRedCallback(green)
322
+ const tBuild = performance.now() - t1
323
+ const total = performance.now() - startTime
324
+ return {
325
+ green,
326
+ red,
327
+ affectedNodes: [red],
328
+ duration: total,
329
+ timings: {
330
+ findAffected: tFind,
331
+ safeBoundary: 0,
332
+ parse: tParse,
333
+ buildRed: tBuild,
334
+ mutate: 0,
335
+ other: Math.max(0, total - tFind - tParse - tBuild),
336
+ },
337
+ path: 'full_rebuild',
338
+ reason,
339
+ }
340
+ }
341
+
342
+ if (newSource.length < this.minSourceLength) {
343
+ return fullRebuild('document-too-small', 0)
344
+ }
345
+
346
+ // The tree must currently describe the text BEFORE the change; otherwise
347
+ // the offsets we are about to descend by mean nothing. This used to be
348
+ // discovered halfway through, as "tree ranges are stale".
349
+ const oldLength = newSource.length - delta
350
+ if (oldGreen.width !== oldLength) {
351
+ return fullRebuild('stale-ranges', 0)
352
+ }
353
+
354
+ // ─── 1. Find the sibling window around the change ───────────
355
+ //
356
+ // The descent runs over the RED tree, because that is where positions live
357
+ // now. It yields the green spine the splice needs, the parent whose
358
+ // children are being replaced, and the absolute offsets of the window.
359
+ const tFind0 = performance.now()
360
+ const found = this.findReparseWindow(oldRed, change)
361
+ const tFind = performance.now() - tFind0
362
+ if (found === null) return fullRebuild('no-window', tFind)
363
+ const { spine, parent, from, to, windowStart, windowEnd, ancestorKinds } = found
364
+
365
+ // ─── 2. Re-parse just that window ───────────────────────────
366
+ const region = newSource.slice(windowStart, windowEnd + delta)
367
+
368
+ const tBoundary0 = performance.now()
369
+ // Re-parsing a region that is nearly the whole document cannot beat simply
370
+ // rebuilding it, and the splice bookkeeping makes it lose. Measured on a
371
+ // 32 KB document whose container spanned 95% of the text: 0.86 ms spliced
372
+ // against 0.20 ms rebuilt.
373
+ if (region.length > (newSource.length + 1) * this.maxRegionFraction) {
374
+ return fullRebuild('region-too-large', tFind)
375
+ }
376
+ if (!bracketsCloseBefore(newSource, windowStart)) {
377
+ return fullRebuild('open-bracket-before', tFind)
378
+ }
379
+ const tBoundary = performance.now() - tBoundary0
380
+
381
+ const tParse0 = performance.now()
382
+ // Paragraph grouping happens at the root and only there, so the window's
383
+ // content is root content exactly when its parent is the document.
384
+ const isRoot = parent.kind === 'document'
385
+ const parsedRegion = parseCallback(region, { normalizeParagraphs: isRoot })
386
+ const tParse = performance.now() - tParse0
387
+
388
+ const reachesEnd = to === parent.children.length
389
+ if (!regionIsSelfContained(parsedRegion, ancestorKinds, { reachesEnd })) {
390
+ return fullRebuild('region-not-isolated', tFind)
391
+ }
392
+
393
+ // No rebasing step: the parsed region has widths, not offsets, so it is
394
+ // already correct wherever it ends up.
395
+ const newChildren = parsedRegion.children as readonly GreenNode[]
396
+
397
+ // ─── 3. Rebuild the spine ───────────────────────────────────
398
+ const tMutate0 = performance.now()
399
+ const newParent = withChildrenSpliced(parent, from, to, newChildren)
400
+ const newGreenRoot = spliceGreen(spine, newParent)
401
+ const tMutate = performance.now() - tMutate0
402
+
403
+ const tBuild0 = performance.now()
404
+ const newRed = buildRedCallback(newGreenRoot)
405
+ const tBuild = performance.now() - tBuild0
406
+
407
+ const total = performance.now() - startTime
408
+ return {
409
+ green: newGreenRoot,
410
+ red: newRed,
411
+ affectedNodes: [newRed],
412
+ duration: total,
413
+ timings: {
414
+ findAffected: tFind,
415
+ safeBoundary: tBoundary,
416
+ parse: tParse,
417
+ buildRed: tBuild,
418
+ mutate: tMutate,
419
+ other: Math.max(0, total - tFind - tBoundary - tParse - tBuild - tMutate),
420
+ },
421
+ path: 'incremental',
422
+ }
423
+ }
424
+
425
+ /**
426
+ * Find the run of sibling children a change can have affected.
427
+ *
428
+ * Two steps. First descend to the deepest node whose INNER span contains the
429
+ * change — inner rather than full, so a container's own delimiters never go
430
+ * back through the parser: an edit that touches `[colo|r=red]` changes what
431
+ * that element IS, and is handled by re-parsing it as part of its parent's
432
+ * window instead.
433
+ *
434
+ * Then pick the children that the change touches, widened by one on each
435
+ * side. The widening is what lets a deletion MERGE two nodes: removing the
436
+ * blank line between two paragraphs changes only the node in between, and
437
+ * without a neighbour on each side the re-parse could not see that the two
438
+ * survivors have to become one.
439
+ */
440
+ private findReparseWindow(
441
+ root: RedNode,
442
+ change: TextChange,
443
+ ): {
444
+ spine: SpineStep[]
445
+ parent: GreenNode
446
+ from: number
447
+ to: number
448
+ windowStart: number
449
+ windowEnd: number
450
+ ancestorKinds: Set<string>
451
+ } | null {
452
+ const spine: SpineStep[] = []
453
+ const ancestorKinds = new Set<string>([root.kind])
454
+ let node = root
455
+ // Absolute start of `node` in the source. Accumulated from GREEN widths, not
456
+ // from red `range` reads: the red tree's offsets may carry a pending lazy
457
+ // shift from the previous reparse (see `RedNode.setStart`), and reading
458
+ // `range`/`innerStart`/`innerEnd` would force a materialization walk over
459
+ // every displaced subtree — moving the cost the lazy shift was meant to
460
+ // remove back into this phase. Green widths are position-free and always
461
+ // current, and the partition invariant guarantees they accumulate to exactly
462
+ // the materialized red ranges.
463
+ let nodeOffset = root.range.start
464
+
465
+ // ── Descend ──
466
+ // `nodeOffset` tracks the current node's absolute start. Each child begins
467
+ // after the parent's leading delimiter plus the previous siblings' widths,
468
+ // and its inner span is the same accumulation past its own delimiters.
469
+ for (;;) {
470
+ const kids = node.children
471
+ let next = -1
472
+ let offset = nodeOffset + node.green.leadingWidth
473
+ for (let i = 0; i < kids.length; i++) {
474
+ const c = kids[i]
475
+ const innerStart = offset + c.green.leadingWidth
476
+ const innerEnd = offset + c.green.width - c.green.trailingWidth
477
+ if (
478
+ !OPAQUE_KINDS.has(c.kind) &&
479
+ c.children.length > 0 &&
480
+ innerStart <= change.start &&
481
+ change.end <= innerEnd
482
+ ) {
483
+ next = i
484
+ break
485
+ }
486
+ offset += c.green.width
487
+ }
488
+ if (next === -1) break
489
+
490
+ spine.push({ node: node.green, index: next })
491
+ // The child's absolute start: the accumulated offset at its index.
492
+ nodeOffset += node.green.leadingWidth
493
+ for (let i = 0; i < next; i++) nodeOffset += node.children[i].green.width
494
+ node = node.children[next]
495
+ ancestorKinds.add(node.kind)
496
+ }
497
+
498
+ const children = node.children
499
+ if (children.length === 0) return null
500
+
501
+ // ── Window ──
502
+ // A child is touched when it overlaps the change at all, boundaries
503
+ // included: an insertion sits between two children and both are candidates.
504
+ let first = -1
505
+ let last = -1
506
+ let offset = nodeOffset + node.green.leadingWidth
507
+ for (let i = 0; i < children.length; i++) {
508
+ const c = children[i]
509
+ const start = offset
510
+ const end = offset + c.green.width
511
+ if (start <= change.end && change.start <= end) {
512
+ if (first === -1) first = i
513
+ last = i
514
+ }
515
+ offset = end
516
+ }
517
+ // A change past the last child (typing at the very end) touches nothing;
518
+ // anchor it to the final child so there is something to re-parse.
519
+ if (first === -1) {
520
+ first = children.length - 1
521
+ last = first
522
+ }
523
+
524
+ let from = Math.max(0, first - 1)
525
+ const to = Math.min(children.length, last + 2)
526
+
527
+ // A run of newlines is `spacing` followed by `empty_line`s: which one a
528
+ // newline becomes depends on how many came BEFORE it. Starting the window
529
+ // mid-run would make its first newline believe it is the first of all, and
530
+ // it would come back as `spacing`. Walk left to the run's real start.
531
+ while (from > 0 && children[from].kind === 'empty_line') from--
532
+
533
+ // Window offsets, again from green widths (identical to the red ranges the
534
+ // tree would report once materialized).
535
+ let windowStart = nodeOffset + node.green.leadingWidth
536
+ for (let i = 0; i < from; i++) windowStart += children[i].green.width
537
+ let windowEnd = windowStart
538
+ for (let i = from; i < to; i++) windowEnd += children[i].green.width
539
+
540
+ return {
541
+ spine,
542
+ parent: node.green,
543
+ from,
544
+ to,
545
+ windowStart,
546
+ windowEnd,
547
+ ancestorKinds,
548
+ }
549
+ }
550
+
551
+ /**
552
+ * Nodes on the path from the root down to the change.
553
+ *
554
+ * Kept because it is part of the public surface and is genuinely useful for
555
+ * callers that want to know what an edit touched; the reparse itself no
556
+ * longer needs it.
557
+ */
558
+ findAffectedNodes(root: RedNode, change: TextChange): RedNode[] {
559
+ const affected: RedNode[] = []
560
+ let current: RedNode | undefined = root
561
+ // Absolute offset of `current`, accumulated from GREEN widths — reading red
562
+ // ranges here would materialize every pending lazy shift (see
563
+ // `RedNode.setStart`), walking the displaced tail for a call that only
564
+ // needs the containment chain. Same invariant as `findReparseWindow`.
565
+ let offset = root.range.start
566
+
567
+ while (current) {
568
+ if (offset <= change.start && offset + current.green.width >= change.end) {
569
+ affected.push(current)
570
+ // Children partition their parent, so at most one can contain the
571
+ // change — the first match is the only match.
572
+ let next: RedNode | undefined
573
+ let childOffset = offset + current.green.leadingWidth
574
+ for (const child of current.children) {
575
+ const cEnd = childOffset + child.green.width
576
+ if (childOffset <= change.start && cEnd >= change.end) {
577
+ next = child
578
+ offset = childOffset
579
+ break
580
+ }
581
+ childOffset = cEnd
582
+ }
583
+ current = next
584
+ } else {
585
+ break
586
+ }
587
+ }
588
+
589
+ return affected
590
+ }
591
+ }