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