@miliastry/quasar 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/LICENSE +119 -0
  2. package/README.md +45 -0
  3. package/dist/Visuals/lyne.css +1027 -0
  4. package/dist/Visuals/osu.css +257 -0
  5. package/dist/index.d.mts +4563 -0
  6. package/dist/index.d.ts +4563 -0
  7. package/dist/index.js +11291 -0
  8. package/dist/index.mjs +11197 -0
  9. package/package.json +51 -0
  10. package/src/Analysis/Contracts/AnalysisReport.ts +26 -0
  11. package/src/Analysis/Contracts/Contribution.ts +76 -0
  12. package/src/Analysis/Contracts/Pass.ts +76 -0
  13. package/src/Analysis/Contracts/PipelineContext.ts +48 -0
  14. package/src/Analysis/Passes/Analysis/GradientAnalyzer.ts +292 -0
  15. package/src/Analysis/Passes/Analysis/MergeableColorAnalyzer.ts +112 -0
  16. package/src/Analysis/Passes/Analysis/RainbowAnalyzer.ts +233 -0
  17. package/src/Analysis/Passes/Analysis/WaveAnalyzer.ts +211 -0
  18. package/src/Analysis/Passes/Analysis/__tests__/GradientAnalyzer.test.ts +135 -0
  19. package/src/Analysis/Passes/Analysis/__tests__/MergeableColorAnalyzer.test.ts +84 -0
  20. package/src/Analysis/Passes/Analysis/__tests__/RainbowAnalyzer.test.ts +99 -0
  21. package/src/Analysis/Passes/Analysis/__tests__/WaveAnalyzer.test.ts +119 -0
  22. package/src/Analysis/Passes/Decision/DefaultDecision.ts +139 -0
  23. package/src/Analysis/Passes/Decision/__tests__/DefaultDecision.test.ts +179 -0
  24. package/src/Analysis/Passes/Transform/CollapseGradientTransform.ts +176 -0
  25. package/src/Analysis/Passes/Transform/MergeColorsTransform.ts +126 -0
  26. package/src/Analysis/Passes/Transform/RainbowCollapseTransform.ts +83 -0
  27. package/src/Analysis/Passes/Transform/WaveCollapseTransform.ts +88 -0
  28. package/src/Analysis/Passes/Utility/CharacterCountAnalyzer.ts +45 -0
  29. package/src/Analysis/Pipeline/Pipeline.ts +133 -0
  30. package/src/Analysis/Pipeline/PipelineBuilder.ts +55 -0
  31. package/src/Analysis/Pipeline/PipelineStage.ts +19 -0
  32. package/src/Analysis/Utils/color-utils.ts +132 -0
  33. package/src/Analysis/__tests__/Integration.test.ts +162 -0
  34. package/src/Analysis/__tests__/Pipeline.test.ts +133 -0
  35. package/src/Analysis/index.ts +52 -0
  36. package/src/BBCode/BBCodeDocumentModel.ts +175 -0
  37. package/src/BBCode/BBCodeToGreenNode.ts +755 -0
  38. package/src/BBCode/Parser.ts +384 -0
  39. package/src/BBCode/index.ts +12 -0
  40. package/src/Collab/positions.ts +91 -0
  41. package/src/Commands/Command.ts +44 -0
  42. package/src/Commands/CommandRegistry.ts +78 -0
  43. package/src/Commands/DeleteNode.ts +20 -0
  44. package/src/Commands/InsertText.ts +21 -0
  45. package/src/Commands/SplitMerge.ts +28 -0
  46. package/src/Commands/WrapInTag.ts +21 -0
  47. package/src/Commands/index.ts +6 -0
  48. package/src/Diff/TreeDiffer.ts +264 -0
  49. package/src/Diff/__tests__/TreeDiffer.test.ts +65 -0
  50. package/src/Diff/index.ts +2 -0
  51. package/src/Events/EventBus.ts +160 -0
  52. package/src/Events/index.ts +2 -0
  53. package/src/Formatter/Formatter.ts +54 -0
  54. package/src/Formatter/index.ts +2 -0
  55. package/src/HTML/HTMLDocumentModel.ts +35 -0
  56. package/src/HTML/HTMLToGreenNode.ts +290 -0
  57. package/src/Incremental/ChangeTracker.ts +105 -0
  58. package/src/Incremental/IncrementalParser.ts +591 -0
  59. package/src/Incremental/__tests__/IncrementalParser.test.ts +164 -0
  60. package/src/Incremental/index.ts +4 -0
  61. package/src/Lexer/BBCodeLexer.ts +382 -0
  62. package/src/Lexer/Lexer.ts +181 -0
  63. package/src/Lexer/index.ts +10 -0
  64. package/src/Linter/Linter.ts +193 -0
  65. package/src/Linter/index.ts +2 -0
  66. package/src/Markdown/MarkdownAST.ts +112 -0
  67. package/src/Markdown/MarkdownDocumentModel.ts +55 -0
  68. package/src/Markdown/MarkdownLexer.ts +203 -0
  69. package/src/Markdown/MarkdownParser.ts +455 -0
  70. package/src/Markdown/MarkdownToGreenNode.ts +153 -0
  71. package/src/Model/DocumentModel.ts +694 -0
  72. package/src/Model/NodeFactory.ts +117 -0
  73. package/src/Model/TagRegistry.ts +495 -0
  74. package/src/Model/index.ts +5 -0
  75. package/src/Plugins/PluginAPI.ts +119 -0
  76. package/src/Plugins/PluginRegistry.ts +132 -0
  77. package/src/Plugins/index.ts +3 -0
  78. package/src/Queries/QueryEngine.ts +152 -0
  79. package/src/Queries/index.ts +1 -0
  80. package/src/RenderPipeline/RenderPipeline.ts +125 -0
  81. package/src/RenderPipeline/RenderTree.ts +134 -0
  82. package/src/RenderPipeline/index.ts +4 -0
  83. package/src/Semantic/SemanticAnalyzer.ts +506 -0
  84. package/src/Semantic/index.ts +2 -0
  85. package/src/Symbols/SymbolTable.ts +124 -0
  86. package/src/Symbols/index.ts +1 -0
  87. package/src/Syntax/GreenNode.ts +324 -0
  88. package/src/Syntax/GreenNodePool.ts +269 -0
  89. package/src/Syntax/NodeMatcher.ts +370 -0
  90. package/src/Syntax/RedNode.ts +569 -0
  91. package/src/Syntax/RedNodeStore.ts +184 -0
  92. package/src/Syntax/TreeBuilder.ts +214 -0
  93. package/src/Syntax/__tests__/GreenNode.test.ts +33 -0
  94. package/src/Syntax/__tests__/RedNode.test.ts +81 -0
  95. package/src/Syntax/__tests__/RedNodeStore.test.ts +104 -0
  96. package/src/Syntax/greenEdit.ts +110 -0
  97. package/src/Syntax/hash.ts +30 -0
  98. package/src/Syntax/index.ts +12 -0
  99. package/src/Syntax/partition.ts +161 -0
  100. package/src/Syntax/preserveNodeIds.ts +201 -0
  101. package/src/Tests/ASTOptimizerIdempotence.test.ts +77 -0
  102. package/src/Tests/BlockPatcher.test.ts +437 -0
  103. package/src/Tests/BlockPatcherWindowed.test.ts +364 -0
  104. package/src/Tests/BoxDrawer.test.ts +217 -0
  105. package/src/Tests/BoxRichTitle.test.ts +105 -0
  106. package/src/Tests/Chars500kBenchmark.test.ts +151 -0
  107. package/src/Tests/Chars500kEdits.test.ts +321 -0
  108. package/src/Tests/CollabPositions.test.ts +146 -0
  109. package/src/Tests/CompilerPathProfiling.test.ts +186 -0
  110. package/src/Tests/DOMMorpher.test.ts +142 -0
  111. package/src/Tests/DomPatchPerf.test.ts +60 -0
  112. package/src/Tests/EffectSegments.snapshot.json +616 -0
  113. package/src/Tests/EffectSegments.test.ts +68 -0
  114. package/src/Tests/FindNodeAtOffset.test.ts +65 -0
  115. package/src/Tests/Fuzzer.test.ts +166 -0
  116. package/src/Tests/GreenNodePool.test.ts +153 -0
  117. package/src/Tests/Lexer.test.ts +238 -0
  118. package/src/Tests/LyneMode.test.ts +187 -0
  119. package/src/Tests/ModelCoherence.test.ts +180 -0
  120. package/src/Tests/Partition.test.ts +238 -0
  121. package/src/Tests/PluginTags.test.ts +150 -0
  122. package/src/Tests/ProblematicSection.test.ts +46 -0
  123. package/src/Tests/ProblematicSectionHTML.test.ts +58 -0
  124. package/src/Tests/RedReuse.test.ts +134 -0
  125. package/src/Tests/ReproDelete20k.test.ts +62 -0
  126. package/src/Tests/SemanticValidators.test.ts +136 -0
  127. package/src/Tests/StableNodeIds.test.ts +210 -0
  128. package/src/Tests/StudioColorBloat.test.ts +25 -0
  129. package/src/Tests/StudioDebugText.test.ts +27 -0
  130. package/src/Tests/StudioTrailingChar.test.ts +25 -0
  131. package/src/Tests/StudioValidText.test.ts +25 -0
  132. package/src/Tests/UrlImgBug.test.ts +23 -0
  133. package/src/Tests/VisualBuilderFidelity.test.ts +105 -0
  134. package/src/Tests/referenceDocument.ts +119 -0
  135. package/src/Transactions/Transaction.ts +176 -0
  136. package/src/Transactions/UndoManager.ts +111 -0
  137. package/src/Transactions/index.ts +3 -0
  138. package/src/Transformers/ASTOptimizer.ts +315 -0
  139. package/src/Transformers/GradientTransformer.ts +143 -0
  140. package/src/Transformers/GrowTransformer.ts +115 -0
  141. package/src/Transformers/RainbowTransformer.ts +121 -0
  142. package/src/Transformers/SineWaveTransformer.ts +130 -0
  143. package/src/Transformers/Transformer.ts +22 -0
  144. package/src/Types/core.ts +270 -0
  145. package/src/Types/diagnostics.ts +156 -0
  146. package/src/Types/index.ts +23 -0
  147. package/src/Types/operations.ts +180 -0
  148. package/src/Types/queries.ts +121 -0
  149. package/src/Types/symbols.ts +69 -0
  150. package/src/Types/tokens.ts +186 -0
  151. package/src/Utils/BBCodeGenerator.ts +126 -0
  152. package/src/Utils/ColorMath.ts +276 -0
  153. package/src/Utils/color.ts +112 -0
  154. package/src/Utils/dom-to-svg.test.ts +86 -0
  155. package/src/Utils/dom-to-svg.ts +615 -0
  156. package/src/Utils/treeTransformers.ts +717 -0
  157. package/src/Visitors/BBBlocksExporter.ts +69 -0
  158. package/src/Visitors/BBCodeExporter.ts +318 -0
  159. package/src/Visitors/BlockPatcher.ts +963 -0
  160. package/src/Visitors/DOMMorpher.ts +134 -0
  161. package/src/Visitors/HTMLRenderer.ts +1077 -0
  162. package/src/Visitors/JSONExporter.ts +66 -0
  163. package/src/Visitors/MarkdownExporter.ts +99 -0
  164. package/src/Visitors/SVGRenderer.ts +35 -0
  165. package/src/Visitors/TiptapExporter.ts +145 -0
  166. package/src/Visitors/Visitor.ts +48 -0
  167. package/src/Visitors/index.ts +9 -0
  168. package/src/Visuals/BoxDrawer.ts +175 -0
  169. package/src/Visuals/index.ts +42 -0
  170. package/src/Visuals/lyne.css +1027 -0
  171. package/src/Visuals/osu.css +257 -0
  172. package/src/index.ts +197 -0
@@ -0,0 +1,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,2 @@
1
+ export { SemanticAnalyzer } from './SemanticAnalyzer'
2
+ export type { Validator, AnalyzerContext, AnalyzeResult } from './SemanticAnalyzer'
@@ -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'