@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,506 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DocumentEngine — SemanticAnalyzer
|
|
3
|
+
*
|
|
4
|
+
* Walks the Red Tree and produces diagnostics by applying
|
|
5
|
+
* semantic rules to the syntax tree.
|
|
6
|
+
*
|
|
7
|
+
* This is where language-specific validation happens.
|
|
8
|
+
* The analyzer is extensible via registered validators.
|
|
9
|
+
*
|
|
10
|
+
* Inspired by Roslyn's Semantic Model and LSP diagnostics.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { RedNode } from '../Syntax/RedNode'
|
|
14
|
+
import type { NodeKind } from '../Types/core'
|
|
15
|
+
import type {
|
|
16
|
+
Diagnostic,
|
|
17
|
+
DiagnosticSeverity,
|
|
18
|
+
DiagnosticCollection,
|
|
19
|
+
} from '../Types/diagnostics'
|
|
20
|
+
import {
|
|
21
|
+
createDiagnosticCollection,
|
|
22
|
+
createDiagnostic,
|
|
23
|
+
addDiagnostic,
|
|
24
|
+
} from '../Types/diagnostics'
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Run one validator and file whatever it returns, both on the collection and on
|
|
28
|
+
* the node itself.
|
|
29
|
+
*
|
|
30
|
+
* Attaching here is the point: a diagnostic is produced while its own node is
|
|
31
|
+
* in hand, so pairing them later — by building an id→node Map of the whole
|
|
32
|
+
* document and looking each one up — was solving a problem that only existed
|
|
33
|
+
* because the two steps had been separated.
|
|
34
|
+
*
|
|
35
|
+
* The `try` is per validator, deliberately: one that throws must not take the
|
|
36
|
+
* rest of the analysis down with it.
|
|
37
|
+
*/
|
|
38
|
+
function runValidator(
|
|
39
|
+
validator: Validator,
|
|
40
|
+
node: RedNode,
|
|
41
|
+
context: AnalyzerContext,
|
|
42
|
+
diagnostics: DiagnosticCollection,
|
|
43
|
+
): void {
|
|
44
|
+
try {
|
|
45
|
+
const result = validator.validate(node, context)
|
|
46
|
+
if (result === null || result === undefined) return
|
|
47
|
+
if (Array.isArray(result)) {
|
|
48
|
+
for (let i = 0; i < result.length; i++) {
|
|
49
|
+
addDiagnostic(diagnostics, result[i])
|
|
50
|
+
node.diagnostics.push(result[i])
|
|
51
|
+
}
|
|
52
|
+
} else {
|
|
53
|
+
addDiagnostic(diagnostics, result)
|
|
54
|
+
node.diagnostics.push(result)
|
|
55
|
+
}
|
|
56
|
+
} catch (error) {
|
|
57
|
+
// Validator error should not break the analysis
|
|
58
|
+
console.warn(`[DocumentEngine] Validator ${validator.code} error:`, error)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ─── Validator Interface ───────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
export interface Validator {
|
|
65
|
+
/** Unique code for this validator (e.g. 'invalid-color') */
|
|
66
|
+
code: string
|
|
67
|
+
/** Severity of issues found by this validator */
|
|
68
|
+
severity: DiagnosticSeverity
|
|
69
|
+
/**
|
|
70
|
+
* Node kinds this validator can ever fire on. Omit to run on every node.
|
|
71
|
+
*
|
|
72
|
+
* Three of the five built-ins open with nothing but a kind test, so on a
|
|
73
|
+
* document of 1736 nodes they were called 1736 times each to answer a
|
|
74
|
+
* question the dispatcher can answer once. Declaring the kinds turns the
|
|
75
|
+
* call into a lookup that never happens.
|
|
76
|
+
*/
|
|
77
|
+
kinds?: readonly string[]
|
|
78
|
+
/** Validate a node. Return diagnostics or null */
|
|
79
|
+
validate(node: RedNode, context: AnalyzerContext): Diagnostic | Diagnostic[] | null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface AnalyzerContext {
|
|
83
|
+
/**
|
|
84
|
+
* Every node in the tree, by id, for cross-reference validation.
|
|
85
|
+
*
|
|
86
|
+
* A getter, and deliberately: building this Map cost a second full walk of
|
|
87
|
+
* the tree plus 1736 `Map.set` calls — 23% of `analyze()` — and **no
|
|
88
|
+
* validator has ever read it**. It was built so that the caller could look up
|
|
89
|
+
* by id the node to attach each diagnostic to, which is the node the
|
|
90
|
+
* validator was looking at when it produced it. Validators that genuinely
|
|
91
|
+
* need cross-references still get it; everyone else stops paying for it.
|
|
92
|
+
*/
|
|
93
|
+
readonly allNodes: Map<string, RedNode>
|
|
94
|
+
/** Previously collected diagnostics */
|
|
95
|
+
diagnostics: DiagnosticCollection
|
|
96
|
+
/** Source text for position lookups */
|
|
97
|
+
source: string
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ─── Analyze Result ────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
export interface AnalyzeResult {
|
|
103
|
+
diagnostics: DiagnosticCollection
|
|
104
|
+
/** Time taken in ms */
|
|
105
|
+
duration: number
|
|
106
|
+
/** Number of nodes analyzed */
|
|
107
|
+
nodesAnalyzed: number
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* What `analyze()` actually returns: an {@link AnalyzeResult} plus the node
|
|
112
|
+
* index it had to build anyway for cross-reference lookups.
|
|
113
|
+
*
|
|
114
|
+
* Kept as a separate type, and deliberately NOT part of `AnalyzeResult`,
|
|
115
|
+
* because the index holds a strong reference to every node in the tree. A
|
|
116
|
+
* caller that stores an `AnalyzeResult` long-term (as `DocumentModel` does)
|
|
117
|
+
* must not pin an entire stale tree; one that needs the index gets it here and
|
|
118
|
+
* owns that decision explicitly.
|
|
119
|
+
*/
|
|
120
|
+
export interface IndexedAnalyzeResult extends AnalyzeResult {
|
|
121
|
+
allNodes: Map<string, RedNode>
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ─── Validator helpers ─────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Tags that still work but have a preferred modern spelling.
|
|
128
|
+
*
|
|
129
|
+
* `kind` is not redundant. Several nodes can share a starting offset — a
|
|
130
|
+
* `document`, the `paragraph` inside it and the tag itself all begin at 0 for
|
|
131
|
+
* `[strike]x[/strike]` — so reading the source at that offset alone reports the
|
|
132
|
+
* same tag three times. Requiring the node's kind to be the one the tag
|
|
133
|
+
* produces pins the diagnostic to the node that actually is that tag.
|
|
134
|
+
*/
|
|
135
|
+
const DEPRECATED_TAGS: Record<string, { kind: NodeKind, message: string }> = {
|
|
136
|
+
strike: { kind: 'strikethrough', message: 'Use [s] instead of [strike]' },
|
|
137
|
+
center: { kind: 'center', message: 'Use [centre] instead of [center]' },
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The kinds any deprecated spelling can produce.
|
|
142
|
+
*
|
|
143
|
+
* Cheap pre-filter: the validator runs on every node of every parse, and
|
|
144
|
+
* reading the source back is only meaningful for the handful of kinds a
|
|
145
|
+
* deprecated tag can even yield. Testing the kind first keeps that work off the
|
|
146
|
+
* keystroke path — measured at +14% on `analyze` without it.
|
|
147
|
+
*/
|
|
148
|
+
const DEPRECATED_KINDS = new Set<NodeKind>(
|
|
149
|
+
Object.values(DEPRECATED_TAGS).map(entry => entry.kind),
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
/** Matches anything shaped like a BBCode tag. */
|
|
153
|
+
const BBCODE_TAG_RE = /\[\/?[a-zA-Z0-9_*-]+(?:=[^\]]*)?\]/
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Reads the tag name at the start of a node's range: `[quote="x"]` → `quote`.
|
|
157
|
+
*
|
|
158
|
+
* Sticky (`y`) so it can be anchored at an arbitrary offset via `lastIndex`.
|
|
159
|
+
* The obvious `source.slice(start, start + 32)` allocates a string for every
|
|
160
|
+
* tag node of every parse; this matches in place.
|
|
161
|
+
*/
|
|
162
|
+
const OPENING_TAG_RE = /\[\/?([a-zA-Z0-9_*-]+)/y
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Kinds that are not written tags, so no closing-tag rule applies to them.
|
|
166
|
+
*
|
|
167
|
+
* The check below reads the source at a node's boundaries, and several nodes
|
|
168
|
+
* can share an offset — a `paragraph` wrapping `[b]x` starts at the same `[` as
|
|
169
|
+
* the `bold` inside it, and would otherwise be judged as an unclosed `[b]`.
|
|
170
|
+
* `list_item` is here because `[*]` has no closing form in BBCode at all.
|
|
171
|
+
*/
|
|
172
|
+
const NOT_A_WRITTEN_TAG = new Set<NodeKind>([
|
|
173
|
+
'document', 'paragraph', 'group', 'text', 'spacing', 'empty_line', 'list_item', 'error',
|
|
174
|
+
])
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Whether the parser had to close this tag itself because the author never did.
|
|
178
|
+
*
|
|
179
|
+
* Exact, not heuristic. A tag the author closed ends exactly at its own
|
|
180
|
+
* `[/tag]`, because that is where the parser sets the node's end. A tag closed
|
|
181
|
+
* *for* the author ends wherever the parser gave up — at the end of the
|
|
182
|
+
* document, or at the mismatched `[/other]` that forced the issue — and the
|
|
183
|
+
* text up to that point does not end in its closing form.
|
|
184
|
+
*
|
|
185
|
+
* That single rule covers both shapes: `[b]x` (never closed) and `[b][i]x[/b]`
|
|
186
|
+
* (where `[i]` is auto-closed by the legacy nesting rules).
|
|
187
|
+
*/
|
|
188
|
+
function isUnclosedTag(node: RedNode, source: string): boolean {
|
|
189
|
+
if (NOT_A_WRITTEN_TAG.has(node.kind)) return false
|
|
190
|
+
|
|
191
|
+
const name = openingTagName(node, source)
|
|
192
|
+
if (!name || name === '*') return false
|
|
193
|
+
|
|
194
|
+
return !endsWithClosingTag(source, node.range.end, name)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The tag name as the author actually spelled it, read back from the source.
|
|
199
|
+
*
|
|
200
|
+
* The tree cannot answer this. Several spellings collapse onto one `NodeKind`
|
|
201
|
+
* — `[strike]` and `[s]` both become `strikethrough` — and a node's `text`
|
|
202
|
+
* holds its attributes, not its name. A node's range does start at the opening
|
|
203
|
+
* bracket, so the name is the identifier immediately after it.
|
|
204
|
+
*
|
|
205
|
+
* Returns null for nodes that do not correspond to a written tag.
|
|
206
|
+
*/
|
|
207
|
+
function openingTagName(node: RedNode, source: string): string | null {
|
|
208
|
+
const { start } = node.range
|
|
209
|
+
if (start < 0 || start >= source.length || source.charCodeAt(start) !== 0x5b /* [ */) return null
|
|
210
|
+
OPENING_TAG_RE.lastIndex = start
|
|
211
|
+
const match = OPENING_TAG_RE.exec(source)
|
|
212
|
+
return match ? match[1].toLowerCase() : null
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Does `source` end with `[/name]` at offset `end`?
|
|
217
|
+
*
|
|
218
|
+
* Compared in place rather than via `slice().toLowerCase()`: this runs for
|
|
219
|
+
* every tag node on the keystroke path, and two throwaway strings per node adds
|
|
220
|
+
* up. ASCII case folding is `| 32`, which is why the letter range is checked
|
|
221
|
+
* first — folding a digit or `_` would corrupt it.
|
|
222
|
+
*/
|
|
223
|
+
function endsWithClosingTag(source: string, end: number, name: string): boolean {
|
|
224
|
+
const start = end - name.length - 3
|
|
225
|
+
if (start < 0) return false
|
|
226
|
+
if (source.charCodeAt(start) !== 0x5b /* [ */) return false
|
|
227
|
+
if (source.charCodeAt(start + 1) !== 0x2f /* / */) return false
|
|
228
|
+
if (source.charCodeAt(end - 1) !== 0x5d /* ] */) return false
|
|
229
|
+
|
|
230
|
+
for (let i = 0; i < name.length; i++) {
|
|
231
|
+
let c = source.charCodeAt(start + 2 + i)
|
|
232
|
+
if (c >= 0x41 && c <= 0x5a) c |= 32 // A-Z → a-z; `name` is already lowercase
|
|
233
|
+
if (c !== name.charCodeAt(i)) return false
|
|
234
|
+
}
|
|
235
|
+
return true
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ─── SemanticAnalyzer ──────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
export class SemanticAnalyzer {
|
|
241
|
+
private validators: Map<string, Validator> = new Map()
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The same validators, arranged for the walk instead of for lookup.
|
|
245
|
+
*
|
|
246
|
+
* `_always` run on every node; `_byKind` are the ones that declared their
|
|
247
|
+
* kinds. Iterating the Map itself allocated an iterator and a destructuring
|
|
248
|
+
* pair per node — 9% of `analyze()` spent on bookkeeping, not on validating.
|
|
249
|
+
*
|
|
250
|
+
* Rebuilt on register/unregister, which happen once at construction and
|
|
251
|
+
* essentially never afterwards.
|
|
252
|
+
*/
|
|
253
|
+
private _always: Validator[] = []
|
|
254
|
+
private _byKind: Map<string, Validator[]> = new Map()
|
|
255
|
+
|
|
256
|
+
constructor() {
|
|
257
|
+
this.registerBuiltinValidators()
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Register a validator.
|
|
262
|
+
*/
|
|
263
|
+
register(validator: Validator): void {
|
|
264
|
+
this.validators.set(validator.code, validator)
|
|
265
|
+
this.rebuildDispatch()
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Remove a validator.
|
|
270
|
+
*/
|
|
271
|
+
unregister(code: string): void {
|
|
272
|
+
this.validators.delete(code)
|
|
273
|
+
this.rebuildDispatch()
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private rebuildDispatch(): void {
|
|
277
|
+
this._always = []
|
|
278
|
+
this._byKind = new Map()
|
|
279
|
+
for (const validator of this.validators.values()) {
|
|
280
|
+
if (validator.kinds === undefined) {
|
|
281
|
+
this._always.push(validator)
|
|
282
|
+
continue
|
|
283
|
+
}
|
|
284
|
+
for (const kind of validator.kinds) {
|
|
285
|
+
const list = this._byKind.get(kind)
|
|
286
|
+
if (list) list.push(validator)
|
|
287
|
+
else this._byKind.set(kind, [validator])
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Analyze a Red Tree and produce diagnostics.
|
|
294
|
+
*/
|
|
295
|
+
analyze(root: RedNode, source: string): IndexedAnalyzeResult {
|
|
296
|
+
const startTime = performance.now()
|
|
297
|
+
const diagnostics = createDiagnosticCollection()
|
|
298
|
+
let nodesAnalyzed = 0
|
|
299
|
+
|
|
300
|
+
// `allNodes` is a getter so the Map is only built if a validator asks for
|
|
301
|
+
// it — see the note on `AnalyzerContext.allNodes`. `root` is captured, so
|
|
302
|
+
// the walk that builds it happens at most once per analyze.
|
|
303
|
+
let allNodesCache: Map<string, RedNode> | null = null
|
|
304
|
+
const context: AnalyzerContext = {
|
|
305
|
+
get allNodes(): Map<string, RedNode> {
|
|
306
|
+
if (allNodesCache === null) {
|
|
307
|
+
allNodesCache = new Map<string, RedNode>()
|
|
308
|
+
root.walk(node => { allNodesCache!.set(node.id, node) })
|
|
309
|
+
}
|
|
310
|
+
return allNodesCache
|
|
311
|
+
},
|
|
312
|
+
diagnostics,
|
|
313
|
+
source,
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const always = this._always
|
|
317
|
+
const byKind = this._byKind
|
|
318
|
+
|
|
319
|
+
root.walk(node => {
|
|
320
|
+
nodesAnalyzed++
|
|
321
|
+
|
|
322
|
+
// Clear here rather than in a pass of its own, and only when there is
|
|
323
|
+
// something to clear: a fresh `[]` per node meant an allocation for every
|
|
324
|
+
// node in the document, and almost none of them carry diagnostics.
|
|
325
|
+
if (node.diagnostics.length > 0) node.diagnostics = []
|
|
326
|
+
|
|
327
|
+
const specific = byKind.get(node.kind)
|
|
328
|
+
for (let i = 0; i < always.length; i++) {
|
|
329
|
+
runValidator(always[i], node, context, diagnostics)
|
|
330
|
+
}
|
|
331
|
+
if (specific !== undefined) {
|
|
332
|
+
for (let i = 0; i < specific.length; i++) {
|
|
333
|
+
runValidator(specific[i], node, context, diagnostics)
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
const duration = performance.now() - startTime
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
diagnostics,
|
|
342
|
+
duration,
|
|
343
|
+
nodesAnalyzed,
|
|
344
|
+
get allNodes(): Map<string, RedNode> { return context.allNodes },
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ─── Built-in Validators ─────────────────────────────────
|
|
349
|
+
|
|
350
|
+
private registerBuiltinValidators(): void {
|
|
351
|
+
// Unknown tag validator
|
|
352
|
+
this.register({
|
|
353
|
+
code: 'unknown-tag',
|
|
354
|
+
severity: 'warning',
|
|
355
|
+
kinds: ['custom'],
|
|
356
|
+
validate: (node) => {
|
|
357
|
+
if (node.kind === 'custom' && node.green.isLeaf) {
|
|
358
|
+
return createDiagnostic(
|
|
359
|
+
'unknown-tag',
|
|
360
|
+
`Unknown BBCode tag: [${node.text}]`,
|
|
361
|
+
'warning',
|
|
362
|
+
{ nodeId: node.id, nodeKind: node.kind, range: node.range },
|
|
363
|
+
)
|
|
364
|
+
}
|
|
365
|
+
return null
|
|
366
|
+
},
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
// Deprecated tag validator
|
|
370
|
+
this.register({
|
|
371
|
+
code: 'deprecated-tag',
|
|
372
|
+
severity: 'info',
|
|
373
|
+
// Same set the validator's own first line tests, hoisted into dispatch.
|
|
374
|
+
kinds: [...DEPRECATED_KINDS],
|
|
375
|
+
validate: (node, ctx) => {
|
|
376
|
+
// Looked up `deprecated[node.text]` before, which could never match:
|
|
377
|
+
// `node.text` holds the tag's *attributes*, not its name. And the name
|
|
378
|
+
// is not on the node either — `[strike]` and `[s]` both parse to kind
|
|
379
|
+
// `strikethrough`, so the spelling the author used only survives in the
|
|
380
|
+
// source. The node's range points at the opening bracket, so read it
|
|
381
|
+
// back from there.
|
|
382
|
+
if (!DEPRECATED_KINDS.has(node.kind)) return null
|
|
383
|
+
|
|
384
|
+
const spelling = openingTagName(node, ctx.source)
|
|
385
|
+
if (!spelling) return null
|
|
386
|
+
|
|
387
|
+
const found = DEPRECATED_TAGS[spelling]
|
|
388
|
+
if (!found || found.kind !== node.kind) return null
|
|
389
|
+
|
|
390
|
+
return createDiagnostic(
|
|
391
|
+
'deprecated-tag',
|
|
392
|
+
found.message,
|
|
393
|
+
'info',
|
|
394
|
+
{ nodeId: node.id, nodeKind: node.kind, range: node.range, tags: ['deprecated'] },
|
|
395
|
+
)
|
|
396
|
+
},
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
// Empty tag validator
|
|
400
|
+
// Excludes structural kinds that are intentionally contentless (empty_line, spacing)
|
|
401
|
+
this.register({
|
|
402
|
+
code: 'empty-tag',
|
|
403
|
+
severity: 'hint',
|
|
404
|
+
validate: (node) => {
|
|
405
|
+
if (
|
|
406
|
+
node.children.length === 0 &&
|
|
407
|
+
node.text === '' &&
|
|
408
|
+
node.kind !== 'text' &&
|
|
409
|
+
node.kind !== 'empty_line' &&
|
|
410
|
+
node.kind !== 'spacing'
|
|
411
|
+
) {
|
|
412
|
+
return createDiagnostic(
|
|
413
|
+
'empty-tag',
|
|
414
|
+
`Empty tag: ${node.kind}`,
|
|
415
|
+
'hint',
|
|
416
|
+
{ nodeId: node.id, nodeKind: node.kind, range: node.range, tags: ['unnecessary'] },
|
|
417
|
+
)
|
|
418
|
+
}
|
|
419
|
+
return null
|
|
420
|
+
},
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
// Unclosed tag validator
|
|
424
|
+
//
|
|
425
|
+
// The one BBCode mistake people actually make. The legacy parser closes
|
|
426
|
+
// these silently by design — the preview still looks plausible — so without
|
|
427
|
+
// a diagnostic there is nothing anywhere telling the author a tag is
|
|
428
|
+
// missing.
|
|
429
|
+
this.register({
|
|
430
|
+
code: 'unclosed-tag',
|
|
431
|
+
severity: 'warning',
|
|
432
|
+
validate: (node, ctx) => {
|
|
433
|
+
if (!isUnclosedTag(node, ctx.source)) return null
|
|
434
|
+
|
|
435
|
+
const name = openingTagName(node, ctx.source)
|
|
436
|
+
return createDiagnostic(
|
|
437
|
+
'unclosed-tag',
|
|
438
|
+
`Missing [/${name}] — the tag was closed automatically`,
|
|
439
|
+
'warning',
|
|
440
|
+
{ nodeId: node.id, nodeKind: node.kind, range: node.range },
|
|
441
|
+
)
|
|
442
|
+
},
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
// Potentially nested structure validator
|
|
446
|
+
this.register({
|
|
447
|
+
code: 'nested-structure',
|
|
448
|
+
kinds: ['code', 'inline_code'],
|
|
449
|
+
severity: 'warning',
|
|
450
|
+
validate: (node) => {
|
|
451
|
+
if (node.kind !== 'code' && node.kind !== 'inline_code') return null
|
|
452
|
+
|
|
453
|
+
// Instead of underlining the entire [code] block, we find the exact
|
|
454
|
+
// positions of the tags inside the text and yield a diagnostic for each.
|
|
455
|
+
const diagnostics: Diagnostic[] = []
|
|
456
|
+
const regex = /\[\/?[a-zA-Z0-9_*-]+(?:=[^\]]*)?\]/g
|
|
457
|
+
|
|
458
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
459
|
+
const child = node.children[i]
|
|
460
|
+
if (child.kind !== 'text') continue
|
|
461
|
+
|
|
462
|
+
let match: RegExpExecArray | null
|
|
463
|
+
while ((match = regex.exec(child.text)) !== null) {
|
|
464
|
+
const start = child.range.start + match.index
|
|
465
|
+
const end = start + match[0].length
|
|
466
|
+
|
|
467
|
+
diagnostics.push(createDiagnostic(
|
|
468
|
+
'nested-tags-in-code',
|
|
469
|
+
'BBCode tags inside [code] blocks are not rendered by osu!',
|
|
470
|
+
'warning',
|
|
471
|
+
{ nodeId: node.id, nodeKind: node.kind, range: { start, end } },
|
|
472
|
+
))
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return diagnostics.length > 0 ? diagnostics : null
|
|
477
|
+
},
|
|
478
|
+
})
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Create a validator for a specific tag/kind.
|
|
483
|
+
* Convenience method for plugin authors.
|
|
484
|
+
*/
|
|
485
|
+
createValidator(
|
|
486
|
+
code: string,
|
|
487
|
+
severity: DiagnosticSeverity,
|
|
488
|
+
predicate: (node: RedNode, ctx: AnalyzerContext) => string | null,
|
|
489
|
+
): Validator {
|
|
490
|
+
return {
|
|
491
|
+
code,
|
|
492
|
+
severity,
|
|
493
|
+
validate: (node, ctx) => {
|
|
494
|
+
const message = predicate(node, ctx)
|
|
495
|
+
if (message) {
|
|
496
|
+
return createDiagnostic(code, message, severity, {
|
|
497
|
+
nodeId: node.id,
|
|
498
|
+
nodeKind: node.kind,
|
|
499
|
+
})
|
|
500
|
+
}
|
|
501
|
+
return null
|
|
502
|
+
},
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DocumentEngine — SymbolTable
|
|
3
|
+
*
|
|
4
|
+
* Tracks symbols (definitions and references) in the document.
|
|
5
|
+
* Enables IDE features like:
|
|
6
|
+
* - Go to definition
|
|
7
|
+
* - Find all references
|
|
8
|
+
* - Rename symbol
|
|
9
|
+
* - Document outline
|
|
10
|
+
*
|
|
11
|
+
* In BBCode, symbols include:
|
|
12
|
+
* - [id=hero] → Definition
|
|
13
|
+
* - [goto=hero] → Reference
|
|
14
|
+
* - Named anchors and targets
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { RedNode } from '../Syntax/RedNode'
|
|
18
|
+
import type { SymbolInfo, SymbolKind, Reference, SymbolSearchResult } from '../Types/symbols'
|
|
19
|
+
|
|
20
|
+
export class SymbolTable {
|
|
21
|
+
private symbols: Map<string, SymbolInfo> = new Map()
|
|
22
|
+
private nodeToSymbolId: Map<string, string> = new Map()
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Build the symbol table from a RedNode tree.
|
|
26
|
+
*/
|
|
27
|
+
build(root: RedNode): void {
|
|
28
|
+
this.symbols.clear()
|
|
29
|
+
this.nodeToSymbolId.clear()
|
|
30
|
+
|
|
31
|
+
root.walk(node => {
|
|
32
|
+
// Check for id attributes (definitions)
|
|
33
|
+
const id = node.metadata?.id as string | undefined
|
|
34
|
+
if (id) {
|
|
35
|
+
this.addSymbol({
|
|
36
|
+
id: this.createSymbolId(id),
|
|
37
|
+
name: id,
|
|
38
|
+
kind: 'id_definition',
|
|
39
|
+
definitionNodeId: node.id,
|
|
40
|
+
definitionRange: { start: node.range.start, end: node.range.end },
|
|
41
|
+
containerNodeId: root.id,
|
|
42
|
+
references: [],
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Check for goto attributes (references)
|
|
47
|
+
const goto = node.metadata?.goto as string | undefined
|
|
48
|
+
if (goto && id !== goto) {
|
|
49
|
+
const existing = this.symbols.get(goto)
|
|
50
|
+
if (existing) {
|
|
51
|
+
existing.references.push({
|
|
52
|
+
nodeId: node.id,
|
|
53
|
+
range: { start: node.range.start, end: node.range.end },
|
|
54
|
+
kind: 'reference',
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Get a symbol by name.
|
|
63
|
+
*/
|
|
64
|
+
get(name: string): SymbolInfo | undefined {
|
|
65
|
+
return this.symbols.get(name)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Get the symbol associated with a node.
|
|
70
|
+
*/
|
|
71
|
+
getSymbolForNode(nodeId: string): SymbolInfo | undefined {
|
|
72
|
+
const symbolId = this.nodeToSymbolId.get(nodeId)
|
|
73
|
+
if (!symbolId) return undefined
|
|
74
|
+
return this.symbols.get(symbolId)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Search for symbols by name.
|
|
79
|
+
*/
|
|
80
|
+
search(query: string, maxResults: number = 10): SymbolSearchResult[] {
|
|
81
|
+
const lower = query.toLowerCase()
|
|
82
|
+
const results: SymbolSearchResult[] = []
|
|
83
|
+
|
|
84
|
+
for (const [, symbol] of this.symbols) {
|
|
85
|
+
if (results.length >= maxResults) break
|
|
86
|
+
const name = symbol.name.toLowerCase()
|
|
87
|
+
let score = 0
|
|
88
|
+
|
|
89
|
+
if (name === lower) score = 100
|
|
90
|
+
else if (name.startsWith(lower)) score = 75
|
|
91
|
+
else if (name.includes(lower)) score = 50
|
|
92
|
+
|
|
93
|
+
if (score > 0) {
|
|
94
|
+
results.push({ symbol, score })
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return results.sort((a, b) => b.score - a.score)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Get all symbols.
|
|
103
|
+
*/
|
|
104
|
+
getAll(): SymbolInfo[] {
|
|
105
|
+
return Array.from(this.symbols.values())
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Clear all symbols.
|
|
110
|
+
*/
|
|
111
|
+
clear(): void {
|
|
112
|
+
this.symbols.clear()
|
|
113
|
+
this.nodeToSymbolId.clear()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private addSymbol(symbol: SymbolInfo): void {
|
|
117
|
+
this.symbols.set(symbol.name, symbol)
|
|
118
|
+
this.nodeToSymbolId.set(symbol.definitionNodeId, symbol.id)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private createSymbolId(name: string): string {
|
|
122
|
+
return `sym-${name}-${Date.now()}`
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { SymbolTable } from './SymbolTable'
|