@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,110 @@
1
+ /**
2
+ * DocumentEngine — Green tree editing primitives
3
+ *
4
+ * The green tree is immutable, so an edit is not a mutation: it is a new tree
5
+ * that shares everything the edit did not touch.
6
+ *
7
+ * ─── What used to be here ───────────────────────────────────────────────────
8
+ *
9
+ * `shiftGreen(node, delta)` — a deep copy of a subtree with every range moved.
10
+ * It existed because green nodes carried absolute positions, so inserting three
11
+ * characters near the top of a document meant rebuilding every node after the
12
+ * insertion point just to renumber it. On a 19.6 KB document that was 14% of
13
+ * the cost of an incremental reparse, spent entirely on producing structurally
14
+ * identical copies of nodes that had not changed.
15
+ *
16
+ * It is gone, and there is nothing to replace it with. Green nodes have widths,
17
+ * not positions (see `GreenNode.ts`), so text inserted before a subtree changes
18
+ * where it is — which is the red tree's business — and not what it is. A
19
+ * sibling after an edit is now shared by reference, exactly like a sibling
20
+ * before it always was.
21
+ */
22
+
23
+ import { GreenNode, greenNode } from './GreenNode'
24
+
25
+ /** One step of the descent from the root to the node being replaced. */
26
+ export interface SpineStep {
27
+ /** The ancestor. */
28
+ node: GreenNode
29
+ /** Index of the child that the descent continued into. */
30
+ index: number
31
+ }
32
+
33
+ /**
34
+ * Rebuild the ancestor spine after replacing one node.
35
+ *
36
+ * `spine` runs root-first; `replacement` takes the place of
37
+ * `spine[spine.length - 1].node.children[spine[spine.length - 1].index]`.
38
+ *
39
+ * Only the spine itself is rebuilt — one new node per level of nesting, and
40
+ * every other subtree in the document is carried over by reference. The
41
+ * ancestors' widths follow from their new children automatically, so there is
42
+ * no delta to propagate and no way to get the arithmetic wrong.
43
+ */
44
+ export function spliceGreen(
45
+ spine: readonly SpineStep[],
46
+ replacement: GreenNode,
47
+ ): GreenNode {
48
+ let current = replacement
49
+
50
+ for (let i = spine.length - 1; i >= 0; i--) {
51
+ const { node, index } = spine[i]
52
+ const oldChildren = node.children as readonly GreenNode[]
53
+ const children = new Array<GreenNode>(oldChildren.length)
54
+
55
+ for (let j = 0; j < oldChildren.length; j++) children[j] = oldChildren[j]
56
+ children[index] = current
57
+
58
+ current = greenNode(
59
+ node.kind,
60
+ node.text,
61
+ children,
62
+ node.leadingWidth,
63
+ node.trailingWidth,
64
+ )
65
+ }
66
+
67
+ return current
68
+ }
69
+
70
+ /**
71
+ * Replace a node's children while keeping the node itself — its kind, its
72
+ * attributes and, above all, its delimiters.
73
+ *
74
+ * The incremental parser re-parses a span strictly INSIDE a node, so that
75
+ * node's own `[centre]` and `[/centre]` never pass through the parser again and
76
+ * cannot be reinterpreted. `leadingWidth`/`trailingWidth` carry across
77
+ * untouched; the new width follows from the new children.
78
+ */
79
+ export function withChildren(node: GreenNode, children: GreenNode[]): GreenNode {
80
+ return greenNode(node.kind, node.text, children, node.leadingWidth, node.trailingWidth)
81
+ }
82
+
83
+ /**
84
+ * Replace the children `[from, to)` of `node` with `replacement`.
85
+ *
86
+ * The unit an edit actually touches is a RUN OF SIBLINGS, not a whole node.
87
+ * Re-parsing a container's entire contents because one character changed
88
+ * somewhere inside it meant that typing into a large `[notice]` re-lexed 66% of
89
+ * the document per keystroke; and an edit at document level had no enclosing
90
+ * container at all, so it fell back to a full rebuild — which is where the
91
+ * caret sits while you write the end of a post, the single most common case.
92
+ *
93
+ * Everything outside `[from, to)` is carried over by reference.
94
+ */
95
+ export function withChildrenSpliced(
96
+ node: GreenNode,
97
+ from: number,
98
+ to: number,
99
+ replacement: readonly GreenNode[],
100
+ ): GreenNode {
101
+ const old = node.children as readonly GreenNode[]
102
+ const children = new Array<GreenNode>(from + replacement.length + (old.length - to))
103
+
104
+ let w = 0
105
+ for (let i = 0; i < from; i++) children[w++] = old[i]
106
+ for (let i = 0; i < replacement.length; i++) children[w++] = replacement[i]
107
+ for (let i = to; i < old.length; i++) children[w++] = old[i]
108
+
109
+ return greenNode(node.kind, node.text, children, node.leadingWidth, node.trailingWidth)
110
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Quasar — Structural Hashing Primitives (FNV-1a)
3
+ *
4
+ * Shared by GreenNode (`_hash`, for interning and structural sharing) and
5
+ * NodeMatcher (subtree fingerprints, for cross-version node matching).
6
+ *
7
+ * FNV-1a is chosen for the same reason Roslyn uses a cheap non-cryptographic
8
+ * hash: this runs once per node on every keystroke, so throughput matters far
9
+ * more than adversarial collision resistance. `Math.imul` keeps the multiply
10
+ * in 32-bit integer space instead of promoting to doubles.
11
+ */
12
+
13
+ export const FNV_OFFSET = 0x811c9dc5
14
+ export const FNV_PRIME = 0x01000193
15
+
16
+ /** Secondary basis, used to build a 64-bit key from two independent lanes. */
17
+ export const FNV_OFFSET_ALT = 0x27d4eb2f
18
+
19
+ export function hashString(seed: number, str: string): number {
20
+ let h = seed
21
+ for (let i = 0; i < str.length; i++) {
22
+ h ^= str.charCodeAt(i)
23
+ h = Math.imul(h, FNV_PRIME)
24
+ }
25
+ return h >>> 0
26
+ }
27
+
28
+ export function hashUint32(seed: number, value: number): number {
29
+ return Math.imul(seed ^ value, FNV_PRIME) >>> 0
30
+ }
@@ -0,0 +1,12 @@
1
+ export { GreenNode, greenNode, greenLeaf } from './GreenNode'
2
+ export { RedNode } from './RedNode'
3
+ export { TreeBuilder } from './TreeBuilder'
4
+ export type { BuildResult, NodeFactory } from './TreeBuilder'
5
+ export { checkPartition, assertPartition } from './partition'
6
+ export type {
7
+ PartitionViolation,
8
+ PartitionViolationKind,
9
+ CheckPartitionOptions,
10
+ } from './partition'
11
+ export { NodeMatcher } from './NodeMatcher'
12
+ export type { MatchResult, MatchStatus, NodeMatch } from './NodeMatcher'
@@ -0,0 +1,161 @@
1
+ /**
2
+ * DocumentEngine — Partition Invariant
3
+ *
4
+ * The green tree must PARTITION the source text: every character of the input
5
+ * is accounted for exactly once, and the tree can answer "which node owns
6
+ * offset N?" without ambiguity.
7
+ *
8
+ * Before the width model this was false in three ways, all measured:
9
+ *
10
+ * 1. Delimiters were not represented at all. `[b]hola[/b]` produced a `bold`
11
+ * spanning `[0..13]` whose only child spanned `[3..7]` — 6 characters
12
+ * inside the parent belonged to no node. 32.630 occurrences of
13
+ * `parent.start != firstChild.start` in a single real document.
14
+ * 2. Siblings could occupy the same range. `"hola\n\nmundo"` produced
15
+ * `spacing [4..6]` AND `empty_line [4..6]`, so offset 5 had two owners.
16
+ * 3. Orphaned closing tags were dropped silently, leaving holes in the root.
17
+ *
18
+ * ─── What this module still checks, and what it no longer needs to ──────────
19
+ *
20
+ * Most of the invariant is now STRUCTURAL. A green node has no position, and
21
+ * its width is computed by its own constructor as
22
+ * `leadingWidth + Σ child widths + trailingWidth`, so gaps, overlaps and
23
+ * mismatched delimiters are not states the type can be in. The checks for them
24
+ * were deleted along with the ability to fail them.
25
+ *
26
+ * Two things remain genuinely checkable, and they are the two that matter:
27
+ *
28
+ * - **Coverage.** The root's width must equal the source length. If the parser
29
+ * drops a token — which is exactly what the orphaned-closing-tag bug did —
30
+ * the tree is internally consistent but describes a shorter document.
31
+ * - **Leaf honesty.** A `text` token's width must equal the text it holds.
32
+ * Nothing derives this, so nothing enforces it.
33
+ */
34
+
35
+ import type { GreenNode } from './GreenNode'
36
+
37
+ export type PartitionViolationKind =
38
+ /** The root's width does not match the source length. */
39
+ | 'root-coverage'
40
+ /** A leaf's width does not match the text it claims to hold. */
41
+ | 'leaf-width'
42
+ /** A node's width disagrees with its own children plus delimiters. */
43
+ | 'width-mismatch'
44
+
45
+ export interface PartitionViolation {
46
+ kind: PartitionViolationKind
47
+ /** Structural path to the offending node, e.g. `document/paragraph[0]/bold[1]`. */
48
+ path: string
49
+ nodeKind: string
50
+ detail: string
51
+ }
52
+
53
+ export interface CheckPartitionOptions {
54
+ /** Stop after this many violations. Default 200 — enough to diagnose, cheap to print. */
55
+ limit?: number
56
+ /**
57
+ * Also require leaf widths to match `text.length`. Off by default: `text` on
58
+ * a leaf is overloaded (it holds tag attributes on element nodes and is empty
59
+ * on `spacing`/`empty_line`), so only `text`-kind leaves are checked even
60
+ * when this is on.
61
+ */
62
+ checkLeafWidths?: boolean
63
+ }
64
+
65
+ /**
66
+ * Verify that `root` partitions `[0..sourceLength]`.
67
+ *
68
+ * Returns an empty array when the tree is well formed. Never throws.
69
+ */
70
+ export function checkPartition(
71
+ root: GreenNode,
72
+ sourceLength: number,
73
+ options: CheckPartitionOptions = {},
74
+ ): PartitionViolation[] {
75
+ const limit = options.limit ?? 200
76
+ const checkLeafWidths = options.checkLeafWidths ?? false
77
+ const violations: PartitionViolation[] = []
78
+
79
+ const report = (
80
+ kind: PartitionViolationKind,
81
+ path: string,
82
+ nodeKind: string,
83
+ detail: string,
84
+ ): void => {
85
+ if (violations.length < limit) {
86
+ violations.push({ kind, path, nodeKind, detail })
87
+ }
88
+ }
89
+
90
+ if (root.width !== sourceLength) {
91
+ report(
92
+ 'root-coverage',
93
+ root.kind,
94
+ root.kind,
95
+ `root width ${root.width}, source length ${sourceLength}`,
96
+ )
97
+ }
98
+
99
+ // Iterative walk: documents nest deeply enough (lists inside boxes inside
100
+ // centres) that recursion here is a needless risk in a validator.
101
+ const stack: { node: GreenNode; path: string }[] = [{ node: root, path: root.kind }]
102
+
103
+ while (stack.length > 0 && violations.length < limit) {
104
+ const { node, path } = stack.pop()!
105
+ const children = node.children as readonly GreenNode[]
106
+
107
+ if (children.length === 0) {
108
+ // A childless node is either a token whose whole width is its own
109
+ // content, or an empty element (`[b][/b]`) whose whole width is its two
110
+ // delimiters. Either way the delimiters cannot claim more room than the
111
+ // node occupies.
112
+ if (node.leadingWidth + node.trailingWidth > node.width) {
113
+ report(
114
+ 'width-mismatch',
115
+ path,
116
+ node.kind,
117
+ `width ${node.width} but leading ${node.leadingWidth} + trailing ${node.trailingWidth}`,
118
+ )
119
+ }
120
+ if (
121
+ checkLeafWidths &&
122
+ node.kind === 'text' &&
123
+ node.width !== node.text.length
124
+ ) {
125
+ report(
126
+ 'leaf-width',
127
+ path,
128
+ node.kind,
129
+ `width ${node.width} but text.length ${node.text.length}`,
130
+ )
131
+ }
132
+ continue
133
+ }
134
+
135
+ // Belt and braces: the constructor computes this, so a failure here means
136
+ // somebody built a GreenNode by a route that bypassed it.
137
+ let sum = node.leadingWidth + node.trailingWidth
138
+ for (let i = 0; i < children.length; i++) {
139
+ sum += children[i].width
140
+ stack.push({ node: children[i], path: `${path}/${children[i].kind}[${i}]` })
141
+ }
142
+ if (sum !== node.width) {
143
+ report(
144
+ 'width-mismatch',
145
+ path,
146
+ node.kind,
147
+ `width ${node.width} but children + delimiters sum to ${sum}`,
148
+ )
149
+ }
150
+ }
151
+
152
+ return violations
153
+ }
154
+
155
+ /** Convenience for tests: throw a readable error if the invariant is broken. */
156
+ export function assertPartition(root: GreenNode, sourceLength: number): void {
157
+ const violations = checkPartition(root, sourceLength, { limit: 10 })
158
+ if (violations.length === 0) return
159
+ const lines = violations.map(v => ` ${v.kind} at ${v.path}: ${v.detail}`)
160
+ throw new Error(`Tree does not partition the source:\n${lines.join('\n')}`)
161
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * DocumentEngine — preserveNodeIds
3
+ *
4
+ * Carry `RedNode.id` across a reparse, so a node that did not change keeps
5
+ * the identity it had in the previous tree.
6
+ *
7
+ * Why this exists: every element in the rendered HTML embeds `data-node-id`
8
+ * (the preview→Monaco click mapping reads it), and ids used to be regenerated
9
+ * on every parse. That made the HTML of *unchanged* subtrees differ between
10
+ * keystrokes, which defeated the DOMMorpher's `isEqualNode` fast path —
11
+ * measured on a one-character edit, only 2 of 5 top-level subtrees compared
12
+ * equal with ids embedded, versus 4 of 5 without them. Stable ids also make
13
+ * any future node-identity feature (selection preservation, collaboration)
14
+ * possible at all: two versions of a document can refer to the same node.
15
+ *
16
+ * The pairing strategy mirrors `NodeMatcher`'s Phase 0, which is validated at
17
+ * scale: walk both trees in lockstep, trim the common prefix and suffix of
18
+ * every child list, and only descend into the changed window when it is the
19
+ * single-child shape a keystroke produces. Three tiers of matching:
20
+ *
21
+ * 1. Reference-equal greens — the incremental splice shares untouched
22
+ * subtrees by reference, so this covers almost everything while typing.
23
+ * Identical by construction; adopt the whole subtree, no checks.
24
+ * 2. Hash-equal greens — the full-rebuild path reparses everything, so
25
+ * nothing is reference-equal but unchanged subtrees hash the same.
26
+ * Verified structurally before adopting (a 32-bit hash is not proof).
27
+ * 3. Same kind at the same position — the node itself changed (it is the
28
+ * one being typed into) but it is still "the same element", so it keeps
29
+ * its id and its children get the prefix/suffix treatment.
30
+ *
31
+ * Uniqueness is preserved by construction: the walk is strictly positional
32
+ * (old child i pairs with at most one new child), so an old id is adopted at
33
+ * most once, and ids never adopted stay as freshly generated — the global
34
+ * counter guarantees those cannot collide with anything older.
35
+ */
36
+
37
+ import type { RedNode } from './RedNode'
38
+ import type { NodeId } from '../Types/core'
39
+
40
+ export interface PreserveIdsStats {
41
+ /** Nodes that kept their previous id */
42
+ adopted: number
43
+ /** Nodes in the new tree that kept a fresh id */
44
+ fresh: number
45
+ }
46
+
47
+ /**
48
+ * Copy ids from `oldRoot`'s tree onto the matching nodes of `newRoot`'s tree.
49
+ * Returns how many nodes were matched, for tests and diagnostics.
50
+ */
51
+ export function preserveNodeIds(oldRoot: RedNode, newRoot: RedNode): PreserveIdsStats {
52
+ const stats: PreserveIdsStats = { adopted: 0, fresh: 0 }
53
+ preserve(oldRoot, newRoot, stats)
54
+ stats.fresh = countNodes(newRoot) - stats.adopted
55
+ return stats
56
+ }
57
+
58
+ function preserve(oldNode: RedNode, newNode: RedNode, stats: PreserveIdsStats): void {
59
+ // Tier 0: with red-subtree reuse, "both" nodes are often the SAME object,
60
+ // adopted from the old tree — its ids are already its own. Note these
61
+ // subtrees are not counted in `adopted`.
62
+ if (oldNode === newNode) return
63
+
64
+ // Tier 1: the splice shared this subtree by reference — identical by
65
+ // construction, adopt wholesale.
66
+ if (oldNode.green === newNode.green) {
67
+ adoptSubtree(oldNode, newNode, stats)
68
+ return
69
+ }
70
+
71
+ // Tier 2: same hash across a full reparse. Verify before trusting it —
72
+ // a false adoption would be harmless for correctness (the morpher compares
73
+ // content, not ids) but would pair ids across genuinely different nodes.
74
+ if (oldNode.green._hash === newNode.green._hash && verifyAndAdopt(oldNode, newNode, stats)) {
75
+ return
76
+ }
77
+
78
+ // Tier 3: the changed path itself. Same kind at the same position is the
79
+ // same element with different content — keep its identity and descend.
80
+ if (oldNode.kind !== newNode.kind) return
81
+ adoptId(oldNode, newNode, stats)
82
+
83
+ const oldKids = oldNode.children
84
+ const newKids = newNode.children
85
+ const limit = Math.min(oldKids.length, newKids.length)
86
+
87
+ // Common prefix.
88
+ let lo = 0
89
+ while (lo < limit && matchable(oldKids[lo], newKids[lo])) {
90
+ preserve(oldKids[lo], newKids[lo], stats)
91
+ lo++
92
+ }
93
+
94
+ // Common suffix, stopping before the prefix already consumed.
95
+ let oldHi = oldKids.length - 1
96
+ let newHi = newKids.length - 1
97
+ while (oldHi >= lo && newHi >= lo && matchable(oldKids[oldHi], newKids[newHi])) {
98
+ preserve(oldKids[oldHi], newKids[newHi], stats)
99
+ oldHi--
100
+ newHi--
101
+ }
102
+
103
+ // A single changed child on both sides is the shape a keystroke produces:
104
+ // descend so its own untouched children still keep their ids. A wider
105
+ // window (multi-block paste, reordering) keeps fresh ids — guessing there
106
+ // would risk pairing unrelated nodes.
107
+ if (oldHi === lo && newHi === lo) {
108
+ preserve(oldKids[lo], newKids[lo], stats)
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Whether two positionally aligned children are the SAME content, and so can
114
+ * be walked in lockstep while trimming the common prefix and suffix.
115
+ *
116
+ * Deliberately not "same kind": that is true of any two paragraphs, so a
117
+ * single inserted block would let the trim march through the whole list
118
+ * pairing each survivor with its neighbour — renumbering every block after
119
+ * the insertion and, worse, assigning ids that are still in use elsewhere.
120
+ * Evidence of sameness has to be content-based; kind similarity is only
121
+ * enough for the one changed child in the middle, which `preserve` handles.
122
+ */
123
+ function matchable(oldNode: RedNode, newNode: RedNode): boolean {
124
+ return (
125
+ oldNode.green === newNode.green ||
126
+ oldNode.green._hash === newNode.green._hash
127
+ )
128
+ }
129
+
130
+ /**
131
+ * Adopt every id of a subtree pair whose greens are the same object.
132
+ *
133
+ * Reference-equal greens guarantee identical *green* structure, but a red
134
+ * tree can be mutated directly (`transact` deletes red children without
135
+ * touching green, and with interning on, an old mutated red can still sit on
136
+ * a pool-shared green). When the red child lists disagree, fall back to the
137
+ * full tiered walk for that level instead of indexing out of bounds.
138
+ */
139
+ function adoptSubtree(oldNode: RedNode, newNode: RedNode, stats: PreserveIdsStats): void {
140
+ if (oldNode === newNode) return
141
+ adoptId(oldNode, newNode, stats)
142
+ const oldKids = oldNode.children
143
+ const newKids = newNode.children
144
+ if (oldKids.length !== newKids.length) {
145
+ const limit = Math.min(oldKids.length, newKids.length)
146
+ for (let i = 0; i < limit; i++) {
147
+ if (!matchable(oldKids[i], newKids[i])) break
148
+ preserve(oldKids[i], newKids[i], stats)
149
+ }
150
+ return
151
+ }
152
+ for (let i = 0; i < oldKids.length; i++) {
153
+ adoptSubtree(oldKids[i], newKids[i], stats)
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Structurally verify a hash match and, only if the whole subtree confirms,
159
+ * adopt every id. Mirrors `NodeMatcher.verifyIdentical`: pairs are collected
160
+ * during the check and committed atomically, so a mismatch (hash collision)
161
+ * leaves nothing half-adopted before the caller falls through to Tier 3.
162
+ */
163
+ function verifyAndAdopt(oldNode: RedNode, newNode: RedNode, stats: PreserveIdsStats): boolean {
164
+ const pairs: RedNode[] = []
165
+ if (!verifyIdentical(oldNode, newNode, pairs)) return false
166
+ for (let i = 0; i < pairs.length; i += 2) {
167
+ adoptId(pairs[i], pairs[i + 1], stats)
168
+ }
169
+ return true
170
+ }
171
+
172
+ function verifyIdentical(oldNode: RedNode, newNode: RedNode, pairs: RedNode[]): boolean {
173
+ if (
174
+ oldNode.kind !== newNode.kind ||
175
+ oldNode.text !== newNode.text ||
176
+ oldNode.children.length !== newNode.children.length
177
+ ) {
178
+ return false
179
+ }
180
+
181
+ pairs.push(oldNode, newNode)
182
+
183
+ const oldKids = oldNode.children
184
+ const newKids = newNode.children
185
+ for (let i = 0; i < oldKids.length; i++) {
186
+ if (!verifyIdentical(oldKids[i], newKids[i], pairs)) return false
187
+ }
188
+ return true
189
+ }
190
+
191
+ function adoptId(oldNode: RedNode, newNode: RedNode, stats: PreserveIdsStats): void {
192
+ // `id` is readonly for consumers; this module is the one sanctioned writer.
193
+ ;(newNode as { id: NodeId }).id = oldNode.id
194
+ stats.adopted++
195
+ }
196
+
197
+ function countNodes(root: RedNode): number {
198
+ let n = 1
199
+ for (const child of root.children) n += countNodes(child)
200
+ return n
201
+ }
@@ -0,0 +1,77 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { BBCodeDocumentModel } from '../BBCode/BBCodeDocumentModel'
3
+ import { BBCodeExporter } from '../Visitors/BBCodeExporter'
4
+ import { ASTOptimizer } from '../Transformers/ASTOptimizer'
5
+ import { TagRegistry } from '../Model/TagRegistry'
6
+
7
+ /**
8
+ * Regression: ASTOptimizer must be idempotent — optimizing its own output has to
9
+ * be a no-op.
10
+ *
11
+ * The fuzz suite originally found this but could not report it: it ran on an
12
+ * unseeded `Math.random()`, so the failure showed up in ~50% of runs and the
13
+ * payload that caused it was discarded. Seeding the fuzzer made CI
14
+ * deterministic, but the default seed happens to miss this case, so the
15
+ * minimized repro is pinned here.
16
+ *
17
+ * The bug: paragraphs exist because a block element splits inline content. When
18
+ * a rule deleted that block — here an empty `[notice]` — the two halves were
19
+ * left as adjacent `paragraph` siblings with nothing between them, and
20
+ * `paragraph` is unmergeable. Exporting that tree emits no separator, so
21
+ * re-parsing folded the paragraphs back into one and a *second* optimizer pass
22
+ * found more work. Fixed by `ASTOptimizer.isOrphanedParagraphPair`, which merges
23
+ * that specific shape — one the parser itself never produces.
24
+ *
25
+ * Other seeds that reproduced it: 2, 7, 42, 99999.
26
+ * Repro a whole corpus with: QUASAR_FUZZ_SEED=2 npm test
27
+ */
28
+ describe('ASTOptimizer — idempotence', () => {
29
+ const registry = new TagRegistry()
30
+ const REPRO = '[url=https://osu.ppy.sh][/url][notice][/notice][url=https://osu.ppy.sh]'
31
+
32
+ /** Optimize → export → re-parse → re-optimize. Should need zero mutations. */
33
+ function reoptimizationOps(source: string): number {
34
+ const model = new BBCodeDocumentModel({ source, strictMode: false })
35
+ new ASTOptimizer().transform(model)
36
+
37
+ const exported = new BBCodeExporter(registry).export(model.redRoot!)
38
+
39
+ const reparsed = new BBCodeDocumentModel({ source: exported, strictMode: false })
40
+ const { transaction } = new ASTOptimizer().transform(reparsed)
41
+ return transaction?.operations.length ?? 0
42
+ }
43
+
44
+ function optimizeAndExport(source: string): string {
45
+ const model = new BBCodeDocumentModel({ source, strictMode: false })
46
+ new ASTOptimizer().transform(model)
47
+ return new BBCodeExporter(registry).export(model.redRoot!)
48
+ }
49
+
50
+ it('optimizing already-optimized output is a no-op', () => {
51
+ expect(reoptimizationOps(REPRO)).toBe(0)
52
+ })
53
+
54
+ it('reaches its fixed point in a single pass', () => {
55
+ const pass1 = optimizeAndExport(REPRO)
56
+ expect(optimizeAndExport(pass1)).toBe(pass1)
57
+ })
58
+
59
+ it('never loses or alters visible text', () => {
60
+ const model = new BBCodeDocumentModel({ source: REPRO, strictMode: false })
61
+ const before = model.redRoot!.green.text
62
+ new ASTOptimizer().transform(model)
63
+ expect(model.redRoot!.green.text).toBe(before)
64
+ })
65
+
66
+ // The fix is deliberately narrow: only paragraphs with *nothing* between them
67
+ // collapse. An authored blank line is a real break and must survive.
68
+ it('preserves paragraphs separated by a blank line', () => {
69
+ const source = '[b]uno[/b]\n\n[b]dos[/b]'
70
+ const optimized = optimizeAndExport(source)
71
+ expect(optimized).toContain('uno')
72
+ expect(optimized).toContain('dos')
73
+ expect(optimized).toMatch(/\n/)
74
+ // And it is still a fixed point.
75
+ expect(optimizeAndExport(optimized)).toBe(optimized)
76
+ })
77
+ })