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