@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,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DocumentEngine — RedNodeStore
|
|
3
|
+
*
|
|
4
|
+
* Canonical store for RedNodes. Ensures that structurally identical subtrees
|
|
5
|
+
* share the EXACT SAME RedNode instance in RAM.
|
|
6
|
+
*
|
|
7
|
+
* This is the RedNode-level complement to GreenNodePool (which handles
|
|
8
|
+
* structural sharing at the GreenNode level).
|
|
9
|
+
*
|
|
10
|
+
* Architecture:
|
|
11
|
+
* - Keyed by GreenNode._hash (deterministic structural hash)
|
|
12
|
+
* - Bottom-up: children are stored first, then parents reference them
|
|
13
|
+
* - Immutable structural data: once a RedNode is in the store, its `children`
|
|
14
|
+
* array and `green` reference never change
|
|
15
|
+
* - Mutable metadata: the store's RedNode has canonical metadata (derived from
|
|
16
|
+
* green.text). Position-specific metadata lives in PositionRef.localOverlay.
|
|
17
|
+
*
|
|
18
|
+
* Benefits:
|
|
19
|
+
* - Memory deduplication: 100 identical blocks = 1 RedNode + 99 lightweight refs
|
|
20
|
+
* - Render caching: BBCodeCanvas can memoize by canonicalId
|
|
21
|
+
* - Faster incremental parse: unchanged subtrees keep their RedNode identity
|
|
22
|
+
* - HTMLRenderer cache: same canonicalId = same HTML (cache hits)
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { GreenNode } from './GreenNode'
|
|
26
|
+
import { RedNode } from './RedNode'
|
|
27
|
+
import type { NodeKind, NodeMetadata } from '../Types/core'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Derives a node's canonical metadata from its GreenNode.
|
|
31
|
+
*
|
|
32
|
+
* Injected rather than hardcoded: metadata extraction is *language* semantics
|
|
33
|
+
* (what `=#ff0000,#00ff00` means for a `gradient`), and this module sits in the
|
|
34
|
+
* language-agnostic `Syntax` layer. The BBCode implementation lives in
|
|
35
|
+
* `BBCode/BBCodeToGreenNode.ts`; pass it via the constructor.
|
|
36
|
+
*/
|
|
37
|
+
export type MetadataExtractor = (green: GreenNode) => NodeMetadata
|
|
38
|
+
|
|
39
|
+
/** Default for callers with no language semantics to contribute. */
|
|
40
|
+
const NO_METADATA: MetadataExtractor = () => ({})
|
|
41
|
+
|
|
42
|
+
export class RedNodeStore {
|
|
43
|
+
private canonicals = new Map<string, RedNode>()
|
|
44
|
+
private _hits = 0
|
|
45
|
+
private _misses = 0
|
|
46
|
+
private _collisions = 0
|
|
47
|
+
private readonly extractMetadata: MetadataExtractor
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param extractMetadata How to derive canonical metadata from a green node.
|
|
51
|
+
* Defaults to producing none — the store stays language-agnostic unless a
|
|
52
|
+
* caller supplies the mapping.
|
|
53
|
+
*/
|
|
54
|
+
constructor(extractMetadata: MetadataExtractor = NO_METADATA) {
|
|
55
|
+
this.extractMetadata = extractMetadata
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Get or create a canonical RedNode for the given GreenNode.
|
|
60
|
+
*
|
|
61
|
+
* The store is keyed by `green._hash` — a 32-bit FNV structural hash — so a
|
|
62
|
+
* matching key is necessary but NOT sufficient proof of identity. The
|
|
63
|
+
* canonical pipeline (GreenNodePool interning) guarantees that structurally
|
|
64
|
+
* identical greens ARE the same object, which is what makes reference
|
|
65
|
+
* equality a sound discriminator:
|
|
66
|
+
*
|
|
67
|
+
* - same hash + same green reference → HIT, reuse the canonical
|
|
68
|
+
* - same hash + different green → COLLISION: a distinct subtree shares
|
|
69
|
+
* the 32-bit hash. It is never reused as the registered canonical and never
|
|
70
|
+
* overwrites it (that would corrupt every future hit for the registered
|
|
71
|
+
* node). The colliding subtree simply loses canonicalization — rebuilt on
|
|
72
|
+
* each call. Guarding correctness costs a rare dedup miss; the alternative
|
|
73
|
+
* costs a wrong AST.
|
|
74
|
+
* - no hash entry → MISS, create and register
|
|
75
|
+
*
|
|
76
|
+
* Invariant preserved: a hash collision can never make one structure be
|
|
77
|
+
* represented by another structure's RedNode.
|
|
78
|
+
*
|
|
79
|
+
* @param green The GreenNode to wrap
|
|
80
|
+
* @param metadata Optional metadata to set (overrides intrinsic metadata)
|
|
81
|
+
* @param kind Optional kind override
|
|
82
|
+
* @returns The canonical RedNode (shared if already exists)
|
|
83
|
+
*/
|
|
84
|
+
getOrCreate(
|
|
85
|
+
green: GreenNode,
|
|
86
|
+
metadata?: NodeMetadata,
|
|
87
|
+
kind?: NodeKind,
|
|
88
|
+
): RedNode {
|
|
89
|
+
const hash = green._hash.toString()
|
|
90
|
+
const existing = this.canonicals.get(hash)
|
|
91
|
+
if (existing) {
|
|
92
|
+
if (existing.green === green) {
|
|
93
|
+
this._hits++
|
|
94
|
+
return existing
|
|
95
|
+
}
|
|
96
|
+
// Hash collision: different green, same 32-bit hash. Never reuse the
|
|
97
|
+
// registered canonical and never overwrite it — build a fresh node and
|
|
98
|
+
// leave the registered entry untouched.
|
|
99
|
+
this._collisions++
|
|
100
|
+
return this.buildCanonical(green, metadata, kind)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
this._misses++
|
|
104
|
+
const red = this.buildCanonical(green, metadata, kind)
|
|
105
|
+
this.canonicals.set(hash, red)
|
|
106
|
+
return red
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Build a canonical RedNode for `green` WITHOUT registering it.
|
|
111
|
+
*
|
|
112
|
+
* Shared by the miss path (which registers the result) and the collision
|
|
113
|
+
* path (which must not). Both need the same bottom-up construction:
|
|
114
|
+
* children are canonicalized first, then the parent references them.
|
|
115
|
+
*/
|
|
116
|
+
private buildCanonical(
|
|
117
|
+
green: GreenNode,
|
|
118
|
+
metadata?: NodeMetadata,
|
|
119
|
+
kind?: NodeKind,
|
|
120
|
+
): RedNode {
|
|
121
|
+
// First, recursively store children (bottom-up construction)
|
|
122
|
+
const childRedNodes: RedNode[] = []
|
|
123
|
+
for (const childGreen of green.children as GreenNode[]) {
|
|
124
|
+
// Children inherit parent's kind from green, no metadata override
|
|
125
|
+
childRedNodes.push(this.getOrCreate(childGreen))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Create the RedNode with canonical metadata from green.text
|
|
129
|
+
const red = new RedNode(green, {
|
|
130
|
+
kind: kind ?? (green.kind as NodeKind),
|
|
131
|
+
metadata: metadata ?? this.extractMetadata(green),
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
// Populate children (construction-time, outside the mutation lock)
|
|
135
|
+
red.initChildren(childRedNodes)
|
|
136
|
+
return red
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Look up a canonical RedNode by its structural hash.
|
|
141
|
+
*/
|
|
142
|
+
getByHash(hash: string): RedNode | undefined {
|
|
143
|
+
return this.canonicals.get(hash)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Check if a GreenNode already has a canonical RedNode.
|
|
148
|
+
*/
|
|
149
|
+
has(green: GreenNode): boolean {
|
|
150
|
+
return this.canonicals.has(green._hash.toString())
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Get the number of unique canonical nodes.
|
|
155
|
+
*/
|
|
156
|
+
get size(): number {
|
|
157
|
+
return this.canonicals.size
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Get store statistics.
|
|
162
|
+
*/
|
|
163
|
+
get stats() {
|
|
164
|
+
return {
|
|
165
|
+
size: this.canonicals.size,
|
|
166
|
+
hits: this._hits,
|
|
167
|
+
misses: this._misses,
|
|
168
|
+
collisions: this._collisions,
|
|
169
|
+
deduplicationRatio: this._hits > 0
|
|
170
|
+
? (this._hits / (this._hits + this._misses) * 100).toFixed(1) + '%'
|
|
171
|
+
: '0%',
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Clear all canonical nodes.
|
|
177
|
+
*/
|
|
178
|
+
clear(): void {
|
|
179
|
+
this.canonicals.clear()
|
|
180
|
+
this._hits = 0
|
|
181
|
+
this._misses = 0
|
|
182
|
+
this._collisions = 0
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DocumentEngine — TreeBuilder
|
|
3
|
+
*
|
|
4
|
+
* Converts a TokenStream into a GreenNode tree.
|
|
5
|
+
* This is the bridge between the Lexer and the Syntax Tree.
|
|
6
|
+
*
|
|
7
|
+
* The TreeBuilder is language-agnostic — it uses registry-based
|
|
8
|
+
* node factories to build the appropriate tree structure.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { GreenNode, greenNode, greenLeaf } from './GreenNode'
|
|
12
|
+
import { RedNode } from './RedNode'
|
|
13
|
+
import type { TokenStream, Token } from '../Types/tokens'
|
|
14
|
+
import type { NodeKind } from '../Types/core'
|
|
15
|
+
|
|
16
|
+
// ─── Build Result ──────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
export interface BuildResult {
|
|
19
|
+
green: GreenNode
|
|
20
|
+
red: RedNode
|
|
21
|
+
tokens: Token[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ─── Node Factory Interface ────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
export interface NodeFactory {
|
|
27
|
+
/** Create a GreenNode from a token or sequence of tokens */
|
|
28
|
+
createGreen(tokens: Token[], stream: TokenStream): GreenNode | null
|
|
29
|
+
/** Create a RedNode from a green node */
|
|
30
|
+
createRed(green: GreenNode, parent?: RedNode | null): RedNode
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ─── TreeBuilder ───────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
export class TreeBuilder {
|
|
36
|
+
private factories: Map<string, NodeFactory> = new Map()
|
|
37
|
+
private defaultKind: NodeKind = 'unknown'
|
|
38
|
+
|
|
39
|
+
constructor(defaultKind?: NodeKind) {
|
|
40
|
+
if (defaultKind) this.defaultKind = defaultKind
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Register a node factory for a specific token kind or tag name.
|
|
45
|
+
*/
|
|
46
|
+
register(kind: string, factory: NodeFactory): void {
|
|
47
|
+
this.factories.set(kind, factory)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Build a tree from a token stream.
|
|
52
|
+
*/
|
|
53
|
+
build(stream: TokenStream): BuildResult {
|
|
54
|
+
const tokens: Token[] = []
|
|
55
|
+
|
|
56
|
+
// Collect all tokens
|
|
57
|
+
while (!stream.isEof()) {
|
|
58
|
+
tokens.push(stream.advance())
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Build the green tree
|
|
62
|
+
const green = this.buildGreen(tokens)
|
|
63
|
+
|
|
64
|
+
// Build the red tree
|
|
65
|
+
const red = this.buildRed(green)
|
|
66
|
+
|
|
67
|
+
return { green, red, tokens }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Build a green tree from a token array.
|
|
72
|
+
* Uses registered factories to determine node structure.
|
|
73
|
+
*
|
|
74
|
+
* By default, creates a flat document structure.
|
|
75
|
+
* Language-specific builders (like BBCodeParser) override this.
|
|
76
|
+
*/
|
|
77
|
+
buildGreen(tokens: Token[]): GreenNode {
|
|
78
|
+
// Default: create a document node with all tokens as direct children
|
|
79
|
+
const children: GreenNode[] = []
|
|
80
|
+
let start = tokens.length > 0 ? tokens[0].range.start : 0
|
|
81
|
+
let end = tokens.length > 0 ? tokens[tokens.length - 1].range.end : 0
|
|
82
|
+
|
|
83
|
+
for (const token of tokens) {
|
|
84
|
+
// Try registered factories
|
|
85
|
+
let green: GreenNode | null = null
|
|
86
|
+
|
|
87
|
+
if (token.name && this.factories.has(token.name)) {
|
|
88
|
+
green = this.factories.get(token.name)!.createGreen([token], {
|
|
89
|
+
peek: () => token,
|
|
90
|
+
advance: () => token,
|
|
91
|
+
lookAhead: () => token,
|
|
92
|
+
isEof: () => true,
|
|
93
|
+
position: () => 0,
|
|
94
|
+
seek: () => {},
|
|
95
|
+
remaining: () => [],
|
|
96
|
+
getAll: () => [token],
|
|
97
|
+
} as TokenStream)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (!green && this.factories.has(token.kind)) {
|
|
101
|
+
green = this.factories.get(token.kind)!.createGreen([token], {
|
|
102
|
+
peek: () => token,
|
|
103
|
+
advance: () => token,
|
|
104
|
+
lookAhead: () => token,
|
|
105
|
+
isEof: () => true,
|
|
106
|
+
position: () => 0,
|
|
107
|
+
seek: () => {},
|
|
108
|
+
remaining: () => [],
|
|
109
|
+
getAll: () => [token],
|
|
110
|
+
} as TokenStream)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!green) {
|
|
114
|
+
// Default: create a text leaf or tag leaf
|
|
115
|
+
if (token.kind === 'text' || token.kind === 'newline') {
|
|
116
|
+
green = greenLeaf('text', token.text)
|
|
117
|
+
} else if (token.kind === 'open_tag' || token.kind === 'close_tag' || token.kind === 'self_closing_tag') {
|
|
118
|
+
green = greenLeaf(token.name ? `tag:${token.name}` : 'tag', token.text)
|
|
119
|
+
} else {
|
|
120
|
+
green = greenLeaf(token.kind, token.text)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
children.push(green)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return greenNode('document', '', children)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Build a red tree from a green tree.
|
|
132
|
+
*/
|
|
133
|
+
buildRed(green: GreenNode, parent?: RedNode | null, kind?: NodeKind): RedNode {
|
|
134
|
+
const red = new RedNode(green, {
|
|
135
|
+
parent: parent ?? null,
|
|
136
|
+
kind: kind ?? this.defaultKind,
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
for (const childGreen of green.children as GreenNode[]) {
|
|
140
|
+
// Determine child kind based on green node kind
|
|
141
|
+
let childKind: NodeKind = childGreen.kind as NodeKind
|
|
142
|
+
if (childGreen.kind.startsWith('tag:')) {
|
|
143
|
+
const tagName = childGreen.kind.slice(4)
|
|
144
|
+
childKind = this.tagToKind(tagName)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const childRed = this.buildRed(childGreen, red, childKind)
|
|
148
|
+
red.appendChild(childRed)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return red
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Create a RedNode directly from children (for language-specific parsers).
|
|
156
|
+
*/
|
|
157
|
+
createRedFromChildren(
|
|
158
|
+
kind: NodeKind,
|
|
159
|
+
text: string,
|
|
160
|
+
children: RedNode[],
|
|
161
|
+
parent?: RedNode | null,
|
|
162
|
+
): RedNode {
|
|
163
|
+
const green = greenNode(kind, text, children.map(c => c.green))
|
|
164
|
+
const red = new RedNode(green, { parent: parent ?? null, kind })
|
|
165
|
+
for (const child of children) {
|
|
166
|
+
child.parent = red
|
|
167
|
+
red.appendChild(child)
|
|
168
|
+
}
|
|
169
|
+
return red
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ─── Tag to Kind Mapping ────────────────────────────────
|
|
173
|
+
|
|
174
|
+
private tagToKind(tag: string): NodeKind {
|
|
175
|
+
const map: Record<string, NodeKind> = {
|
|
176
|
+
'b': 'bold',
|
|
177
|
+
'i': 'italic',
|
|
178
|
+
'u': 'underline',
|
|
179
|
+
's': 'strikethrough',
|
|
180
|
+
'strike': 'strikethrough',
|
|
181
|
+
'color': 'color',
|
|
182
|
+
'size': 'font_size',
|
|
183
|
+
'font': 'font',
|
|
184
|
+
'c': 'inline_code',
|
|
185
|
+
'code': 'code',
|
|
186
|
+
'spoiler': 'spoiler',
|
|
187
|
+
'centre': 'center',
|
|
188
|
+
'right': 'right',
|
|
189
|
+
'left': 'left',
|
|
190
|
+
'center': 'center',
|
|
191
|
+
'url': 'url',
|
|
192
|
+
'email': 'email',
|
|
193
|
+
'profile': 'profile',
|
|
194
|
+
'img': 'image',
|
|
195
|
+
'youtube': 'video',
|
|
196
|
+
'audio': 'audio',
|
|
197
|
+
'imagemap': 'imagemap',
|
|
198
|
+
'quote': 'quote',
|
|
199
|
+
'notice': 'notice',
|
|
200
|
+
'spoilerbox': 'spoilerbox',
|
|
201
|
+
'box': 'box',
|
|
202
|
+
'boxw': 'boxw',
|
|
203
|
+
'list': 'list',
|
|
204
|
+
'*': 'list_item',
|
|
205
|
+
'heading': 'heading',
|
|
206
|
+
'zalgo': 'zalgo',
|
|
207
|
+
'aesthetic': 'aesthetic',
|
|
208
|
+
'sparkle': 'sparkle',
|
|
209
|
+
'bubble': 'bubble',
|
|
210
|
+
'flower': 'flower',
|
|
211
|
+
}
|
|
212
|
+
return map[tag] ?? 'custom'
|
|
213
|
+
}
|
|
214
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { greenNode, greenLeaf } from '../GreenNode'
|
|
3
|
+
|
|
4
|
+
describe('GreenNode', () => {
|
|
5
|
+
it('creates a green node with correct properties', () => {
|
|
6
|
+
const leaf1 = greenLeaf('Text', 'hello ')
|
|
7
|
+
const leaf2 = greenLeaf('Text', 'world')
|
|
8
|
+
const node = greenNode('Paragraph', '', [leaf1, leaf2])
|
|
9
|
+
|
|
10
|
+
expect(node.kind).toBe('Paragraph')
|
|
11
|
+
expect(node.text).toBe('')
|
|
12
|
+
expect(node.width).toBe(11)
|
|
13
|
+
expect(node.childCount).toBe(2)
|
|
14
|
+
expect(node.isLeaf).toBe(false)
|
|
15
|
+
|
|
16
|
+
expect(leaf1.isLeaf).toBe(true)
|
|
17
|
+
expect(leaf1.text).toBe('hello ')
|
|
18
|
+
expect(leaf1.width).toBe(6)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('is immutable', () => {
|
|
22
|
+
const leaf1 = greenLeaf('Text', 'hello')
|
|
23
|
+
const leaf2 = greenLeaf('Text', 'world')
|
|
24
|
+
const input = [leaf1]
|
|
25
|
+
const node = greenNode('Paragraph', '', input)
|
|
26
|
+
|
|
27
|
+
// Children should be a copy, not the same reference as input
|
|
28
|
+
expect(node.children).not.toBe(input)
|
|
29
|
+
// But should contain the same elements
|
|
30
|
+
expect(node.children.length).toBe(1)
|
|
31
|
+
expect(node.children[0]).toBe(leaf1)
|
|
32
|
+
})
|
|
33
|
+
})
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { RedNode } from '../RedNode'
|
|
3
|
+
import { greenNode, greenLeaf } from '../GreenNode'
|
|
4
|
+
|
|
5
|
+
describe('RedNode', () => {
|
|
6
|
+
it('creates a red node from a green node', () => {
|
|
7
|
+
const green = greenLeaf('Text', 'hello')
|
|
8
|
+
const red = new RedNode(green)
|
|
9
|
+
|
|
10
|
+
expect(red.kind).toBe('Text')
|
|
11
|
+
expect(red.text).toBe('hello')
|
|
12
|
+
expect(red.version).toBe(1)
|
|
13
|
+
expect(red.id).toBeDefined()
|
|
14
|
+
expect(red.parent).toBeNull()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('supports tree navigation', () => {
|
|
18
|
+
const rootGreen = greenNode('Root', '')
|
|
19
|
+
const childGreen = greenLeaf('Text', 'hello')
|
|
20
|
+
|
|
21
|
+
const root = new RedNode(rootGreen)
|
|
22
|
+
const child = new RedNode(childGreen)
|
|
23
|
+
|
|
24
|
+
RedNode.allowMutation(() => {
|
|
25
|
+
root.appendChild(child)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
expect(child.parent).toBe(root)
|
|
29
|
+
expect(root.childCount).toBe(1)
|
|
30
|
+
expect(root.childAt(0)).toBe(child)
|
|
31
|
+
expect(child.root).toBe(root)
|
|
32
|
+
expect(child.depth).toBe(1)
|
|
33
|
+
expect(root.depth).toBe(0)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('supports mutation and bumps version', () => {
|
|
37
|
+
const rootGreen = greenNode('Root', '')
|
|
38
|
+
const childGreen = greenLeaf('Text', 'hello')
|
|
39
|
+
|
|
40
|
+
const root = new RedNode(rootGreen)
|
|
41
|
+
const child = new RedNode(childGreen)
|
|
42
|
+
|
|
43
|
+
expect(root.version).toBe(1)
|
|
44
|
+
|
|
45
|
+
RedNode.allowMutation(() => {
|
|
46
|
+
root.appendChild(child)
|
|
47
|
+
})
|
|
48
|
+
expect(root.version).toBe(2)
|
|
49
|
+
|
|
50
|
+
RedNode.allowMutation(() => {
|
|
51
|
+
root.removeChild(child.id)
|
|
52
|
+
})
|
|
53
|
+
expect(root.version).toBe(3)
|
|
54
|
+
expect(root.childCount).toBe(0)
|
|
55
|
+
expect(child.parent).toBeNull()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('throws an error if mutation methods are called without an active transaction or lock', () => {
|
|
59
|
+
const rootGreen = greenNode('Root', '')
|
|
60
|
+
const childGreen = greenLeaf('Text', 'hello')
|
|
61
|
+
|
|
62
|
+
const root = new RedNode(rootGreen)
|
|
63
|
+
const child = new RedNode(childGreen)
|
|
64
|
+
|
|
65
|
+
expect(() => {
|
|
66
|
+
root.appendChild(child)
|
|
67
|
+
}).toThrow(/mutation boundary/)
|
|
68
|
+
|
|
69
|
+
RedNode.allowMutation(() => {
|
|
70
|
+
root.appendChild(child)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
expect(() => {
|
|
74
|
+
root.removeChild(child.id)
|
|
75
|
+
}).toThrow(/mutation boundary/)
|
|
76
|
+
|
|
77
|
+
expect(() => {
|
|
78
|
+
root.bumpVersion()
|
|
79
|
+
}).toThrow(/mutation boundary/)
|
|
80
|
+
})
|
|
81
|
+
})
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { greenLeaf } from '../GreenNode'
|
|
3
|
+
import { RedNodeStore } from '../RedNodeStore'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Forge a deterministic FNV-32 collision for a test green node.
|
|
7
|
+
*
|
|
8
|
+
* `GreenNode._hash` is a lazy getter over `_hashCache` (computed once, then
|
|
9
|
+
* memoized). The store keys purely on `green._hash`, so forcing two
|
|
10
|
+
* structurally DIFFERENT greens to the same cached hash is a faithful
|
|
11
|
+
* simulation of a real 32-bit hash collision — the store cannot and should
|
|
12
|
+
* not distinguish how the collision arose.
|
|
13
|
+
*/
|
|
14
|
+
function forceHash(green: ReturnType<typeof greenLeaf>, hash: number): void {
|
|
15
|
+
;(green as unknown as { _hashCache: number })._hashCache = hash
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('RedNodeStore', () => {
|
|
19
|
+
it('canonicalizes: same green reference reuses the same RedNode', () => {
|
|
20
|
+
const store = new RedNodeStore()
|
|
21
|
+
const green = greenLeaf('Text', 'hello')
|
|
22
|
+
|
|
23
|
+
const first = store.getOrCreate(green)
|
|
24
|
+
const second = store.getOrCreate(green)
|
|
25
|
+
|
|
26
|
+
expect(second).toBe(first)
|
|
27
|
+
expect(store.size).toBe(1)
|
|
28
|
+
expect(store.stats.hits).toBe(1)
|
|
29
|
+
expect(store.stats.misses).toBe(1)
|
|
30
|
+
expect(store.stats.collisions).toBe(0)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('treats a hash collision as neither a hit nor an overwrite', () => {
|
|
34
|
+
const store = new RedNodeStore()
|
|
35
|
+
|
|
36
|
+
// Two genuinely different structures that share a hash.
|
|
37
|
+
const greenA = greenLeaf('Text', 'alpha')
|
|
38
|
+
const greenB = greenLeaf('Text', 'bravo')
|
|
39
|
+
expect(greenA).not.toBe(greenB)
|
|
40
|
+
expect(greenA.text).not.toBe(greenB.text)
|
|
41
|
+
|
|
42
|
+
const COLLISION_HASH = 0xdeadbeef
|
|
43
|
+
forceHash(greenA, COLLISION_HASH)
|
|
44
|
+
forceHash(greenB, COLLISION_HASH)
|
|
45
|
+
expect(greenA._hash).toBe(greenB._hash)
|
|
46
|
+
|
|
47
|
+
const canonicalA = store.getOrCreate(greenA)
|
|
48
|
+
expect(store.stats.misses).toBe(1)
|
|
49
|
+
|
|
50
|
+
// The colliding green must NOT be answered with A's canonical...
|
|
51
|
+
const fromB = store.getOrCreate(greenB)
|
|
52
|
+
expect(fromB).not.toBe(canonicalA)
|
|
53
|
+
// ...and the returned node must wrap B, never A.
|
|
54
|
+
expect(fromB.green).toBe(greenB)
|
|
55
|
+
|
|
56
|
+
// The registered entry under the hash is still A — never overwritten.
|
|
57
|
+
expect(store.getByHash(COLLISION_HASH.toString())).toBe(canonicalA)
|
|
58
|
+
expect(store.stats.collisions).toBe(1)
|
|
59
|
+
expect(store.size).toBe(1)
|
|
60
|
+
|
|
61
|
+
// A still canonicalizes normally after the collision.
|
|
62
|
+
expect(store.getOrCreate(greenA)).toBe(canonicalA)
|
|
63
|
+
expect(store.stats.hits).toBe(1)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('does not register the colliding green (no canonicalization for it)', () => {
|
|
67
|
+
const store = new RedNodeStore()
|
|
68
|
+
|
|
69
|
+
const greenA = greenLeaf('Tag', 'same-hash-a')
|
|
70
|
+
const greenB = greenLeaf('Tag', 'same-hash-b')
|
|
71
|
+
forceHash(greenA, 42)
|
|
72
|
+
forceHash(greenB, 42)
|
|
73
|
+
|
|
74
|
+
store.getOrCreate(greenA)
|
|
75
|
+
const b1 = store.getOrCreate(greenB)
|
|
76
|
+
const b2 = store.getOrCreate(greenB)
|
|
77
|
+
|
|
78
|
+
// v1 semantic: a colliding subtree is rebuilt on each request rather than
|
|
79
|
+
// risk returning the wrong canonical.
|
|
80
|
+
expect(b1).not.toBe(b2)
|
|
81
|
+
expect(b1.green).toBe(greenB)
|
|
82
|
+
expect(b2.green).toBe(greenB)
|
|
83
|
+
expect(store.stats.collisions).toBe(2)
|
|
84
|
+
expect(store.size).toBe(1)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('resets collision stats on clear', () => {
|
|
88
|
+
const store = new RedNodeStore()
|
|
89
|
+
const greenA = greenLeaf('Text', 'x')
|
|
90
|
+
const greenB = greenLeaf('Text', 'y')
|
|
91
|
+
forceHash(greenA, 7)
|
|
92
|
+
forceHash(greenB, 7)
|
|
93
|
+
|
|
94
|
+
store.getOrCreate(greenA)
|
|
95
|
+
store.getOrCreate(greenB)
|
|
96
|
+
expect(store.stats.collisions).toBe(1)
|
|
97
|
+
|
|
98
|
+
store.clear()
|
|
99
|
+
expect(store.size).toBe(0)
|
|
100
|
+
expect(store.stats.hits).toBe(0)
|
|
101
|
+
expect(store.stats.misses).toBe(0)
|
|
102
|
+
expect(store.stats.collisions).toBe(0)
|
|
103
|
+
})
|
|
104
|
+
})
|