@miliastry/quasar 1.0.6 → 1.1.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 (273) hide show
  1. package/dist/Visuals/lyne.css +117 -6
  2. package/dist/Visuals/osu.css +36 -5
  3. package/dist/index.js +9664 -3282
  4. package/dist/index.mjs +9535 -3283
  5. package/dist/types/Analysis/Contracts/AnalysisReport.d.ts +22 -0
  6. package/dist/types/Analysis/Contracts/Contribution.d.ts +68 -0
  7. package/dist/types/Analysis/Contracts/Pass.d.ts +59 -0
  8. package/dist/types/Analysis/Contracts/PipelineContext.d.ts +34 -0
  9. package/dist/types/Analysis/Passes/Analysis/ColorUsageAnalyzer.d.ts +56 -0
  10. package/dist/types/Analysis/Passes/Analysis/GradientAnalyzer.d.ts +111 -0
  11. package/dist/types/Analysis/Passes/Analysis/MergeableColorAnalyzer.d.ts +26 -0
  12. package/dist/types/Analysis/Passes/Analysis/RainbowAnalyzer.d.ts +47 -0
  13. package/dist/types/Analysis/Passes/Analysis/SymbolAnalyzer.d.ts +89 -0
  14. package/dist/types/Analysis/Passes/Analysis/WaveAnalyzer.d.ts +44 -0
  15. package/dist/types/Analysis/Passes/Analysis/__tests__/GradientAnalyzer.test.d.ts +4 -0
  16. package/dist/types/Analysis/Passes/Analysis/__tests__/MergeableColorAnalyzer.test.d.ts +4 -0
  17. package/dist/types/Analysis/Passes/Analysis/__tests__/RainbowAnalyzer.test.d.ts +4 -0
  18. package/dist/types/Analysis/Passes/Analysis/__tests__/WaveAnalyzer.test.d.ts +4 -0
  19. package/dist/types/Analysis/Passes/Decision/DefaultDecision.d.ts +30 -0
  20. package/dist/types/Analysis/Passes/Decision/PaletteRemapDecision.d.ts +169 -0
  21. package/dist/types/Analysis/Passes/Decision/__tests__/DefaultDecision.test.d.ts +4 -0
  22. package/dist/types/Analysis/Passes/Transform/CollapseGradientTransform.d.ts +37 -0
  23. package/dist/types/Analysis/Passes/Transform/MergeColorsTransform.d.ts +28 -0
  24. package/dist/types/Analysis/Passes/Transform/RainbowCollapseTransform.d.ts +19 -0
  25. package/dist/types/Analysis/Passes/Transform/WaveCollapseTransform.d.ts +20 -0
  26. package/dist/types/Analysis/Passes/Utility/CharacterCountAnalyzer.d.ts +19 -0
  27. package/dist/types/Analysis/Pipeline/Pipeline.d.ts +69 -0
  28. package/dist/types/Analysis/Pipeline/PipelineBuilder.d.ts +31 -0
  29. package/dist/types/Analysis/Pipeline/PipelineStage.d.ts +17 -0
  30. package/dist/types/Analysis/Utils/color-utils.d.ts +60 -0
  31. package/dist/types/Analysis/__tests__/Integration.test.d.ts +8 -0
  32. package/dist/types/Analysis/__tests__/PaletteRemap.test.d.ts +14 -0
  33. package/dist/types/Analysis/__tests__/Pipeline.test.d.ts +10 -0
  34. package/dist/types/Analysis/__tests__/RoundTrip.test.d.ts +30 -0
  35. package/dist/types/Analysis/__tests__/SymbolAnalyzer.test.d.ts +11 -0
  36. package/dist/types/Analysis/index.d.ts +40 -0
  37. package/dist/types/BBCode/BBCodeDocumentModel.d.ts +92 -0
  38. package/dist/types/BBCode/BBCodeToGreenNode.d.ts +111 -0
  39. package/dist/types/BBCode/Parser.d.ts +78 -0
  40. package/dist/types/BBCode/index.d.ts +4 -0
  41. package/dist/types/Collab/positions.d.ts +55 -0
  42. package/dist/types/Commands/Command.d.ts +37 -0
  43. package/dist/types/Commands/CommandRegistry.d.ts +48 -0
  44. package/dist/types/Commands/DeleteNode.d.ts +7 -0
  45. package/dist/types/Commands/InsertText.d.ts +8 -0
  46. package/dist/types/Commands/SplitMerge.d.ts +9 -0
  47. package/dist/types/Commands/WrapInTag.d.ts +8 -0
  48. package/dist/types/Commands/index.d.ts +6 -0
  49. package/dist/types/Diff/TreeDiffer.d.ts +52 -0
  50. package/dist/types/Diff/__tests__/TreeDiffer.test.d.ts +1 -0
  51. package/dist/types/Diff/index.d.ts +2 -0
  52. package/dist/types/Edits/EditPlan.d.ts +144 -0
  53. package/dist/types/Edits/Optimizer.d.ts +78 -0
  54. package/dist/types/Edits/Rules/Rule.d.ts +91 -0
  55. package/dist/types/Edits/Rules/dropEmptyTags.d.ts +40 -0
  56. package/dist/types/Edits/Rules/dropRedundantNesting.d.ts +59 -0
  57. package/dist/types/Edits/Rules/index.d.ts +9 -0
  58. package/dist/types/Edits/Rules/mergeAdjacent.d.ts +93 -0
  59. package/dist/types/Edits/Rules/reorderWrappers.d.ts +42 -0
  60. package/dist/types/Edits/Rules/shortenHex.d.ts +30 -0
  61. package/dist/types/Edits/Rules/tagValue.d.ts +56 -0
  62. package/dist/types/Edits/Rules/unwrapInvisibleColor.d.ts +47 -0
  63. package/dist/types/Edits/applyEdits.d.ts +36 -0
  64. package/dist/types/Edits/index.d.ts +6 -0
  65. package/dist/types/Events/EventBus.d.ts +103 -0
  66. package/dist/types/Events/index.d.ts +2 -0
  67. package/dist/types/Formatter/Formatter.d.ts +32 -0
  68. package/dist/types/Formatter/index.d.ts +2 -0
  69. package/dist/types/HTML/HTMLDocumentModel.d.ts +13 -0
  70. package/dist/types/HTML/HTMLToGreenNode.d.ts +4 -0
  71. package/dist/types/Incremental/BracketIndex.d.ts +101 -0
  72. package/dist/types/Incremental/ChangeTracker.d.ts +69 -0
  73. package/dist/types/Incremental/IncrementalParser.d.ts +246 -0
  74. package/dist/types/Incremental/__tests__/BracketIndex.test.d.ts +1 -0
  75. package/dist/types/Incremental/__tests__/IncrementalParser.test.d.ts +1 -0
  76. package/dist/types/Incremental/index.d.ts +4 -0
  77. package/dist/types/Lexer/BBCodeLexer.d.ts +74 -0
  78. package/dist/types/Lexer/Lexer.d.ts +54 -0
  79. package/dist/types/Lexer/index.d.ts +4 -0
  80. package/dist/types/Linter/Linter.d.ts +58 -0
  81. package/dist/types/Linter/index.d.ts +2 -0
  82. package/dist/types/Markdown/MarkdownAST.d.ts +122 -0
  83. package/dist/types/Markdown/MarkdownDocumentModel.d.ts +15 -0
  84. package/dist/types/Markdown/MarkdownLexer.d.ts +155 -0
  85. package/dist/types/Markdown/MarkdownParser.d.ts +42 -0
  86. package/dist/types/Markdown/MarkdownToGreenNode.d.ts +6 -0
  87. package/dist/types/MilHibri/MilHibriDocumentModel.d.ts +21 -0
  88. package/dist/types/Model/DocumentModel.d.ts +318 -0
  89. package/dist/types/Model/NodeFactory.d.ts +29 -0
  90. package/dist/types/Model/TagRegistry.d.ts +165 -0
  91. package/dist/types/Model/index.d.ts +5 -0
  92. package/dist/types/Plugins/PluginAPI.d.ts +44 -0
  93. package/dist/types/Plugins/PluginRegistry.d.ts +81 -0
  94. package/dist/types/Plugins/index.d.ts +3 -0
  95. package/dist/types/Queries/QueryEngine.d.ts +32 -0
  96. package/dist/types/Queries/index.d.ts +1 -0
  97. package/dist/types/Reconciler/SurgicalReconciler.d.ts +23 -0
  98. package/dist/types/RenderPipeline/RenderPipeline.d.ts +46 -0
  99. package/dist/types/RenderPipeline/RenderTree.d.ts +55 -0
  100. package/dist/types/RenderPipeline/index.d.ts +4 -0
  101. package/dist/types/Repair/NestingRepair.d.ts +48 -0
  102. package/dist/types/Repair/index.d.ts +1 -0
  103. package/dist/types/Semantic/SemanticAnalyzer.d.ts +321 -0
  104. package/dist/types/Semantic/index.d.ts +2 -0
  105. package/dist/types/Symbols/SymbolTable.d.ts +47 -0
  106. package/dist/types/Symbols/index.d.ts +1 -0
  107. package/dist/types/Syntax/GreenNode.d.ts +154 -0
  108. package/dist/types/Syntax/GreenNodePool.d.ts +134 -0
  109. package/dist/types/Syntax/NodeMatcher.d.ts +75 -0
  110. package/dist/types/Syntax/RedNode.d.ts +280 -0
  111. package/dist/types/Syntax/RedNodeStore.d.ts +116 -0
  112. package/dist/types/Syntax/TreeBuilder.d.ts +54 -0
  113. package/dist/types/Syntax/__tests__/GreenNode.test.d.ts +1 -0
  114. package/dist/types/Syntax/__tests__/RedNode.test.d.ts +1 -0
  115. package/dist/types/Syntax/__tests__/RedNodeStore.test.d.ts +1 -0
  116. package/dist/types/Syntax/greenEdit.d.ts +64 -0
  117. package/dist/types/Syntax/hash.d.ts +17 -0
  118. package/dist/types/Syntax/index.d.ts +8 -0
  119. package/dist/types/Syntax/nodeAttr.d.ts +63 -0
  120. package/dist/types/Syntax/partition.d.ts +67 -0
  121. package/dist/types/Syntax/preserveNodeIds.d.ts +47 -0
  122. package/dist/types/Tokens/TokenResolver.d.ts +25 -0
  123. package/dist/types/Tokens/index.d.ts +1 -0
  124. package/dist/types/Transactions/Transaction.d.ts +36 -0
  125. package/dist/types/Transactions/UndoManager.d.ts +63 -0
  126. package/dist/types/Transactions/index.d.ts +3 -0
  127. package/dist/types/Transformers/ASTOptimizer.d.ts +34 -0
  128. package/dist/types/Transformers/GradientTransformer.d.ts +18 -0
  129. package/dist/types/Transformers/GrowTransformer.d.ts +16 -0
  130. package/dist/types/Transformers/RainbowTransformer.d.ts +17 -0
  131. package/dist/types/Transformers/SineWaveTransformer.d.ts +17 -0
  132. package/dist/types/Transformers/Transformer.d.ts +17 -0
  133. package/dist/types/Types/core.d.ts +104 -0
  134. package/dist/types/Types/diagnostics.d.ts +85 -0
  135. package/dist/types/Types/index.d.ts +11 -0
  136. package/dist/types/Types/operations.d.ts +109 -0
  137. package/dist/types/Types/queries.d.ts +67 -0
  138. package/dist/types/Types/symbols.d.ts +42 -0
  139. package/dist/types/Types/tokens.d.ts +75 -0
  140. package/dist/types/Utils/BBCodeGenerator.d.ts +21 -0
  141. package/dist/types/Utils/ColorMath.d.ts +92 -0
  142. package/dist/types/Utils/EffectMath.d.ts +643 -0
  143. package/dist/types/Utils/TagVocabulary.d.ts +104 -0
  144. package/dist/types/Utils/TagVocabulary.test.d.ts +1 -0
  145. package/dist/types/Utils/color.d.ts +29 -0
  146. package/dist/types/Utils/dom-to-svg.d.ts +28 -0
  147. package/dist/types/Utils/dom-to-svg.test.d.ts +1 -0
  148. package/dist/types/Utils/treeTransformers.d.ts +160 -0
  149. package/dist/types/Visitors/BBBlocksExporter.d.ts +27 -0
  150. package/dist/types/Visitors/BBCodeExporter.d.ts +90 -0
  151. package/dist/types/Visitors/BlockPatcher.d.ts +91 -0
  152. package/dist/types/Visitors/DOMMorpher.d.ts +13 -0
  153. package/dist/types/Visitors/HTMLRenderer.d.ts +380 -0
  154. package/dist/types/Visitors/JSONExporter.d.ts +42 -0
  155. package/dist/types/Visitors/MarkdownExporter.d.ts +28 -0
  156. package/dist/types/Visitors/SVGRenderer.d.ts +16 -0
  157. package/dist/types/Visitors/TiptapExporter.d.ts +40 -0
  158. package/dist/types/Visitors/Visitor.d.ts +35 -0
  159. package/dist/types/Visitors/index.d.ts +9 -0
  160. package/dist/types/Visuals/BoxDrawer.d.ts +49 -0
  161. package/dist/types/Visuals/index.d.ts +38 -0
  162. package/dist/types/index.d.ts +154 -0
  163. package/package.json +14 -10
  164. package/src/Analysis/Passes/Analysis/ColorUsageAnalyzer.ts +103 -0
  165. package/src/Analysis/Passes/Analysis/GradientAnalyzer.ts +294 -20
  166. package/src/Analysis/Passes/Analysis/SymbolAnalyzer.ts +257 -0
  167. package/src/Analysis/Passes/Decision/PaletteRemapDecision.ts +425 -0
  168. package/src/Analysis/Pipeline/Pipeline.ts +15 -2
  169. package/src/Analysis/index.ts +8 -2
  170. package/src/BBCode/BBCodeDocumentModel.ts +3 -0
  171. package/src/BBCode/BBCodeToGreenNode.ts +18 -12
  172. package/src/BBCode/Parser.ts +38 -1
  173. package/src/Edits/EditPlan.ts +290 -0
  174. package/src/Edits/Optimizer.ts +185 -0
  175. package/src/Edits/Rules/Rule.ts +145 -0
  176. package/src/Edits/Rules/dropEmptyTags.ts +110 -0
  177. package/src/Edits/Rules/dropRedundantNesting.ts +120 -0
  178. package/src/Edits/Rules/index.ts +28 -0
  179. package/src/Edits/Rules/mergeAdjacent.ts +224 -0
  180. package/src/Edits/Rules/reorderWrappers.ts +154 -0
  181. package/src/Edits/Rules/shortenHex.ts +79 -0
  182. package/src/Edits/Rules/tagValue.ts +105 -0
  183. package/src/Edits/Rules/unwrapInvisibleColor.ts +93 -0
  184. package/src/Edits/applyEdits.ts +54 -0
  185. package/src/Edits/index.ts +22 -0
  186. package/src/Events/EventBus.ts +18 -0
  187. package/src/HTML/HTMLToGreenNode.ts +185 -37
  188. package/src/Incremental/BracketIndex.ts +249 -0
  189. package/src/Incremental/IncrementalParser.ts +464 -41
  190. package/src/Lexer/BBCodeLexer.ts +25 -3
  191. package/src/Lexer/index.ts +1 -1
  192. package/src/Markdown/MarkdownAST.ts +65 -2
  193. package/src/Markdown/MarkdownLexer.ts +69 -14
  194. package/src/Markdown/MarkdownParser.ts +749 -62
  195. package/src/Markdown/MarkdownToGreenNode.ts +148 -27
  196. package/src/MilHibri/MilHibriDocumentModel.ts +56 -0
  197. package/src/Model/DocumentModel.ts +138 -5
  198. package/src/Model/TagRegistry.ts +129 -134
  199. package/src/Reconciler/SurgicalReconciler.ts +460 -0
  200. package/src/Repair/NestingRepair.ts +116 -0
  201. package/src/Repair/index.ts +1 -0
  202. package/src/Semantic/SemanticAnalyzer.ts +1802 -65
  203. package/src/Syntax/GreenNodePool.ts +12 -12
  204. package/src/Syntax/RedNode.ts +17 -3
  205. package/src/Syntax/RedNodeStore.ts +9 -1
  206. package/src/Syntax/nodeAttr.ts +172 -0
  207. package/src/Tokens/TokenResolver.ts +55 -0
  208. package/src/Tokens/index.ts +1 -0
  209. package/src/Types/core.ts +15 -0
  210. package/src/Utils/ColorMath.ts +276 -55
  211. package/src/Utils/EffectMath.ts +2462 -0
  212. package/src/Utils/TagVocabulary.ts +141 -0
  213. package/src/Utils/dom-to-svg.ts +28 -12
  214. package/src/Visitors/BBBlocksExporter.ts +15 -1
  215. package/src/Visitors/BBCodeExporter.ts +319 -32
  216. package/src/Visitors/BlockPatcher.ts +87 -11
  217. package/src/Visitors/DOMMorpher.ts +15 -5
  218. package/src/Visitors/HTMLRenderer.ts +374 -151
  219. package/src/Visitors/MarkdownExporter.ts +98 -41
  220. package/src/Visitors/TiptapExporter.ts +1 -0
  221. package/src/Visuals/index.ts +7 -4
  222. package/src/Visuals/lyne.css +117 -6
  223. package/src/Visuals/osu.css +36 -5
  224. package/src/index.ts +172 -5
  225. package/dist/index.d.mts +0 -4612
  226. package/dist/index.d.ts +0 -4612
  227. package/src/Analysis/Passes/Analysis/__tests__/GradientAnalyzer.test.ts +0 -135
  228. package/src/Analysis/Passes/Analysis/__tests__/MergeableColorAnalyzer.test.ts +0 -84
  229. package/src/Analysis/Passes/Analysis/__tests__/RainbowAnalyzer.test.ts +0 -99
  230. package/src/Analysis/Passes/Analysis/__tests__/WaveAnalyzer.test.ts +0 -119
  231. package/src/Analysis/Passes/Decision/__tests__/DefaultDecision.test.ts +0 -179
  232. package/src/Analysis/__tests__/Integration.test.ts +0 -162
  233. package/src/Analysis/__tests__/Pipeline.test.ts +0 -133
  234. package/src/Diff/__tests__/TreeDiffer.test.ts +0 -65
  235. package/src/Incremental/__tests__/IncrementalParser.test.ts +0 -164
  236. package/src/Syntax/__tests__/GreenNode.test.ts +0 -33
  237. package/src/Syntax/__tests__/RedNode.test.ts +0 -81
  238. package/src/Syntax/__tests__/RedNodeStore.test.ts +0 -104
  239. package/src/Tests/ASTOptimizerIdempotence.test.ts +0 -77
  240. package/src/Tests/BlockPatcher.test.ts +0 -437
  241. package/src/Tests/BlockPatcherWindowed.test.ts +0 -364
  242. package/src/Tests/BoxDrawer.test.ts +0 -217
  243. package/src/Tests/BoxRichTitle.test.ts +0 -105
  244. package/src/Tests/Chars500kBenchmark.test.ts +0 -151
  245. package/src/Tests/Chars500kEdits.test.ts +0 -321
  246. package/src/Tests/CollabPositions.test.ts +0 -146
  247. package/src/Tests/CompilerPathProfiling.test.ts +0 -186
  248. package/src/Tests/DOMMorpher.test.ts +0 -142
  249. package/src/Tests/DomPatchPerf.test.ts +0 -60
  250. package/src/Tests/EffectSegments.snapshot.json +0 -616
  251. package/src/Tests/EffectSegments.test.ts +0 -68
  252. package/src/Tests/FindNodeAtOffset.test.ts +0 -65
  253. package/src/Tests/Fuzzer.test.ts +0 -166
  254. package/src/Tests/GreenNodePool.test.ts +0 -153
  255. package/src/Tests/Lexer.test.ts +0 -238
  256. package/src/Tests/LyneMode.test.ts +0 -291
  257. package/src/Tests/ModelCoherence.test.ts +0 -180
  258. package/src/Tests/Partition.test.ts +0 -238
  259. package/src/Tests/PluginTags.test.ts +0 -150
  260. package/src/Tests/ProblematicSection.test.ts +0 -46
  261. package/src/Tests/ProblematicSectionHTML.test.ts +0 -58
  262. package/src/Tests/RedReuse.test.ts +0 -134
  263. package/src/Tests/ReproDelete20k.test.ts +0 -62
  264. package/src/Tests/SemanticValidators.test.ts +0 -136
  265. package/src/Tests/StableNodeIds.test.ts +0 -210
  266. package/src/Tests/StudioColorBloat.test.ts +0 -25
  267. package/src/Tests/StudioDebugText.test.ts +0 -27
  268. package/src/Tests/StudioTrailingChar.test.ts +0 -25
  269. package/src/Tests/StudioValidText.test.ts +0 -25
  270. package/src/Tests/UrlImgBug.test.ts +0 -23
  271. package/src/Tests/VisualBuilderFidelity.test.ts +0 -105
  272. package/src/Tests/referenceDocument.ts +0 -119
  273. package/src/Utils/dom-to-svg.test.ts +0 -86
@@ -11,17 +11,73 @@
11
11
  */
12
12
 
13
13
  import { RedNode } from '../Syntax/RedNode'
14
+ import { getBBCodeTagNames, type BBCodeDialect } from '../BBCode/BBCodeToGreenNode'
14
15
  import type { NodeKind } from '../Types/core'
16
+ import type { Range } from '../Types/tokens'
15
17
  import type {
16
18
  Diagnostic,
17
19
  DiagnosticSeverity,
18
20
  DiagnosticCollection,
21
+ DiagnosticFix,
19
22
  } from '../Types/diagnostics'
20
23
  import {
21
24
  createDiagnosticCollection,
22
25
  createDiagnostic,
23
26
  addDiagnostic,
24
27
  } from '../Types/diagnostics'
28
+ import { collapsibleGradientsAt, type CollapsibleGradient } from '../Analysis/Passes/Analysis/GradientAnalyzer'
29
+ import type { SourceSpan } from '../Incremental/IncrementalParser'
30
+ import {
31
+ toTokenResolver,
32
+ type TokenResolverFn,
33
+ type TokenSource,
34
+ } from '../Tokens'
35
+ import { nodeAttrValue } from '../Syntax/nodeAttr'
36
+
37
+ function findTokenReferences(node: RedNode): string[] {
38
+ const tokens = new Set<string>()
39
+
40
+ // Check metadata
41
+ if (node.metadata) {
42
+ for (const [k, val] of Object.entries(node.metadata)) {
43
+ if (typeof val === 'string' && val.startsWith('$')) {
44
+ tokens.add(val)
45
+ } else if (k === 'style' && val && typeof val === 'object') {
46
+ for (const sVal of Object.values(val as Record<string, unknown>)) {
47
+ if (typeof sVal === 'string' && sVal.startsWith('$')) {
48
+ tokens.add(sVal)
49
+ }
50
+ }
51
+ } else if (k === 'style' && typeof val === 'string') {
52
+ const matches = val.match(/\$[a-zA-Z0-9_.-]+/g)
53
+ if (matches) {
54
+ for (const m of matches) tokens.add(m)
55
+ }
56
+ }
57
+ }
58
+ }
59
+
60
+ // Check node.text for tag nodes (like [color=$accent] or [box=Title:$accent])
61
+ if (
62
+ node.kind !== 'text' &&
63
+ node.kind !== 'document' &&
64
+ node.kind !== 'paragraph' &&
65
+ node.kind !== 'spacing' &&
66
+ node.kind !== 'empty_line'
67
+ ) {
68
+ const attr = nodeAttrValue(node)
69
+ if (attr && attr.startsWith('$')) {
70
+ tokens.add(attr)
71
+ }
72
+ const text = node.text || ''
73
+ const match = /:(\$[a-zA-Z0-9_.-]+)/.exec(text)
74
+ if (match) {
75
+ tokens.add(match[1])
76
+ }
77
+ }
78
+
79
+ return Array.from(tokens)
80
+ }
25
81
 
26
82
  /**
27
83
  * Run one validator and file whatever it returns, both on the collection and on
@@ -35,6 +91,28 @@ import {
35
91
  * The `try` is per validator, deliberately: one that throws must not take the
36
92
  * rest of the analysis down with it.
37
93
  */
94
+ /**
95
+ * A diagnostic whose range is its OWN object, not the node's.
96
+ *
97
+ * `RedNode.range` hands back the node's internal `_range`, and a shift
98
+ * mutates that object in place (see `RedNode.materialize`). A validator that
99
+ * files a diagnostic "at this node" — most of them, plus `hrefRange`'s
100
+ * fallback — therefore ends up holding a range that silently follows the node
101
+ * around. Harmless while every analysis rebuilt every diagnostic; a bug the
102
+ * moment one is KEPT across an edit, because the incremental pass then moves
103
+ * it a second time by the displacement it had already absorbed, and the
104
+ * squiggle lands two characters past the tag while its quick fix stays put.
105
+ * Found by the analysis differential on the first fuzz document.
106
+ *
107
+ * One small object per diagnostic produced, on a document that has a few
108
+ * hundred of them among 38.000 nodes.
109
+ */
110
+ function detachRange(diagnostic: Diagnostic): Diagnostic {
111
+ const range = diagnostic.range
112
+ if (range === null || range === undefined) return diagnostic
113
+ return { ...diagnostic, range: { start: range.start, end: range.end } }
114
+ }
115
+
38
116
  function runValidator(
39
117
  validator: Validator,
40
118
  node: RedNode,
@@ -46,12 +124,14 @@ function runValidator(
46
124
  if (result === null || result === undefined) return
47
125
  if (Array.isArray(result)) {
48
126
  for (let i = 0; i < result.length; i++) {
49
- addDiagnostic(diagnostics, result[i])
50
- node.diagnostics.push(result[i])
127
+ const own = detachRange(result[i])
128
+ addDiagnostic(diagnostics, own)
129
+ node.diagnostics.push(own)
51
130
  }
52
131
  } else {
53
- addDiagnostic(diagnostics, result)
54
- node.diagnostics.push(result)
132
+ const own = detachRange(result)
133
+ addDiagnostic(diagnostics, own)
134
+ node.diagnostics.push(own)
55
135
  }
56
136
  } catch (error) {
57
137
  // Validator error should not break the analysis
@@ -75,6 +155,22 @@ export interface Validator {
75
155
  * call into a lookup that never happens.
76
156
  */
77
157
  kinds?: readonly string[]
158
+ /**
159
+ * What a verdict on one node depends on. Default `'node'`.
160
+ *
161
+ * `'node'`: the node itself, its ancestors, its own source span, and the
162
+ * document-scope maps on the context (`crossings`, `unknownTags`,
163
+ * `orphanClosers`, `collapsibleGradients`) — everything the incremental
164
+ * analysis knows how to keep current. Every built-in is `'node'`-scoped.
165
+ *
166
+ * `'document'`: anything else — counting headings, comparing a node with
167
+ * an unrelated one, reading `allNodes`. A verdict like that can change for
168
+ * a node the edit never touched, which the window pass cannot see, so
169
+ * registering one turns every analysis into a full pass until it is
170
+ * unregistered. Reading `allNodes` from a `'node'`-scoped validator is
171
+ * treated the same way, at runtime.
172
+ */
173
+ scope?: 'node' | 'document'
78
174
  /** Validate a node. Return diagnostics or null */
79
175
  validate(node: RedNode, context: AnalyzerContext): Diagnostic | Diagnostic[] | null
80
176
  }
@@ -91,6 +187,35 @@ export interface AnalyzerContext {
91
187
  * need cross-references still get it; everyone else stops paying for it.
92
188
  */
93
189
  readonly allNodes: Map<string, RedNode>
190
+ /**
191
+ * Openers whose `[/tag]` does exist but arrived too late, keyed by the
192
+ * opener's node id.
193
+ *
194
+ * A getter for the same reason `allNodes` is one, and asked for even more
195
+ * rarely: the only way to reach it is through a node that is already known
196
+ * to be unclosed, which on a healthy document never happens. Both validators
197
+ * that read it test `isUnclosedTag` first, so a document with no auto-closed
198
+ * tag never pays for the walk.
199
+ */
200
+ readonly crossings: ReadonlyMap<string, CrossedTags>
201
+ /**
202
+ * Unknown tags that were written as a PAIR, keyed by the opener's node id.
203
+ *
204
+ * Same lazy contract as `crossings`, and reached through an even tighter
205
+ * gate: only a text leaf whose first and last characters are brackets can
206
+ * be in it, which is three integer compares per text node.
207
+ */
208
+ readonly unknownTags: ReadonlyMap<string, UnknownTag>
209
+ /**
210
+ * Closing tags no opener claims, node id → tag name.
211
+ *
212
+ * Same walk as `unknownTags`, cached together, because the two answers are
213
+ * two halves of one pairing: what is left over after every unknown opener
214
+ * has taken its closer is a `[/tag]` that closes nothing.
215
+ */
216
+ readonly orphanClosers: ReadonlyMap<string, string>
217
+ /** Sequences of [color] tags that form a gradient, keyed by the first node's id */
218
+ readonly collapsibleGradients: ReadonlyMap<string, CollapsibleGradient>
94
219
  /** Previously collected diagnostics */
95
220
  diagnostics: DiagnosticCollection
96
221
  /** Source text for position lookups */
@@ -99,12 +224,28 @@ export interface AnalyzerContext {
99
224
 
100
225
  // ─── Analyze Result ────────────────────────────────────────────
101
226
 
227
+ /**
228
+ * How much of the tree a pass looked at.
229
+ *
230
+ * `'full'` — every node was validated afresh. `'window'` — only the nodes an
231
+ * edit could have changed were: the ones inside the reparse window, the
232
+ * ancestors on the path down to it, and any node elsewhere whose
233
+ * document-scope facts (a pairing, a crossing, a colour run) the edit
234
+ * altered. The diagnostics are complete and identical either way; the scope
235
+ * says how they were obtained.
236
+ */
237
+ export type AnalyzeScope = 'full' | 'window'
238
+
102
239
  export interface AnalyzeResult {
103
240
  diagnostics: DiagnosticCollection
104
241
  /** Time taken in ms */
105
242
  duration: number
106
243
  /** Number of nodes analyzed */
107
244
  nodesAnalyzed: number
245
+ /** See {@link AnalyzeScope}. */
246
+ scope: AnalyzeScope
247
+ /** The span re-validated by a `'window'` pass; `null` for a full one. */
248
+ window: SourceSpan | null
108
249
  }
109
250
 
110
251
  /**
@@ -132,9 +273,9 @@ export interface IndexedAnalyzeResult extends AnalyzeResult {
132
273
  * same tag three times. Requiring the node's kind to be the one the tag
133
274
  * produces pins the diagnostic to the node that actually is that tag.
134
275
  */
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]' },
276
+ const DEPRECATED_TAGS: Record<string, { kind: NodeKind, message: string, replacement: string }> = {
277
+ strike: { kind: 'strikethrough', message: 'Use [s] instead of [strike]', replacement: 's' },
278
+ center: { kind: 'center', message: 'Use [centre] instead of [center]', replacement: 'centre' },
138
279
  }
139
280
 
140
281
  /**
@@ -149,9 +290,6 @@ const DEPRECATED_KINDS = new Set<NodeKind>(
149
290
  Object.values(DEPRECATED_TAGS).map(entry => entry.kind),
150
291
  )
151
292
 
152
- /** Matches anything shaped like a BBCode tag. */
153
- const BBCODE_TAG_RE = /\[\/?[a-zA-Z0-9_*-]+(?:=[^\]]*)?\]/
154
-
155
293
  /**
156
294
  * Reads the tag name at the start of a node's range: `[quote="x"]` → `quote`.
157
295
  *
@@ -162,15 +300,41 @@ const BBCODE_TAG_RE = /\[\/?[a-zA-Z0-9_*-]+(?:=[^\]]*)?\]/
162
300
  const OPENING_TAG_RE = /\[\/?([a-zA-Z0-9_*-]+)/y
163
301
 
164
302
  /**
165
- * Kinds that are not written tags, so no closing-tag rule applies to them.
303
+ * Kinds the grammar gives no content slot at all.
304
+ *
305
+ * Lyne's `[hr]` and `[separator=stars]` are emitted by the parser as a single
306
+ * leaf spanning `[`..`]`. There is no inner range for content to occupy and no
307
+ * closing form to write, now or ever. Two validators have to know that, and
308
+ * both were getting it wrong in the same way — describing the tag's definition
309
+ * as if it were something the author had done:
310
+ *
311
+ * `empty-tag` every `[hr]` is contentless; saying so is noise, not a hint
312
+ * `unclosed-tag` worse than noise — `repairNesting` shares the
313
+ * `isUnclosedTag` predicate, so the "repair" wrote a
314
+ * `[/separator]` into the author's source, which osu!/Lyne
315
+ * then render as literal text
316
+ *
317
+ * Deliberately narrower than "void". `list_item` also has no closing form, but
318
+ * `[*]` does have a content slot, so an empty one is a real (if minor)
319
+ * observation and stays reportable as `empty-tag`.
320
+ */
321
+ const CONTENTLESS_BY_NATURE = new Set<NodeKind>(['separator'])
322
+
323
+ /**
324
+ * Kinds no closing tag is ever expected for, held in one set because the check
325
+ * below runs per node and one lookup is cheaper than two.
166
326
  *
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.
327
+ * Two reasons land a kind here. Most are not written tags at all: the check
328
+ * reads the source at a node's boundaries, and several nodes can share an
329
+ * offset — a `paragraph` wrapping `[b]x` starts at the same `[` as the `bold`
330
+ * inside it, and would otherwise be judged as an unclosed `[b]`.
331
+ *
332
+ * The rest are written but have no closing form: `list_item`, because `[*]`
333
+ * has none in BBCode at all, and whatever {@link CONTENTLESS_BY_NATURE} holds.
171
334
  */
172
- const NOT_A_WRITTEN_TAG = new Set<NodeKind>([
335
+ const NO_CLOSING_TAG_EXPECTED = new Set<NodeKind>([
173
336
  'document', 'paragraph', 'group', 'text', 'spacing', 'empty_line', 'list_item', 'error',
337
+ ...CONTENTLESS_BY_NATURE,
174
338
  ])
175
339
 
176
340
  /**
@@ -185,13 +349,289 @@ const NOT_A_WRITTEN_TAG = new Set<NodeKind>([
185
349
  * That single rule covers both shapes: `[b]x` (never closed) and `[b][i]x[/b]`
186
350
  * (where `[i]` is auto-closed by the legacy nesting rules).
187
351
  */
188
- function isUnclosedTag(node: RedNode, source: string): boolean {
189
- if (NOT_A_WRITTEN_TAG.has(node.kind)) return false
352
+ export function isUnclosedTag(node: RedNode, source: string): boolean {
353
+ if (NO_CLOSING_TAG_EXPECTED.has(node.kind)) return false
354
+ const range = node.range
355
+ return isUnclosedSpan(range.start, range.end, source)
356
+ }
357
+
358
+ /**
359
+ * `isUnclosedTag` on a span already known to belong to a tag-bearing kind.
360
+ *
361
+ * Split out so a walk that accumulates offsets from green widths can ask
362
+ * without reading `node.range` — a read that materializes the pending lazy
363
+ * shift of every displaced subtree (see `RedNode.setStart`).
364
+ */
365
+ function isUnclosedSpan(start: number, end: number, source: string): boolean {
366
+ // Por códigos de carácter y sin materializar el nombre: esto corre una vez
367
+ // por nodo de elemento del documento, y `openingTagName` construía un array
368
+ // de captura y una cadena en minúsculas para cada uno, casi siempre solo
369
+ // para compararla con el cierre y tirarla.
370
+ //
371
+ // El recorrido reproduce exactamente lo que acepta `OPENING_TAG_RE` — `[`,
372
+ // una `/` OPCIONAL, y luego uno o más `[a-zA-Z0-9_*-]` — y el nombre
373
+ // empieza DESPUÉS de esa barra, igual que el grupo de captura: un
374
+ // `discarded_tag` es literalmente `[/notice]`, y tomar su nombre desde el
375
+ // corchete daría `/notice`.
376
+ if (start < 0 || start >= source.length || source.charCodeAt(start) !== 0x5b /* [ */) return false
377
+ let i = start + 1
378
+ if (source.charCodeAt(i) === 0x2f /* / */) i++
379
+ const nameStart = i
380
+ while (i < source.length && isTagNameChar(source.charCodeAt(i))) i++
381
+ if (i === nameStart) return false
382
+ // Un `[*]` de lista no lleva cierre.
383
+ if (i - nameStart === 1 && source.charCodeAt(nameStart) === 0x2a /* * */) return false
384
+
385
+ return !endsWithClosingTagSpan(source, end, nameStart, i)
386
+ }
387
+
388
+ /** `[a-zA-Z0-9_*-]`, el alfabeto de nombres de `OPENING_TAG_RE`. */
389
+ function isTagNameChar(c: number): boolean {
390
+ return (
391
+ (c >= 0x61 && c <= 0x7a) || // a-z
392
+ (c >= 0x41 && c <= 0x5a) || // A-Z
393
+ (c >= 0x30 && c <= 0x39) || // 0-9
394
+ c === 0x5f /* _ */ || c === 0x2a /* * */ || c === 0x2d /* - */
395
+ )
396
+ }
397
+
398
+ /**
399
+ * `endsWithClosingTag`, pero comparando contra el nombre EN el propio texto
400
+ * en vez de contra una cadena ya extraída. Las dos partes se comparan sin
401
+ * distinguir mayúsculas, que es lo que hacía la versión anterior al pasar el
402
+ * nombre de apertura por `toLowerCase()`.
403
+ */
404
+ function endsWithClosingTagSpan(
405
+ source: string,
406
+ end: number,
407
+ nameStart: number,
408
+ nameEnd: number,
409
+ ): boolean {
410
+ const length = nameEnd - nameStart
411
+ const start = end - length - 3
412
+ if (start < 0) return false
413
+ if (source.charCodeAt(start) !== 0x5b /* [ */) return false
414
+ if (source.charCodeAt(start + 1) !== 0x2f /* / */) return false
415
+ if (source.charCodeAt(end - 1) !== 0x5d /* ] */) return false
416
+
417
+ for (let i = 0; i < length; i++) {
418
+ let a = source.charCodeAt(start + 2 + i)
419
+ let b = source.charCodeAt(nameStart + i)
420
+ if (a >= 0x41 && a <= 0x5a) a |= 32
421
+ if (b >= 0x41 && b <= 0x5a) b |= 32
422
+ if (a !== b) return false
423
+ }
424
+ return true
425
+ }
426
+
427
+ /**
428
+ * An opener the parser closed on the author's behalf, together with the
429
+ * `[/tag]` the author *did* write — just too late for it to count.
430
+ */
431
+ export interface CrossedTags {
432
+ /** The tag name as written, lowercased. */
433
+ tag: string
434
+ /** Where its closing tag belongs: the offset the parser already closed it at. */
435
+ at: number
436
+ /** Where the ignored `[/tag]` sits in the source. */
437
+ closer: { start: number; end: number }
438
+ }
439
+
440
+ /** The whole text of a `discarded_tag` leaf: `[/box]` → `box`. */
441
+ const DISCARDED_CLOSING_TAG = /^\[\/([a-zA-Z0-9_*-]+)\]$/
442
+
443
+ // ─── Candidates ────────────────────────────────────────────────
444
+ //
445
+ // The document-scope maps — `crossings`, `unknownTags`/`orphanClosers`,
446
+ // `collapsibleGradients` — are functions of a SMALL set of nodes: auto-closed
447
+ // openers and discarded closers, bracketed text leaves, children lists with a
448
+ // run of colours. On a healthy document the sets are empty; on the 547 KB
449
+ // fixture they hold a few hundred entries among 38.000 nodes.
450
+ //
451
+ // A full pass finds them in the same walk that validates. A window pass does
452
+ // not walk the document: it keeps the candidates it did not visit — they sit
453
+ // in adopted subtrees, whose text is byte-identical to what it was — and
454
+ // collects afresh only inside the window. Every record therefore carries the
455
+ // node it came from and the offsets it was collected at, so the next pass can
456
+ // tell an entry that merely moved (same, shifted by the edit) from one whose
457
+ // meaning changed.
458
+
459
+ interface LiteralCandidate {
460
+ node: RedNode
461
+ /** The name as written, lowercased. */
462
+ tag: string
463
+ /** `[/tag]` rather than `[tag]`. */
464
+ close: boolean
465
+ start: number
466
+ end: number
467
+ }
468
+
469
+ interface OpenerCandidate {
470
+ node: RedNode
471
+ tag: string
472
+ start: number
473
+ /** Where the parser closed it — `range.end`. */
474
+ end: number
475
+ }
476
+
477
+ interface CloserCandidate {
478
+ node: RedNode
479
+ tag: string
480
+ start: number
481
+ end: number
482
+ }
483
+
484
+ interface GradientRun {
485
+ /** The node whose children list holds the run. */
486
+ parent: RedNode
487
+ /** The run's first colour node — the one the diagnostic is filed on. */
488
+ node: RedNode
489
+ /** `node`'s start offset when the run was computed. */
490
+ start: number
491
+ item: CollapsibleGradient
492
+ }
493
+
494
+ interface Candidates {
495
+ literals: LiteralCandidate[]
496
+ openers: OpenerCandidate[]
497
+ closers: CloserCandidate[]
498
+ gradients: GradientRun[]
499
+ }
500
+
501
+ function emptyCandidates(): Candidates {
502
+ return { literals: [], openers: [], closers: [], gradients: [] }
503
+ }
504
+
505
+ /**
506
+ * Collect what `node` contributes to the document-scope maps.
507
+ *
508
+ * `start` is the node's absolute offset, accumulated by the caller from green
509
+ * widths; `inCode` says whether an enclosing raw block makes its brackets
510
+ * content rather than syntax.
511
+ */
512
+ function collectCandidatesAt(
513
+ node: RedNode,
514
+ start: number,
515
+ inCode: boolean,
516
+ source: string,
517
+ out: Candidates,
518
+ ): void {
519
+ const kind = node.kind
520
+ const width = node.green.width
521
+
522
+ if (kind === 'text') {
523
+ if (inCode) return
524
+ const text = node.text
525
+ // Three integer compares before any regex: this runs on every text leaf
526
+ // of the document, and almost none of them are a bracketed tag.
527
+ if (
528
+ text.length >= 3 &&
529
+ text.charCodeAt(0) === 0x5b /* [ */ &&
530
+ text.charCodeAt(text.length - 1) === 0x5d /* ] */ &&
531
+ // A leaf whose text is not its own source span did not come from the
532
+ // BBCode parser — an HTML import, say — and its offsets would not
533
+ // point at the characters this reports.
534
+ source.slice(start, start + width) === text
535
+ ) {
536
+ const open = LITERAL_OPEN.exec(text)
537
+ if (open) {
538
+ out.literals.push({ node, tag: open[1].toLowerCase(), close: false, start, end: start + width })
539
+ } else {
540
+ const close = LITERAL_CLOSE.exec(text)
541
+ if (close) {
542
+ out.literals.push({ node, tag: close[1].toLowerCase(), close: true, start, end: start + width })
543
+ }
544
+ }
545
+ }
546
+ return
547
+ }
548
+
549
+ if (kind === 'discarded_tag') {
550
+ const match = DISCARDED_CLOSING_TAG.exec(node.text)
551
+ if (match) out.closers.push({ node, tag: match[1].toLowerCase(), start, end: start + width })
552
+ return
553
+ }
554
+
555
+ if (!NO_CLOSING_TAG_EXPECTED.has(kind) && isUnclosedSpan(start, start + width, source)) {
556
+ OPENING_TAG_RE.lastIndex = start
557
+ const match = OPENING_TAG_RE.exec(source)
558
+ if (match) out.openers.push({ node, tag: match[1].toLowerCase(), start, end: start + width })
559
+ }
560
+
561
+ const children = node.children
562
+ if (children.length >= 3) {
563
+ const items: CollapsibleGradient[] = []
564
+ if (collapsibleGradientsAt(node.green, start, items) > 0) {
565
+ // Each run starts at one of the children; items come out in child
566
+ // order, so one forward cursor over the offsets finds them all.
567
+ let offset = start + node.green.leadingWidth
568
+ let i = 0
569
+ for (const item of items) {
570
+ while (i < children.length && offset < item.range.start) {
571
+ offset += children[i].green.width
572
+ i++
573
+ }
574
+ if (i < children.length && offset === item.range.start) {
575
+ out.gradients.push({ parent: node, node: children[i], start: offset, item })
576
+ }
577
+ }
578
+ }
579
+ }
580
+ }
581
+
582
+ /** Pre-order over the document: by start, outer node first at a tie. */
583
+ function byDocumentOrder(a: { start: number; end: number }, b: { start: number; end: number }): number {
584
+ return a.start - b.start || b.end - a.end
585
+ }
586
+
587
+ /**
588
+ * Pairs every auto-closed opener with the stranded `[/tag]` that was meant for
589
+ * it, so crossed tags stop being reported as missing ones.
590
+ *
591
+ * The tree already holds both halves and nothing else has to be recomputed.
592
+ * When `[/centre]` arrives over an open `[notice][box]`, the parser closes both
593
+ * inner frames and records their names; the `[/box]` and `[/notice]` that
594
+ * follow find their name already spent and land as `discarded_tag` leaves,
595
+ * which keep their range precisely so this is answerable later.
596
+ *
597
+ * The pairing mirrors what the parser did rather than guessing at it. Its
598
+ * `autoClosed` is a Set keyed by name and consumed on use, so a stranded
599
+ * `[/tag]` belongs to the most recently opened frame of that name that was
600
+ * already closed by the time it appeared. Both lists arrive in document
601
+ * (pre-)order, so within one name the innermost frame is the last one
602
+ * collected — which is why the search runs backwards and stops at the first
603
+ * unclaimed match.
604
+ */
605
+ function pairCrossings(openers: OpenerCandidate[], closers: CloserCandidate[]): Map<string, CrossedTags> {
606
+ const crossings = new Map<string, CrossedTags>()
607
+ if (openers.length === 0 || closers.length === 0) return crossings
190
608
 
191
- const name = openingTagName(node, source)
192
- if (!name || name === '*') return false
609
+ const byTag = new Map<string, OpenerCandidate[]>()
610
+ for (const opener of openers) {
611
+ const list = byTag.get(opener.tag)
612
+ if (list) list.push(opener)
613
+ else byTag.set(opener.tag, [opener])
614
+ }
615
+
616
+ for (const closer of closers) {
617
+ const candidates = byTag.get(closer.tag)
618
+ if (candidates === undefined) continue
619
+ for (let i = candidates.length - 1; i >= 0; i--) {
620
+ const opener = candidates[i]
621
+ // A closer cannot belong to a frame that was still open when it arrived,
622
+ // nor to one another closer already claimed.
623
+ if (opener.end > closer.start) continue
624
+ if (crossings.has(opener.node.id)) continue
625
+ crossings.set(opener.node.id, {
626
+ tag: closer.tag,
627
+ at: opener.end,
628
+ closer: { start: closer.start, end: closer.end },
629
+ })
630
+ break
631
+ }
632
+ }
193
633
 
194
- return !endsWithClosingTag(source, node.range.end, name)
634
+ return crossings
195
635
  }
196
636
 
197
637
  /**
@@ -204,7 +644,7 @@ function isUnclosedTag(node: RedNode, source: string): boolean {
204
644
  *
205
645
  * Returns null for nodes that do not correspond to a written tag.
206
646
  */
207
- function openingTagName(node: RedNode, source: string): string | null {
647
+ export function openingTagName(node: RedNode, source: string): string | null {
208
648
  const { start } = node.range
209
649
  if (start < 0 || start >= source.length || source.charCodeAt(start) !== 0x5b /* [ */) return null
210
650
  OPENING_TAG_RE.lastIndex = start
@@ -212,6 +652,25 @@ function openingTagName(node: RedNode, source: string): string | null {
212
652
  return match ? match[1].toLowerCase() : null
213
653
  }
214
654
 
655
+ /**
656
+ * The span of the *name* inside this node's closing tag, or null if the author
657
+ * never wrote one.
658
+ *
659
+ * A rename has to touch both ends: rewriting only the opening `[strike]` to
660
+ * `[s]` leaves an orphan `[/strike]` that osu! renders as literal text — worse
661
+ * than the deprecation it was fixing. The name sits between `[/` and `]`, so it
662
+ * ends one character before the node and runs back its own length.
663
+ */
664
+ export function closingTagNameRange(
665
+ node: RedNode,
666
+ source: string,
667
+ name: string,
668
+ ): { start: number; end: number } | null {
669
+ const { end } = node.range
670
+ if (!endsWithClosingTag(source, end, name)) return null
671
+ return { start: end - name.length - 1, end: end - 1 }
672
+ }
673
+
215
674
  /**
216
675
  * Does `source` end with `[/name]` at offset `end`?
217
676
  *
@@ -235,8 +694,457 @@ function endsWithClosingTag(source: string, end: number, name: string): boolean
235
694
  return true
236
695
  }
237
696
 
697
+ /**
698
+ * An unknown tag the author wrote as a pair: `[bold]x[/bold]`.
699
+ *
700
+ * The pairing is the whole signal. An unknown tag becomes literal text on
701
+ * purpose — `[Gateron]` in prose has to stay visible, and a real corpus of 53
702
+ * userpages carries `[gb]`, `[insane]`, `[rm120]` and a dozen more of those —
703
+ * so a bare `[name]` is not evidence of anything. A `[name]` with a matching
704
+ * `[/name]` is: nobody closes a bracketed aside. That same corpus contains
705
+ * exactly ZERO of them, which is the false-positive budget this rule spends.
706
+ */
707
+ export interface UnknownTag {
708
+ /** The name as written, lowercased. */
709
+ tag: string
710
+ /** The `[tag]` the parser refused, kept as literal text. */
711
+ opener: { start: number; end: number }
712
+ /** Its `[/tag]` — what makes this a misspelling rather than prose. */
713
+ closer: { start: number; end: number }
714
+ }
715
+
716
+ /** A text leaf that is exactly an opening tag: `[bold]` or `[bold=x]`. */
717
+ const LITERAL_OPEN = /^\[([a-zA-Z][a-zA-Z0-9_-]*)(?:=[^\]]*)?\]$/
718
+ /** A text leaf that is exactly a closing tag: `[/bold]`. */
719
+ const LITERAL_CLOSE = /^\[\/([a-zA-Z][a-zA-Z0-9_-]*)\]$/
720
+
721
+ /**
722
+ * Finds unknown tags the author closed, so the checker can stop being silent
723
+ * about them.
724
+ *
725
+ * Nothing here decides what a valid tag is, and that is deliberate: the parser
726
+ * already decided, by refusing the tag and emitting its text verbatim as a
727
+ * leaf. Reading that decision back keeps the rule correct in every dialect for
728
+ * free, where a name list of its own would drift the moment a tag is added.
729
+ *
730
+ * `[code]` is excluded by walking with the flag rather than testing the node,
731
+ * because there the brackets are content the author typed on purpose — and
732
+ * `nested-tags-in-code` already covers that case with the right message.
733
+ */
734
+ interface LiteralTagScan {
735
+ /** Unknown openers the author closed, keyed by the OPENER's node id. */
736
+ paired: Map<string, UnknownTag>
737
+ /** Closing tags no opener claims, keyed by node id → the tag name. */
738
+ orphans: Map<string, string>
739
+ }
740
+
741
+ function pairLiteralTags(literals: LiteralCandidate[]): LiteralTagScan {
742
+ const paired = new Map<string, UnknownTag>()
743
+ const orphans = new Map<string, string>()
744
+ if (literals.length === 0) return { paired, orphans }
745
+
746
+ // `literals` is in document order, so the first unused match is the nearest.
747
+ //
748
+ // Indexed by tag name, with a cursor per name: the flat scan re-walked the
749
+ // consumed prefix for every opener, which is free on a healthy document —
750
+ // both lists are empty — and quadratic on the one full of unknown pairs.
751
+ // That is exactly the document a checker exists for.
752
+ const closesByTag = new Map<string, LiteralCandidate[]>()
753
+ for (const candidate of literals) {
754
+ if (!candidate.close) continue
755
+ const list = closesByTag.get(candidate.tag)
756
+ if (list) list.push(candidate)
757
+ else closesByTag.set(candidate.tag, [candidate])
758
+ }
759
+ const cursorByTag = new Map<string, number>()
760
+ const used = new Set<LiteralCandidate>()
761
+
762
+ for (const open of literals) {
763
+ if (open.close) continue
764
+ const candidates = closesByTag.get(open.tag)
765
+ if (candidates === undefined) continue
766
+ let i = cursorByTag.get(open.tag) ?? 0
767
+ // Openers arrive in document order too, so a closer skipped for sitting
768
+ // before this opener sits before every later one as well. The cursor
769
+ // therefore only ever moves forward.
770
+ while (i < candidates.length && candidates[i].start < open.end) i++
771
+ if (i >= candidates.length) {
772
+ cursorByTag.set(open.tag, i)
773
+ continue
774
+ }
775
+ const close = candidates[i]
776
+ used.add(close)
777
+ cursorByTag.set(open.tag, i + 1)
778
+ paired.set(open.node.id, {
779
+ tag: open.tag,
780
+ opener: { start: open.start, end: open.end },
781
+ closer: { start: close.start, end: close.end },
782
+ })
783
+ }
784
+
785
+ // Whatever no opener claimed closes nothing at all. Pairing has to run first:
786
+ // the `[/bold]` of `[bold]x[/bold]` is not an orphan, it is the evidence that
787
+ // made its opener a typo, and `unknown-tag` already reports the pair.
788
+ for (const candidate of literals) {
789
+ if (candidate.close && !used.has(candidate)) orphans.set(candidate.node.id, candidate.tag)
790
+ }
791
+
792
+ return { paired, orphans }
793
+ }
794
+
795
+ /**
796
+ * Names people reach for that BBCode does not have.
797
+ *
798
+ * Edit distance cannot find these — `bold` is three edits away from `b` — and
799
+ * they are the most common way to end up with an unknown tag at all: writing
800
+ * the word for what you mean, when the format spells it with one letter.
801
+ */
802
+ const COMMON_MISNOMERS: Record<string, string> = {
803
+ bold: 'b', strong: 'b',
804
+ italic: 'i', italics: 'i', em: 'i',
805
+ strikethrough: 's', strikeout: 's', del: 's',
806
+ underline: 'u',
807
+ link: 'url',
808
+ image: 'img', picture: 'img', pic: 'img',
809
+ video: 'youtube', yt: 'youtube',
810
+ header: 'heading', title: 'heading', h1: 'heading', h2: 'heading', h3: 'heading',
811
+ hide: 'spoiler',
812
+ }
813
+
814
+ /**
815
+ * Levenshtein distance, abandoned as soon as it cannot beat `limit`.
816
+ *
817
+ * Two rows rather than a matrix, and the early exit matters: this runs against
818
+ * every known tag name, and most of them are nowhere near the misspelling.
819
+ */
820
+ function editDistance(a: string, b: string, limit: number): number {
821
+ let prev = new Array<number>(b.length + 1)
822
+ let curr = new Array<number>(b.length + 1)
823
+ for (let j = 0; j <= b.length; j++) prev[j] = j
824
+
825
+ for (let i = 1; i <= a.length; i++) {
826
+ curr[0] = i
827
+ let rowBest = curr[0]
828
+ for (let j = 1; j <= b.length; j++) {
829
+ const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1
830
+ curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost)
831
+ if (curr[j] < rowBest) rowBest = curr[j]
832
+ }
833
+ if (rowBest >= limit) return limit
834
+ const swap = prev; prev = curr; curr = swap
835
+ }
836
+ return prev[b.length]
837
+ }
838
+
839
+ /**
840
+ * The tag the author probably meant, or null when guessing would be worse than
841
+ * saying nothing.
842
+ *
843
+ * Misnomers first, because they are exact. Then a typo within two edits, and
844
+ * only for names of four characters or more — under that, two edits reaches
845
+ * most of the one-letter tags from almost anything.
846
+ */
847
+ function suggestTag(tag: string, known: readonly string[]): string | null {
848
+ const misnomer = COMMON_MISNOMERS[tag]
849
+ if (misnomer !== undefined && known.includes(misnomer)) return misnomer
850
+ if (tag.length < 4) return null
851
+
852
+ let best: string | null = null
853
+ let bestDistance = 3
854
+ for (const name of known) {
855
+ if (name.length < 3) continue
856
+ // Never propose a spelling that `deprecated-tag` would flag on the next
857
+ // pass. `[centr]` sits one edit from `center` AND from `centre`, and the
858
+ // sorted list offers the outdated one first.
859
+ if (name in DEPRECATED_TAGS) continue
860
+ if (Math.abs(name.length - tag.length) >= bestDistance) continue
861
+ const distance = editDistance(tag, name, bestDistance)
862
+ if (distance < bestDistance) { bestDistance = distance; best = name }
863
+ }
864
+ return best
865
+ }
866
+
867
+ /**
868
+ * Schemes a BBCode link is allowed to carry.
869
+ *
870
+ * osu!'s renderer emits the href verbatim, so anything the browser will
871
+ * *execute* rather than *navigate to* is a real hazard in a shared post, not a
872
+ * style preference. `mailto:` is here because osu! profiles legitimately use
873
+ * it; everything else is either inert (`data:`) or dangerous (`javascript:`).
874
+ */
875
+ const ALLOWED_URL_SCHEMES = new Set(['http:', 'https:', 'mailto:'])
876
+
877
+ /** `https://x` → `https:`; `www.x.com` → null. Deliberately RFC-shaped. */
878
+ const URL_SCHEME_RE = /^([a-z][a-z0-9+.\-]*:)/i
879
+
880
+ /**
881
+ * How deep quotes may nest before osu! renders them as an unreadable stack.
882
+ *
883
+ * Three is the legacy renderer's own comfortable limit: past it each level
884
+ * loses another ~2em of width and the innermost quote wraps to one word a line.
885
+ */
886
+ const MAX_QUOTE_DEPTH = 3
887
+
888
+ /**
889
+ * Inline kinds that mean nothing when nested inside themselves.
890
+ *
891
+ * `[b][b]x[/b][/b]` is not an error — it renders exactly like `[b]x[/b]` — but
892
+ * it is always an accident, usually from pasting styled text twice. Deliberately
893
+ * limited to the kinds whose effect is idempotent: `[color]` inside `[color]`
894
+ * is meaningful (the inner one wins) and must never be reported.
895
+ */
896
+ const SELF_NESTING_REDUNDANT = new Set<NodeKind>([
897
+ 'bold', 'italic', 'underline', 'strikethrough',
898
+ ])
899
+
900
+ /**
901
+ * Where a link's destination actually sits in the source.
902
+ *
903
+ * Both BBCode link forms put the href somewhere inside the node — as an
904
+ * attribute in `[url=https://x]`, as the content in `[url]https://x[/url]` —
905
+ * and a diagnostic that underlines the whole tag when only the scheme is wrong
906
+ * makes the reader hunt for it. Searching the node's own span finds either form
907
+ * without the validator having to know which one it is looking at.
908
+ *
909
+ * Falls back to the node's range when the href was normalised by the parser and
910
+ * no longer appears literally (a trailing slash added, say): a slightly wide
911
+ * underline beats no location at all.
912
+ */
913
+ /**
914
+ * The span of a node's opening tag alone: `[quote="x"]` out of the whole quote.
915
+ *
916
+ * A structural finding — nesting too deep, a block in the wrong place — is
917
+ * *about* the whole node but has to be *pointed at* its opening tag. Underlining
918
+ * a four-deep quote's full range highlights most of the document to say "this is
919
+ * too deep", which tells the reader nothing about where to cut.
920
+ *
921
+ * Falls back to the node's own range when there is no `]` inside it, which only
922
+ * happens for nodes that are not written tags.
923
+ */
924
+ function openingTagRange(node: RedNode, source: string): { start: number; end: number } {
925
+ const close = source.indexOf(']', node.range.start)
926
+ if (close < 0 || close >= node.range.end) return node.range
927
+ return { start: node.range.start, end: close + 1 }
928
+ }
929
+
930
+ function hrefRange(node: RedNode, source: string, href: string): { start: number; end: number } {
931
+ const idx = source.indexOf(href, node.range.start)
932
+ if (idx >= 0 && idx + href.length <= node.range.end) {
933
+ return { start: idx, end: idx + href.length }
934
+ }
935
+ return node.range
936
+ }
937
+
938
+ // ─── Incremental analysis ──────────────────────────────────────
939
+ //
940
+ // What a window pass keeps from the previous pass, and why each part is
941
+ // enough. The contract it rests on is the incremental parser's: after an edit
942
+ // with red-subtree reuse, every node outside `ReparseResult.window` (and off
943
+ // the path down to it) is the SAME object it was, over the same text, under
944
+ // ancestors of the same kinds. So for those nodes:
945
+ //
946
+ // - every node-scoped verdict still holds, and only its OFFSETS may have
947
+ // moved — by exactly the node's own displacement, which `diagStarts`
948
+ // lets the next pass measure without a walk;
949
+ // - every document-scope fact they contribute (a bracketed leaf, an
950
+ // auto-closed opener, a colour run) is still contributed, at shifted
951
+ // offsets — so `candidates` are kept rather than re-found;
952
+ // - what CAN change for them is a document-scope verdict whose other half
953
+ // sits in the window: a `[/bold]` typed for a `[bold]` far above, a
954
+ // `[/b]` that now pairs with an opener elsewhere, a colour run that grew
955
+ // or broke at the window's edge. The previous maps are kept so the next
956
+ // pass can diff them and re-validate exactly the nodes whose entry
957
+ // changed — nothing more, and (checked differentially against a full
958
+ // pass on every edit of the fuzz and battery suites) nothing less.
959
+
960
+ interface DocumentMaps {
961
+ literal: LiteralTagScan
962
+ crossings: Map<string, CrossedTags>
963
+ gradients: Map<string, CollapsibleGradient>
964
+ }
965
+
966
+ interface AnalysisSnapshot {
967
+ root: RedNode
968
+ collection: DiagnosticCollection
969
+ /** Nodes carrying at least one diagnostic, in document order… */
970
+ diagNodes: RedNode[]
971
+ /** …and the start offset each one had when it was validated. */
972
+ diagStarts: number[]
973
+ candidates: Candidates
974
+ maps: DocumentMaps
975
+ validatorsVersion: number
976
+ tokensVersion: number
977
+ /**
978
+ * A validator declared `'document'` scope, or read `allNodes`: no window
979
+ * pass can be trusted on top of this one.
980
+ */
981
+ documentScoped: boolean
982
+ }
983
+
984
+ /** Every node of the tree by id — the index `allNodes` hands out. */
985
+ function indexNodes(root: RedNode): Map<string, RedNode> {
986
+ const index = new Map<string, RedNode>()
987
+ root.walk(node => { index.set(node.id, node) })
988
+ return index
989
+ }
990
+
991
+ /** Whether `node` still hangs from `root` — false for a node an edit replaced. */
992
+ function isAttached(node: RedNode, root: RedNode): boolean {
993
+ let n: RedNode = node
994
+ while (n.parent !== null) n = n.parent
995
+ return n === root
996
+ }
997
+
998
+ function depthOf(node: RedNode): number {
999
+ let d = 0
1000
+ for (let n = node.parent; n !== null; n = n.parent) d++
1001
+ return d
1002
+ }
1003
+
1004
+ function deriveMaps(candidates: Candidates): DocumentMaps {
1005
+ const gradients = new Map<string, CollapsibleGradient>()
1006
+ for (const run of candidates.gradients) gradients.set(run.node.id, run.item)
1007
+ return {
1008
+ literal: pairLiteralTags(candidates.literals),
1009
+ crossings: pairCrossings(candidates.openers, candidates.closers),
1010
+ gradients,
1011
+ }
1012
+ }
1013
+
1014
+ /**
1015
+ * Walk `node`'s subtree in pre-order, listing every node and collecting its
1016
+ * candidates. Offsets are accumulated from green widths, never read from
1017
+ * `range` — see `isUnclosedSpan`.
1018
+ */
1019
+ function collectSubtree(
1020
+ node: RedNode,
1021
+ start: number,
1022
+ inCode: boolean,
1023
+ source: string,
1024
+ out: Candidates,
1025
+ nodes: RedNode[],
1026
+ ): void {
1027
+ nodes.push(node)
1028
+ collectCandidatesAt(node, start, inCode, source, out)
1029
+ const children = node.children
1030
+ if (children.length === 0) return
1031
+ const code = inCode || node.kind === 'code' || node.kind === 'inline_code'
1032
+ let offset = start + node.green.leadingWidth
1033
+ for (let i = 0; i < children.length; i++) {
1034
+ collectSubtree(children[i], offset, code, source, out, nodes)
1035
+ offset += children[i].green.width
1036
+ }
1037
+ }
1038
+
1039
+ /**
1040
+ * The nodes a window pass has to look at: the path from the root down to the
1041
+ * window, and everything inside it.
1042
+ *
1043
+ * A child is entered when its span overlaps the window (it is one of the
1044
+ * re-parsed siblings, so its whole subtree is new) or contains it (it is on
1045
+ * the path down, so only the node itself is new and the descent continues).
1046
+ * Adopted siblings touch the window at a boundary and satisfy neither —
1047
+ * except when the window is empty (every re-parsed sibling was deleted),
1048
+ * where the two neighbours are entered and re-validated for nothing, which
1049
+ * is harmless.
1050
+ */
1051
+ function descendWindow(
1052
+ node: RedNode,
1053
+ start: number,
1054
+ inCode: boolean,
1055
+ window: SourceSpan,
1056
+ source: string,
1057
+ out: Candidates,
1058
+ nodes: RedNode[],
1059
+ ): void {
1060
+ nodes.push(node)
1061
+ collectCandidatesAt(node, start, inCode, source, out)
1062
+ const children = node.children
1063
+ if (children.length === 0) return
1064
+ const code = inCode || node.kind === 'code' || node.kind === 'inline_code'
1065
+ let offset = start + node.green.leadingWidth
1066
+ for (let i = 0; i < children.length; i++) {
1067
+ const childStart = offset
1068
+ const childEnd = offset + children[i].green.width
1069
+ if (
1070
+ (childStart < window.end && childEnd > window.start) ||
1071
+ (childStart <= window.start && window.end <= childEnd)
1072
+ ) {
1073
+ descendWindow(children[i], childStart, code, window, source, out, nodes)
1074
+ }
1075
+ offset = childEnd
1076
+ }
1077
+ }
1078
+
1079
+ function shiftRange(range: Range | null, delta: number): Range | null {
1080
+ return range === null ? null : { start: range.start + delta, end: range.end + delta }
1081
+ }
1082
+
1083
+ /**
1084
+ * The same diagnostic, `delta` characters further on. A copy, not a
1085
+ * mutation: the previous collection may still be in a subscriber's hands.
1086
+ */
1087
+ function shiftDiagnostic(diagnostic: Diagnostic, delta: number): Diagnostic {
1088
+ const shifted: Diagnostic = { ...diagnostic, range: shiftRange(diagnostic.range, delta) }
1089
+ if (diagnostic.related !== undefined) {
1090
+ shifted.related = diagnostic.related.map(r => ({ ...r, range: shiftRange(r.range, delta) }))
1091
+ }
1092
+ if (diagnostic.fixes !== undefined) {
1093
+ shifted.fixes = diagnostic.fixes.map(fix => ({
1094
+ ...fix,
1095
+ operations: fix.operations.map(op =>
1096
+ op.kind === 'insert_text'
1097
+ ? { ...op, position: op.position + delta }
1098
+ : { ...op, range: { start: op.range.start + delta, end: op.range.end + delta } },
1099
+ ),
1100
+ }))
1101
+ }
1102
+ return shifted
1103
+ }
1104
+
1105
+ // Entry comparisons for the diff, invariant under a uniform shift of the
1106
+ // node they are filed on: an entry that merely moved with its node is the
1107
+ // same entry. Anything whose other half moved differently — because it sits
1108
+ // on the other side of the edit — compares different, and the node is
1109
+ // re-validated.
1110
+ function sameUnknownTag(a: UnknownTag | undefined, b: UnknownTag | undefined, shift: number): boolean {
1111
+ if (a === undefined || b === undefined) return a === b
1112
+ return (
1113
+ a.tag === b.tag &&
1114
+ a.opener.start + shift === b.opener.start && a.opener.end + shift === b.opener.end &&
1115
+ a.closer.start + shift === b.closer.start && a.closer.end + shift === b.closer.end
1116
+ )
1117
+ }
1118
+
1119
+ function sameCrossing(a: CrossedTags | undefined, b: CrossedTags | undefined, shift: number): boolean {
1120
+ if (a === undefined || b === undefined) return a === b
1121
+ return (
1122
+ a.tag === b.tag && a.at + shift === b.at &&
1123
+ a.closer.start + shift === b.closer.start && a.closer.end + shift === b.closer.end
1124
+ )
1125
+ }
1126
+
1127
+ function sameGradient(
1128
+ a: CollapsibleGradient | undefined,
1129
+ b: CollapsibleGradient | undefined,
1130
+ shift: number,
1131
+ ): boolean {
1132
+ if (a === undefined || b === undefined) return a === b
1133
+ // Everything the diagnostic is built from: where, how many, and the exact
1134
+ // replacement (which encodes the stops, the easing and the run's text).
1135
+ return (
1136
+ a.range.start + shift === b.range.start && a.range.end + shift === b.range.end &&
1137
+ a.colorCount === b.colorCount && a.replacementText === b.replacementText
1138
+ )
1139
+ }
1140
+
238
1141
  // ─── SemanticAnalyzer ──────────────────────────────────────────
239
1142
 
1143
+ export interface SemanticAnalyzerOptions {
1144
+ dialect?: BBCodeDialect
1145
+ tokens?: TokenSource
1146
+ }
1147
+
240
1148
  export class SemanticAnalyzer {
241
1149
  private validators: Map<string, Validator> = new Map()
242
1150
 
@@ -252,8 +1160,57 @@ export class SemanticAnalyzer {
252
1160
  */
253
1161
  private _always: Validator[] = []
254
1162
  private _byKind: Map<string, Validator[]> = new Map()
1163
+ /** Set by register/unregister; cleared by the next `rebuildDispatch`. */
1164
+ private _dispatchDirty: boolean = true
1165
+
1166
+ /**
1167
+ * The dialect this analyzer validates against.
1168
+ *
1169
+ * Only the suggestion for an unknown tag reads it — detection never needs a
1170
+ * name list, because the parser already refused the tag. `DocumentModel`
1171
+ * builds the analyzer before a subclass knows its dialect, so this is set
1172
+ * afterwards rather than taken by the constructor.
1173
+ */
1174
+ dialect: BBCodeDialect = 'miliastry'
1175
+
1176
+ private _tokens?: TokenSource
1177
+ private _tokenResolver?: TokenResolverFn
1178
+
1179
+ // ─── Incremental state ──────────────────────────────────
1180
+ //
1181
+ // A window pass is only valid on top of a pass that ran the same validators
1182
+ // against the same tokens: either changing invalidates every kept verdict.
1183
+ private _validatorsVersion = 0
1184
+ private _tokensVersion = 0
1185
+ private _snapshot: AnalysisSnapshot | null = null
1186
+
1187
+ get tokens(): TokenSource | undefined {
1188
+ return this._tokens
1189
+ }
1190
+
1191
+ set tokens(val: TokenSource | undefined) {
1192
+ if (val !== this._tokens) this._tokensVersion++
1193
+ this._tokens = val
1194
+ this._tokenResolver = toTokenResolver(val)
1195
+ }
1196
+
1197
+ get tokenResolver(): TokenResolverFn | undefined {
1198
+ return this._tokenResolver
1199
+ }
1200
+
1201
+ setTokens(tokens?: TokenSource): void {
1202
+ this.tokens = tokens
1203
+ }
255
1204
 
256
- constructor() {
1205
+ constructor(options?: SemanticAnalyzerOptions | BBCodeDialect) {
1206
+ if (typeof options === 'string') {
1207
+ this.dialect = options
1208
+ } else if (options) {
1209
+ if (options.dialect) this.dialect = options.dialect
1210
+ if (options.tokens) {
1211
+ this.tokens = options.tokens
1212
+ }
1213
+ }
257
1214
  this.registerBuiltinValidators()
258
1215
  }
259
1216
 
@@ -262,7 +1219,8 @@ export class SemanticAnalyzer {
262
1219
  */
263
1220
  register(validator: Validator): void {
264
1221
  this.validators.set(validator.code, validator)
265
- this.rebuildDispatch()
1222
+ this._dispatchDirty = true
1223
+ this._validatorsVersion++
266
1224
  }
267
1225
 
268
1226
  /**
@@ -270,10 +1228,30 @@ export class SemanticAnalyzer {
270
1228
  */
271
1229
  unregister(code: string): void {
272
1230
  this.validators.delete(code)
273
- this.rebuildDispatch()
1231
+ this._dispatchDirty = true
1232
+ this._validatorsVersion++
1233
+ }
1234
+
1235
+ /** Whether any registered validator declared `'document'` scope. */
1236
+ private hasDocumentScopedValidator(): boolean {
1237
+ for (const validator of this.validators.values()) {
1238
+ if (validator.scope === 'document') return true
1239
+ }
1240
+ return false
274
1241
  }
275
1242
 
1243
+ /**
1244
+ * Rebuild the kind buckets, unless they are already current.
1245
+ *
1246
+ * `register` used to rebuild them itself, so constructing an analyzer —
1247
+ * which registers thirteen built-ins — rebuilt the whole table thirteen
1248
+ * times and threw away the first twelve. A plugin registering a batch paid
1249
+ * the same way. Registration now only marks the table stale and `analyze`
1250
+ * rebuilds it at most once, whatever the batch size.
1251
+ */
276
1252
  private rebuildDispatch(): void {
1253
+ if (!this._dispatchDirty) return
1254
+ this._dispatchDirty = false
277
1255
  this._always = []
278
1256
  this._byKind = new Map()
279
1257
  for (const validator of this.validators.values()) {
@@ -290,35 +1268,323 @@ export class SemanticAnalyzer {
290
1268
  }
291
1269
 
292
1270
  /**
293
- * Analyze a Red Tree and produce diagnostics.
1271
+ * Analyze a Red Tree and produce diagnostics — every node, from scratch.
1272
+ *
1273
+ * Also the base a later {@link analyzeWindow} builds on: the pass remembers
1274
+ * which nodes carry diagnostics and which ones feed the document-scope
1275
+ * maps, so the next edit can re-validate its window alone.
1276
+ */
1277
+ analyze(
1278
+ root: RedNode,
1279
+ source: string,
1280
+ tokensOrOptions?: TokenSource | { tokens?: TokenSource },
1281
+ ): IndexedAnalyzeResult {
1282
+ if (tokensOrOptions !== undefined) {
1283
+ if (
1284
+ typeof tokensOrOptions === 'object' &&
1285
+ tokensOrOptions !== null &&
1286
+ 'tokens' in tokensOrOptions &&
1287
+ !('palette' in tokensOrOptions) &&
1288
+ !('variables' in tokensOrOptions)
1289
+ ) {
1290
+ this.tokens = (tokensOrOptions as { tokens?: TokenSource }).tokens
1291
+ } else {
1292
+ this.tokens = tokensOrOptions as TokenSource
1293
+ }
1294
+ }
1295
+ const startTime = performance.now()
1296
+ if (this._dispatchDirty) this.rebuildDispatch()
1297
+
1298
+ // One walk lists the nodes and collects the candidates; the maps are
1299
+ // derived from those before any validator runs, so a validator asking
1300
+ // for `crossings` on the first node already sees the whole document.
1301
+ // The gradient runs used to be found by a second walk over the green
1302
+ // tree plus a `findNodeAtOffset` per run (1.9 ms for 600 runs on the
1303
+ // 547 KB fixture); they are picked up per children list here instead.
1304
+ const candidates = emptyCandidates()
1305
+ const nodes: RedNode[] = []
1306
+ collectSubtree(root, root.range.start, false, source, candidates, nodes)
1307
+ const maps = deriveMaps(candidates)
1308
+
1309
+ const diagnostics = createDiagnosticCollection()
1310
+ const flags = { sawAllNodes: false }
1311
+ const context = this.makeContext(root, source, diagnostics, maps, flags)
1312
+ this.validateNodes(nodes, context, diagnostics)
1313
+
1314
+ // Diagnostic-bearing nodes come out in pass order, which is pre-order —
1315
+ // the same order the collection's items are in.
1316
+ const diagNodes: RedNode[] = []
1317
+ const diagStarts: number[] = []
1318
+ for (let i = 0; i < nodes.length; i++) {
1319
+ const node = nodes[i]
1320
+ if (node.diagnostics.length > 0) {
1321
+ diagNodes.push(node)
1322
+ diagStarts.push(node.range.start)
1323
+ }
1324
+ }
1325
+ this._snapshot = {
1326
+ root,
1327
+ collection: diagnostics,
1328
+ diagNodes,
1329
+ diagStarts,
1330
+ candidates,
1331
+ maps,
1332
+ validatorsVersion: this._validatorsVersion,
1333
+ tokensVersion: this._tokensVersion,
1334
+ documentScoped: flags.sawAllNodes || this.hasDocumentScopedValidator(),
1335
+ }
1336
+
1337
+ const duration = performance.now() - startTime
1338
+ return {
1339
+ diagnostics,
1340
+ duration,
1341
+ nodesAnalyzed: nodes.length,
1342
+ scope: 'full',
1343
+ window: null,
1344
+ get allNodes(): Map<string, RedNode> { return indexNodes(root) },
1345
+ }
1346
+ }
1347
+
1348
+ /**
1349
+ * Re-validate only what an edit could have changed, on top of the previous
1350
+ * pass over this analyzer.
1351
+ *
1352
+ * `root` must be the tree the previous pass's root turned into through
1353
+ * incremental reparses WITH red-subtree reuse — that is what makes every
1354
+ * node outside `window` (the union of those reparses' windows, carried
1355
+ * into `root`'s coordinates) the same object over the same text. The
1356
+ * `DocumentModel` tracks exactly that and calls this only when it holds;
1357
+ * anyone else should prefer `DocumentModel.analyzeIncremental`.
1358
+ *
1359
+ * Returns `null` when no window pass can be trusted: there is no previous
1360
+ * pass, the validators or the tokens changed since it, or a validator with
1361
+ * document scope is registered (see `Validator.scope`). The caller runs a
1362
+ * full `analyze` then. The result's diagnostics are complete — the whole
1363
+ * document's, identical to what a full pass would produce — and come out
1364
+ * in document order like a full pass's do.
1365
+ *
1366
+ * What happens, in order:
1367
+ * 1. descend the window (see `descendWindow`), collecting its candidates;
1368
+ * 2. keep every previous candidate outside it that is still in the tree,
1369
+ * at its current offsets; derive the maps from kept plus fresh;
1370
+ * 3. diff the maps against the previous ones for nodes outside the
1371
+ * window, and mark the nodes whose entry changed for re-validation;
1372
+ * 4. move the kept diagnostics of displaced nodes by their displacement;
1373
+ * 5. validate the window nodes and the marked ones;
1374
+ * 6. assemble the collection from every diagnostic-bearing node.
294
1375
  */
295
- analyze(root: RedNode, source: string): IndexedAnalyzeResult {
1376
+ analyzeWindow(root: RedNode, source: string, window: SourceSpan): IndexedAnalyzeResult | null {
1377
+ const snapshot = this._snapshot
1378
+ if (snapshot === null || snapshot.documentScoped) return null
1379
+ if (
1380
+ snapshot.validatorsVersion !== this._validatorsVersion ||
1381
+ snapshot.tokensVersion !== this._tokensVersion
1382
+ ) return null
296
1383
  const startTime = performance.now()
1384
+ if (this._dispatchDirty) this.rebuildDispatch()
1385
+
1386
+ // ── 1. The window ──
1387
+ const fresh = emptyCandidates()
1388
+ const visited: RedNode[] = []
1389
+ descendWindow(root, root.range.start, false, window, source, fresh, visited)
1390
+ const visitedSet = new Set<RedNode>(visited)
1391
+
1392
+ // ── 2. Kept candidates, at their current offsets ──
1393
+ // `shiftById` remembers how far each kept candidate moved, for the diff.
1394
+ const previous = snapshot.candidates
1395
+ const kept = emptyCandidates()
1396
+ const keptById = new Map<string, RedNode>()
1397
+ const shiftById = new Map<string, number>()
1398
+ for (const c of previous.literals) {
1399
+ if (visitedSet.has(c.node) || !isAttached(c.node, root)) continue
1400
+ const range = c.node.range
1401
+ kept.literals.push({ node: c.node, tag: c.tag, close: c.close, start: range.start, end: range.end })
1402
+ keptById.set(c.node.id, c.node)
1403
+ shiftById.set(c.node.id, range.start - c.start)
1404
+ }
1405
+ for (const c of previous.openers) {
1406
+ if (visitedSet.has(c.node) || !isAttached(c.node, root)) continue
1407
+ const range = c.node.range
1408
+ kept.openers.push({ node: c.node, tag: c.tag, start: range.start, end: range.end })
1409
+ keptById.set(c.node.id, c.node)
1410
+ shiftById.set(c.node.id, range.start - c.start)
1411
+ }
1412
+ for (const c of previous.closers) {
1413
+ if (visitedSet.has(c.node) || !isAttached(c.node, root)) continue
1414
+ const range = c.node.range
1415
+ kept.closers.push({ node: c.node, tag: c.tag, start: range.start, end: range.end })
1416
+ }
1417
+ // A run belongs to its parent's children list: kept when the parent is
1418
+ // untouched, recomputed (in `fresh`) when the parent was visited, gone
1419
+ // when the parent was replaced. Runs of the latter two kinds may still
1420
+ // be filed on a node outside the window — an adopted child of a rebuilt
1421
+ // ancestor — and those nodes are the ones the diff below has to look at.
1422
+ const affectedGradients = new Map<RedNode, number>()
1423
+ for (const run of previous.gradients) {
1424
+ if (!visitedSet.has(run.parent) && isAttached(run.parent, root)) {
1425
+ const start = run.node.range.start
1426
+ const shift = start - run.start
1427
+ kept.gradients.push({
1428
+ parent: run.parent,
1429
+ node: run.node,
1430
+ start,
1431
+ item: shift === 0
1432
+ ? run.item
1433
+ : { ...run.item, range: { start: run.item.range.start + shift, end: run.item.range.end + shift } },
1434
+ })
1435
+ continue
1436
+ }
1437
+ if (!visitedSet.has(run.node) && isAttached(run.node, root)) {
1438
+ affectedGradients.set(run.node, run.node.range.start - run.start)
1439
+ }
1440
+ }
1441
+ for (const run of fresh.gradients) {
1442
+ if (!visitedSet.has(run.node) && !affectedGradients.has(run.node)) {
1443
+ affectedGradients.set(run.node, 0)
1444
+ }
1445
+ }
1446
+
1447
+ const candidates: Candidates = {
1448
+ literals: kept.literals.concat(fresh.literals).sort(byDocumentOrder),
1449
+ openers: kept.openers.concat(fresh.openers).sort(byDocumentOrder),
1450
+ closers: kept.closers.concat(fresh.closers).sort(byDocumentOrder),
1451
+ gradients: kept.gradients.concat(fresh.gradients),
1452
+ }
1453
+ const maps = deriveMaps(candidates)
1454
+
1455
+ // ── 3. The diff: nodes outside the window whose entry changed ──
1456
+ const extra: RedNode[] = []
1457
+ const extraSet = new Set<RedNode>()
1458
+ const revalidate = (node: RedNode): void => {
1459
+ if (visitedSet.has(node) || extraSet.has(node)) return
1460
+ extraSet.add(node)
1461
+ extra.push(node)
1462
+ }
1463
+ const old = snapshot.maps
1464
+ const diffIds = (a: ReadonlyMap<string, unknown>, b: ReadonlyMap<string, unknown>, same: (id: string, shift: number) => boolean): void => {
1465
+ const check = (id: string): void => {
1466
+ const node = keptById.get(id)
1467
+ if (node === undefined) return
1468
+ if (!same(id, shiftById.get(id) ?? 0)) revalidate(node)
1469
+ }
1470
+ for (const id of a.keys()) check(id)
1471
+ for (const id of b.keys()) if (!a.has(id)) check(id)
1472
+ }
1473
+ diffIds(old.literal.paired, maps.literal.paired, (id, shift) =>
1474
+ sameUnknownTag(old.literal.paired.get(id), maps.literal.paired.get(id), shift))
1475
+ diffIds(old.literal.orphans, maps.literal.orphans, id =>
1476
+ old.literal.orphans.get(id) === maps.literal.orphans.get(id))
1477
+ diffIds(old.crossings, maps.crossings, (id, shift) =>
1478
+ sameCrossing(old.crossings.get(id), maps.crossings.get(id), shift))
1479
+ for (const [node, shift] of affectedGradients) {
1480
+ if (!sameGradient(old.gradients.get(node.id), maps.gradients.get(node.id), shift)) revalidate(node)
1481
+ }
1482
+
1483
+ // ── 4. Kept diagnostics follow their nodes ──
1484
+ const bearing: RedNode[] = []
1485
+ for (let i = 0; i < snapshot.diagNodes.length; i++) {
1486
+ const node = snapshot.diagNodes[i]
1487
+ if (visitedSet.has(node) || extraSet.has(node) || !isAttached(node, root)) continue
1488
+ const delta = node.range.start - snapshot.diagStarts[i]
1489
+ if (delta !== 0) {
1490
+ const shifted = new Array<Diagnostic>(node.diagnostics.length)
1491
+ for (let k = 0; k < shifted.length; k++) shifted[k] = shiftDiagnostic(node.diagnostics[k], delta)
1492
+ node.diagnostics = shifted
1493
+ }
1494
+ bearing.push(node)
1495
+ }
1496
+
1497
+ // ── 5. Validate ──
1498
+ const produced = createDiagnosticCollection()
1499
+ const flags = { sawAllNodes: false }
1500
+ const context = this.makeContext(root, source, produced, maps, flags)
1501
+ this.validateNodes(visited, context, produced)
1502
+ this.validateNodes(extra, context, produced)
1503
+ for (let i = 0; i < visited.length; i++) if (visited[i].diagnostics.length > 0) bearing.push(visited[i])
1504
+ for (let i = 0; i < extra.length; i++) if (extra[i].diagnostics.length > 0) bearing.push(extra[i])
1505
+
1506
+ // ── 6. Assemble, in document order ──
1507
+ // Pre-order by position: an ancestor shares its start with its first
1508
+ // child and its end with its last, so depth breaks the tie. Zero-width
1509
+ // nodes never carry diagnostics, so no two siblings ever tie.
1510
+ const ordered = bearing.map(node => {
1511
+ const range = node.range
1512
+ return { node, start: range.start, end: range.end, depth: depthOf(node) }
1513
+ })
1514
+ ordered.sort((a, b) => a.start - b.start || b.end - a.end || a.depth - b.depth)
297
1515
  const diagnostics = createDiagnosticCollection()
298
- let nodesAnalyzed = 0
1516
+ const diagNodes: RedNode[] = new Array(ordered.length)
1517
+ const diagStarts: number[] = new Array(ordered.length)
1518
+ for (let i = 0; i < ordered.length; i++) {
1519
+ const entry = ordered[i]
1520
+ diagNodes[i] = entry.node
1521
+ diagStarts[i] = entry.start
1522
+ const own = entry.node.diagnostics
1523
+ for (let k = 0; k < own.length; k++) addDiagnostic(diagnostics, own[k])
1524
+ }
1525
+
1526
+ this._snapshot = {
1527
+ root,
1528
+ collection: diagnostics,
1529
+ diagNodes,
1530
+ diagStarts,
1531
+ candidates,
1532
+ maps,
1533
+ validatorsVersion: this._validatorsVersion,
1534
+ tokensVersion: this._tokensVersion,
1535
+ documentScoped: flags.sawAllNodes,
1536
+ }
299
1537
 
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.
1538
+ const duration = performance.now() - startTime
1539
+ return {
1540
+ diagnostics,
1541
+ duration,
1542
+ nodesAnalyzed: visited.length + extra.length,
1543
+ scope: 'window',
1544
+ window,
1545
+ get allNodes(): Map<string, RedNode> { return indexNodes(root) },
1546
+ }
1547
+ }
1548
+
1549
+ /**
1550
+ * The context validators see. The maps are ready-made; `allNodes` is
1551
+ * built on demand and, being the one cross-document index a validator can
1552
+ * reach without declaring itself, its use is recorded (see
1553
+ * `Validator.scope`).
1554
+ */
1555
+ private makeContext(
1556
+ root: RedNode,
1557
+ source: string,
1558
+ diagnostics: DiagnosticCollection,
1559
+ maps: DocumentMaps,
1560
+ flags: { sawAllNodes: boolean },
1561
+ ): AnalyzerContext {
303
1562
  let allNodesCache: Map<string, RedNode> | null = null
304
- const context: AnalyzerContext = {
1563
+ return {
305
1564
  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
- }
1565
+ flags.sawAllNodes = true
1566
+ if (allNodesCache === null) allNodesCache = indexNodes(root)
310
1567
  return allNodesCache
311
1568
  },
1569
+ get crossings(): ReadonlyMap<string, CrossedTags> { return maps.crossings },
1570
+ get unknownTags(): ReadonlyMap<string, UnknownTag> { return maps.literal.paired },
1571
+ get orphanClosers(): ReadonlyMap<string, string> { return maps.literal.orphans },
1572
+ get collapsibleGradients(): ReadonlyMap<string, CollapsibleGradient> { return maps.gradients },
312
1573
  diagnostics,
313
1574
  source,
314
1575
  }
1576
+ }
315
1577
 
1578
+ /** Run every applicable validator over `nodes`, in order. */
1579
+ private validateNodes(
1580
+ nodes: readonly RedNode[],
1581
+ context: AnalyzerContext,
1582
+ diagnostics: DiagnosticCollection,
1583
+ ): void {
316
1584
  const always = this._always
317
1585
  const byKind = this._byKind
318
-
319
- root.walk(node => {
320
- nodesAnalyzed++
321
-
1586
+ for (let n = 0; n < nodes.length; n++) {
1587
+ const node = nodes[n]
322
1588
  // Clear here rather than in a pass of its own, and only when there is
323
1589
  // something to clear: a fresh `[]` per node meant an allocation for every
324
1590
  // node in the document, and almost none of them carry diagnostics.
@@ -333,15 +1599,6 @@ export class SemanticAnalyzer {
333
1599
  runValidator(specific[i], node, context, diagnostics)
334
1600
  }
335
1601
  }
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
1602
  }
346
1603
  }
347
1604
 
@@ -349,20 +1606,136 @@ export class SemanticAnalyzer {
349
1606
 
350
1607
  private registerBuiltinValidators(): void {
351
1608
  // Unknown tag validator
1609
+ //
1610
+ // This rule could not fire. It waited on `custom`, the kind
1611
+ // `tagToNodeKind` returns for a tag it does not know — but `Parser` never
1612
+ // lets one through: it intercepts `custom` and emits the tag's text as a
1613
+ // literal leaf, on purpose, so `[Gateron]` in prose stays visible. No
1614
+ // `custom` node has ever reached the analyzer from a BBCode parse, so
1615
+ // `[bold]x[/bold]` rendered as visible garbage and the checker said
1616
+ // nothing. The translation and the panel's rule label had shipped for it
1617
+ // all along.
1618
+ //
1619
+ // It now reads the parser's decision back off the literal leaf, and fires
1620
+ // only on a tag that was CLOSED. See {@link UnknownTag} for why the
1621
+ // pairing is the entire rule.
352
1622
  this.register({
353
1623
  code: 'unknown-tag',
354
1624
  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
1625
+ kinds: ['text'],
1626
+ validate: (node, ctx) => {
1627
+ const text = node.text
1628
+ // Cheap enough to sit in front of the map: reaching `ctx.unknownTags`
1629
+ // walks the document, and a text leaf that is not bracketed end to end
1630
+ // can never be in it.
1631
+ if (
1632
+ text.length < 3 ||
1633
+ text.charCodeAt(0) !== 0x5b /* [ */ ||
1634
+ text.charCodeAt(text.length - 1) !== 0x5d /* ] */
1635
+ ) return null
1636
+
1637
+ const unknown = ctx.unknownTags.get(node.id)
1638
+ if (unknown === undefined) return null
1639
+
1640
+ const suggestion = suggestTag(unknown.tag, getBBCodeTagNames(this.dialect))
1641
+
1642
+ // Not automatic, and for the opposite reason to every other fix here:
1643
+ // those are safe because they do not change the render, and this one
1644
+ // exists precisely to change it. `[bold]x[/bold]` is literal text
1645
+ // today and bold afterwards — which is what the author wanted, but it
1646
+ // is a guess at their intent, so it is theirs to accept.
1647
+ const fixes: DiagnosticFix[] | undefined = suggestion === null ? undefined : [{
1648
+ description: `Replace [${unknown.tag}] with [${suggestion}]`,
1649
+ isAutomatic: false,
1650
+ operations: [
1651
+ // Both ends, or the rename leaves an orphan `[/bold]` that osu!
1652
+ // paints as literal text — the same half-repair `deprecated-tag`
1653
+ // had to learn to avoid.
1654
+ {
1655
+ kind: 'replace_text',
1656
+ range: { start: unknown.opener.start + 1, end: unknown.opener.start + 1 + unknown.tag.length },
1657
+ newText: suggestion,
1658
+ },
1659
+ {
1660
+ kind: 'replace_text',
1661
+ range: { start: unknown.closer.start + 2, end: unknown.closer.start + 2 + unknown.tag.length },
1662
+ newText: suggestion,
1663
+ },
1664
+ ],
1665
+ }]
1666
+
1667
+ return createDiagnostic(
1668
+ 'unknown-tag',
1669
+ `Unknown BBCode tag: [${unknown.tag}] — it renders as literal text`,
1670
+ 'warning',
1671
+ {
1672
+ nodeId: node.id,
1673
+ nodeKind: node.kind,
1674
+ range: node.range,
1675
+ fixes,
1676
+ related: [{
1677
+ message: `Its closing [/${unknown.tag}]`,
1678
+ range: { start: unknown.closer.start, end: unknown.closer.end },
1679
+ nodeId: null,
1680
+ }],
1681
+ },
1682
+ )
1683
+ },
1684
+ })
1685
+
1686
+ // Orphan closing tag validator
1687
+ //
1688
+ // A `[/tag]` with no opener anywhere. The parser keeps it as literal text
1689
+ // so no character of the source belongs to nothing — which means it is
1690
+ // PRINTED, and until now nothing said so: the checker reported a clean
1691
+ // document while the preview showed `[/notice][/centre]` as body text.
1692
+ // Found by opening the editor, not by any test or corpus sweep.
1693
+ //
1694
+ // osu! and Quasar genuinely disagree here — osu! discards the tag, Quasar
1695
+ // shows it — and `Tests/OsuNestingFidelity.test.ts` pins that: deleting
1696
+ // the orphans is what makes the visible text match the hand-verified
1697
+ // oracle. So the message has to name both behaviours, or the author cannot
1698
+ // tell whether the preview or their post is the one lying.
1699
+ //
1700
+ // In 53 real userpages these appear only in the deliberately broken ones:
1701
+ // 12 across the three NyuPenyu files, 0 everywhere else.
1702
+ this.register({
1703
+ code: 'orphan-closing-tag',
1704
+ severity: 'warning',
1705
+ kinds: ['text'],
1706
+ validate: (node, ctx) => {
1707
+ const text = node.text
1708
+ // Same cheap gate as `unknown-tag`, plus the slash: a closing tag is
1709
+ // the only thing this can be.
1710
+ if (
1711
+ text.length < 4 ||
1712
+ text.charCodeAt(0) !== 0x5b /* [ */ ||
1713
+ text.charCodeAt(1) !== 0x2f /* / */ ||
1714
+ text.charCodeAt(text.length - 1) !== 0x5d /* ] */
1715
+ ) return null
1716
+
1717
+ const tag = ctx.orphanClosers.get(node.id)
1718
+ if (tag === undefined) return null
1719
+
1720
+ return createDiagnostic(
1721
+ 'orphan-closing-tag',
1722
+ `[/${tag}] closes nothing — osu! drops it, the preview shows it as text`,
1723
+ 'warning',
1724
+ {
1725
+ nodeId: node.id,
1726
+ nodeKind: node.kind,
1727
+ range: node.range,
1728
+ // Manual, like the other two repairs that alter what is displayed.
1729
+ // Deleting it is what osu! already does, so the published post does
1730
+ // not move — but this preview does, and a `[/notice]` alone on its
1731
+ // line leaves its newline behind exactly as `crossed-tags` does.
1732
+ fixes: [{
1733
+ description: `Delete [/${tag}]`,
1734
+ isAutomatic: false,
1735
+ operations: [{ kind: 'delete_range', range: { start: node.range.start, end: node.range.end } }],
1736
+ }],
1737
+ },
1738
+ )
366
1739
  },
367
1740
  })
368
1741
 
@@ -387,17 +1760,47 @@ export class SemanticAnalyzer {
387
1760
  const found = DEPRECATED_TAGS[spelling]
388
1761
  if (!found || found.kind !== node.kind) return null
389
1762
 
1763
+ // Renombrar es dos ediciones, no una: apertura y cierre. Se emiten
1764
+ // ambas en el MISMO fix para que se apliquen como una sola operación —
1765
+ // aplicar media deja un `[/strike]` huérfano en el documento.
1766
+ const operations: DiagnosticFix['operations'] = [
1767
+ {
1768
+ kind: 'replace_text',
1769
+ range: { start: node.range.start + 1, end: node.range.start + 1 + spelling.length },
1770
+ newText: found.replacement,
1771
+ },
1772
+ ]
1773
+ const closing = closingTagNameRange(node, ctx.source, spelling)
1774
+ if (closing) {
1775
+ operations.push({ kind: 'replace_text', range: closing, newText: found.replacement })
1776
+ }
1777
+
390
1778
  return createDiagnostic(
391
1779
  'deprecated-tag',
392
1780
  found.message,
393
1781
  'info',
394
- { nodeId: node.id, nodeKind: node.kind, range: node.range, tags: ['deprecated'] },
1782
+ {
1783
+ nodeId: node.id,
1784
+ nodeKind: node.kind,
1785
+ range: node.range,
1786
+ tags: ['deprecated'],
1787
+ fixes: [{
1788
+ description: `Replace [${spelling}] with [${found.replacement}]`,
1789
+ isAutomatic: true,
1790
+ operations,
1791
+ }],
1792
+ },
395
1793
  )
396
1794
  },
397
1795
  })
398
1796
 
399
1797
  // Empty tag validator
400
- // Excludes structural kinds that are intentionally contentless (empty_line, spacing)
1798
+ //
1799
+ // Excludes structural kinds that are intentionally contentless
1800
+ // (empty_line, spacing), and the kinds that cannot hold content at all —
1801
+ // see CONTENTLESS_BY_NATURE. Note that only the bare `[hr]` / `[separator]`
1802
+ // reached here anyway: with an attribute, `[separator=stars]` carries
1803
+ // `=stars` as its text and failed the `text === ''` test by accident.
401
1804
  this.register({
402
1805
  code: 'empty-tag',
403
1806
  severity: 'hint',
@@ -407,13 +1810,27 @@ export class SemanticAnalyzer {
407
1810
  node.text === '' &&
408
1811
  node.kind !== 'text' &&
409
1812
  node.kind !== 'empty_line' &&
410
- node.kind !== 'spacing'
1813
+ node.kind !== 'spacing' &&
1814
+ !CONTENTLESS_BY_NATURE.has(node.kind)
411
1815
  ) {
412
1816
  return createDiagnostic(
413
1817
  'empty-tag',
414
1818
  `Empty tag: ${node.kind}`,
415
1819
  'hint',
416
- { nodeId: node.id, nodeKind: node.kind, range: node.range, tags: ['unnecessary'] },
1820
+ {
1821
+ nodeId: node.id,
1822
+ nodeKind: node.kind,
1823
+ range: node.range,
1824
+ tags: ['unnecessary'],
1825
+ // Una etiqueta sin contenido no renderiza nada, así que borrarla
1826
+ // no puede cambiar la salida: es la corrección más segura de las
1827
+ // tres.
1828
+ fixes: [{
1829
+ description: 'Remove the empty tag',
1830
+ isAutomatic: true,
1831
+ operations: [{ kind: 'delete_range', range: node.range }],
1832
+ }],
1833
+ },
417
1834
  )
418
1835
  }
419
1836
  return null
@@ -431,13 +1848,87 @@ export class SemanticAnalyzer {
431
1848
  severity: 'warning',
432
1849
  validate: (node, ctx) => {
433
1850
  if (!isUnclosedTag(node, ctx.source)) return null
1851
+ // Its closing tag is not missing, it is misplaced. Reported — with a
1852
+ // different repair — by `crossed-tags` below.
1853
+ if (ctx.crossings.has(node.id)) return null
434
1854
 
435
1855
  const name = openingTagName(node, ctx.source)
1856
+
1857
+ // El parser YA cerró la etiqueta en `range.end`; la corrección solo
1858
+ // escribe en el fuente la decisión que el árbol ya tomó. Por eso es
1859
+ // segura: no cambia cómo se renderiza nada, elimina la divergencia
1860
+ // entre lo que el autor escribió y lo que se está mostrando.
1861
+ const fixes: DiagnosticFix[] | undefined = name
1862
+ ? [{
1863
+ description: `Insert [/${name}]`,
1864
+ isAutomatic: true,
1865
+ operations: [{ kind: 'insert_text', position: node.range.end, text: `[/${name}]` }],
1866
+ }]
1867
+ : undefined
1868
+
436
1869
  return createDiagnostic(
437
1870
  'unclosed-tag',
438
1871
  `Missing [/${name}] — the tag was closed automatically`,
439
1872
  'warning',
440
- { nodeId: node.id, nodeKind: node.kind, range: node.range },
1873
+ { nodeId: node.id, nodeKind: node.kind, range: node.range, fixes },
1874
+ )
1875
+ },
1876
+ })
1877
+
1878
+ // Crossed tags validator
1879
+ //
1880
+ // `[centre][notice]x[/centre][/notice]` — the closers are all there, just
1881
+ // in the wrong order. The parser resolves it the way osu! does and moves
1882
+ // on, so without this the author is told two tags are *missing* while
1883
+ // their `[/tag]`s sit in plain sight further down the document.
1884
+ this.register({
1885
+ code: 'crossed-tags',
1886
+ severity: 'warning',
1887
+ validate: (node, ctx) => {
1888
+ // Cheap gate first: reaching `ctx.crossings` at all builds the pairing
1889
+ // for the whole document, and only an auto-closed tag can be in it.
1890
+ if (!isUnclosedTag(node, ctx.source)) return null
1891
+
1892
+ const crossing = ctx.crossings.get(node.id)
1893
+ if (crossing === undefined) return null
1894
+
1895
+ // Deliberately NOT automatic, and this is the whole reason the repair
1896
+ // is not just `repairNesting`'s edits handed over as a fix.
1897
+ //
1898
+ // Moving the closer is correct BBCode but it is not render-neutral:
1899
+ // the whitespace that surrounded the stranded `[/tag]` stays where it
1900
+ // was, and a newline that used to sit outside the container now sits
1901
+ // inside it — or two newlines that were separated by the discarded tag
1902
+ // become adjacent and turn into a blank line. Measured on
1903
+ // `[centre][notice]hola\n[/centre]\n[/notice]`: one extra
1904
+ // `bb-empty-line` in the output. Every other automatic fix in here is
1905
+ // safe precisely because it only writes down a decision the parser had
1906
+ // already taken; this one changes what the reader sees, so it is the
1907
+ // author's call and it stays out of "fix all".
1908
+ const fixes: DiagnosticFix[] = [{
1909
+ description: `Move [/${crossing.tag}] to where the tag actually closes`,
1910
+ isAutomatic: false,
1911
+ operations: [
1912
+ { kind: 'insert_text', position: crossing.at, text: `[/${crossing.tag}]` },
1913
+ { kind: 'delete_range', range: { start: crossing.closer.start, end: crossing.closer.end } },
1914
+ ],
1915
+ }]
1916
+
1917
+ return createDiagnostic(
1918
+ 'crossed-tags',
1919
+ `[/${crossing.tag}] is out of order — the tag was closed earlier and this closing tag is ignored`,
1920
+ 'warning',
1921
+ {
1922
+ nodeId: node.id,
1923
+ nodeKind: node.kind,
1924
+ range: node.range,
1925
+ fixes,
1926
+ related: [{
1927
+ message: `The ignored [/${crossing.tag}]`,
1928
+ range: { start: crossing.closer.start, end: crossing.closer.end },
1929
+ nodeId: null,
1930
+ }],
1931
+ },
441
1932
  )
442
1933
  },
443
1934
  })
@@ -476,6 +1967,252 @@ export class SemanticAnalyzer {
476
1967
  return diagnostics.length > 0 ? diagnostics : null
477
1968
  },
478
1969
  })
1970
+
1971
+ // ── Link destination validators ──────────────────────────
1972
+ //
1973
+ // Split into two codes on purpose. A missing scheme (`www.osu.ppy.sh`) is a
1974
+ // typo with one obvious repair, so it is a warning that fixes itself. A
1975
+ // *disallowed* scheme (`javascript:`) is either a mistake of a completely
1976
+ // different kind or an attack on whoever reads the post, and there is no
1977
+ // safe automatic rewrite for it — the author has to decide what they meant.
1978
+ this.register({
1979
+ code: 'invalid-url-protocol',
1980
+ severity: 'error',
1981
+ kinds: ['url'],
1982
+ validate: (node, ctx) => {
1983
+ const href = String(node.metadata?.href ?? '').trim()
1984
+ if (!href) return null
1985
+
1986
+ const scheme = href.match(URL_SCHEME_RE)?.[1]?.toLowerCase()
1987
+ const range = hrefRange(node, ctx.source, href)
1988
+
1989
+ if (scheme === undefined) {
1990
+ // No scheme at all. osu! resolves these against its own domain, so
1991
+ // `www.google.com` silently becomes an osu!web 404 — the link looks
1992
+ // right in the editor and is broken everywhere else.
1993
+ return createDiagnostic(
1994
+ 'missing-url-protocol',
1995
+ `Link "${href}" has no protocol — it will resolve against osu!'s own domain`,
1996
+ 'warning',
1997
+ {
1998
+ nodeId: node.id,
1999
+ nodeKind: node.kind,
2000
+ range,
2001
+ fixes: [{
2002
+ description: 'Prefix the link with https://',
2003
+ // Safe to batch: it only ever adds a scheme in front of a
2004
+ // destination that has none, so it cannot collide with another
2005
+ // finding's range and cannot change how the link text renders.
2006
+ isAutomatic: true,
2007
+ operations: [{ kind: 'insert_text', position: range.start, text: 'https://' }],
2008
+ }],
2009
+ },
2010
+ )
2011
+ }
2012
+
2013
+ if (ALLOWED_URL_SCHEMES.has(scheme)) return null
2014
+
2015
+ return createDiagnostic(
2016
+ 'invalid-url-protocol',
2017
+ `Link protocol "${scheme}" is not allowed — use http://, https:// or mailto:`,
2018
+ 'error',
2019
+ { nodeId: node.id, nodeKind: node.kind, range },
2020
+ )
2021
+ },
2022
+ })
2023
+
2024
+ // Empty link validator
2025
+ //
2026
+ // Not reachable by `empty-tag`: `[url=https://x][/url]` carries `=https://x`
2027
+ // as its text, so it fails that rule's `text === ''` test and slips through.
2028
+ // The result renders as an anchor with nothing between its tags — invisible
2029
+ // and unclickable — which is exactly the kind of breakage a checker exists
2030
+ // to catch.
2031
+ this.register({
2032
+ code: 'empty-link',
2033
+ severity: 'warning',
2034
+ kinds: ['url'],
2035
+ validate: (node, ctx) => {
2036
+ if (node.children.length > 0) return null
2037
+ const href = String(node.metadata?.href ?? '').trim()
2038
+ if (!href) return null
2039
+
2040
+ // `[url]https://x[/url]` parses its destination into a text child, so
2041
+ // the `children.length` test above has already isolated the attribute
2042
+ // form. What is left to find is where the label would go: immediately
2043
+ // before the closing tag.
2044
+ const name = openingTagName(node, ctx.source)
2045
+ const closingName = name ? closingTagNameRange(node, ctx.source, name) : null
2046
+ if (!closingName) return null
2047
+ const closing = closingName.start - 2
2048
+
2049
+ return createDiagnostic(
2050
+ 'empty-link',
2051
+ 'Link has no visible text — it renders as an empty anchor',
2052
+ 'warning',
2053
+ {
2054
+ nodeId: node.id,
2055
+ nodeKind: node.kind,
2056
+ range: node.range,
2057
+ fixes: [{
2058
+ description: `Use "${href}" as the link text`,
2059
+ // Manual: it puts text on screen that was not there before. The
2060
+ // author may well want a different label, and "Fix all" must not
2061
+ // write copy on their behalf.
2062
+ isAutomatic: false,
2063
+ operations: [{ kind: 'insert_text', position: closing, text: href }],
2064
+ }],
2065
+ },
2066
+ )
2067
+ },
2068
+ })
2069
+
2070
+ // Quote depth validator
2071
+ //
2072
+ // Ranged, unlike the Linter rule it replaces: a finding with `range: null`
2073
+ // cannot be jumped to, and a nesting problem you cannot navigate to is
2074
+ // barely a finding at all.
2075
+ this.register({
2076
+ code: 'max-quote-depth',
2077
+ severity: 'warning',
2078
+ kinds: ['quote'],
2079
+ validate: (node, ctx) => {
2080
+ let depth = 0
2081
+ for (let current = node.parent; current; current = current.parent) {
2082
+ if (current.kind === 'quote') depth++
2083
+ }
2084
+ if (depth < MAX_QUOTE_DEPTH) return null
2085
+
2086
+ return createDiagnostic(
2087
+ 'max-quote-depth',
2088
+ `Quotes are nested ${depth + 1} levels deep — osu! renders past ${MAX_QUOTE_DEPTH} as an unreadable stack`,
2089
+ 'warning',
2090
+ { nodeId: node.id, nodeKind: node.kind, range: openingTagRange(node, ctx.source) },
2091
+ )
2092
+ },
2093
+ })
2094
+
2095
+ // Redundant self-nesting validator
2096
+ //
2097
+ // Replaces the Linter's `no-nested-bold`, generalised to every inline kind
2098
+ // whose effect is idempotent. `[color]` inside `[color]` is deliberately
2099
+ // NOT reported: there the inner one wins, so the nesting means something.
2100
+ this.register({
2101
+ code: 'redundant-nesting',
2102
+ severity: 'hint',
2103
+ kinds: [...SELF_NESTING_REDUNDANT],
2104
+ validate: (node, ctx) => {
2105
+ if (node.parent?.kind !== node.kind) return null
2106
+
2107
+ const name = openingTagName(node, ctx.source)
2108
+ const operations: DiagnosticFix['operations'] = []
2109
+ if (name) {
2110
+ const openEnd = ctx.source.indexOf(']', node.range.start)
2111
+ if (openEnd > 0 && openEnd < node.range.end) {
2112
+ operations.push({
2113
+ kind: 'delete_range',
2114
+ range: { start: node.range.start, end: openEnd + 1 },
2115
+ })
2116
+ }
2117
+ const closing = closingTagNameRange(node, ctx.source, name)
2118
+ if (closing) {
2119
+ operations.push({
2120
+ kind: 'delete_range',
2121
+ range: { start: closing.start - 2, end: node.range.end },
2122
+ })
2123
+ }
2124
+ }
2125
+
2126
+ return createDiagnostic(
2127
+ 'redundant-nesting',
2128
+ `[${name ?? node.kind}] inside another [${name ?? node.kind}] has no additional effect`,
2129
+ 'hint',
2130
+ {
2131
+ nodeId: node.id,
2132
+ nodeKind: node.kind,
2133
+ range: node.range,
2134
+ tags: ['redundant'],
2135
+ // Manual even though the visual result is identical: unwrapping
2136
+ // changes the emitted HTML, and the one promise "Fix all" makes is
2137
+ // that it never changes the document's output. Offered per-finding
2138
+ // so the author can still take it.
2139
+ fixes: operations.length === 2
2140
+ ? [{ description: `Unwrap the inner [${name}]`, isAutomatic: false, operations }]
2141
+ : undefined,
2142
+ },
2143
+ )
2144
+ },
2145
+ })
2146
+
2147
+ // ── Collapsible gradient sequence ───────────────────────────
2148
+ this.register({
2149
+ code: 'collapsible-gradient',
2150
+ severity: 'info',
2151
+ kinds: ['color'],
2152
+ validate: (node, ctx) => {
2153
+ const item = ctx.collapsibleGradients.get(node.id)
2154
+ if (!item) return null
2155
+
2156
+ return createDiagnostic(
2157
+ 'collapsible-gradient',
2158
+ `Sequence of ${item.colorCount} colors can be collapsed into [gradient]`,
2159
+ 'info',
2160
+ {
2161
+ nodeId: node.id,
2162
+ nodeKind: 'color',
2163
+ range: item.range,
2164
+ tags: ['unnecessary'],
2165
+ fixes: [
2166
+ {
2167
+ description: 'Collapse into [gradient]',
2168
+ isAutomatic: false,
2169
+ operations: [
2170
+ {
2171
+ kind: 'replace_text',
2172
+ range: item.range,
2173
+ newText: item.replacementText,
2174
+ },
2175
+ ],
2176
+ },
2177
+ ],
2178
+ },
2179
+ )
2180
+ },
2181
+ })
2182
+
2183
+ // ── Unresolved design token validator ────────────────────────
2184
+ this.register({
2185
+ code: 'unresolved-token',
2186
+ severity: 'warning',
2187
+ validate: (node) => {
2188
+ if (!this._tokenResolver) return null
2189
+
2190
+ const tokenRefs = findTokenReferences(node)
2191
+ if (tokenRefs.length === 0) return null
2192
+
2193
+ const diagnostics: Diagnostic[] = []
2194
+ for (const tokenRef of tokenRefs) {
2195
+ const name = tokenRef.startsWith('$') ? tokenRef.slice(1) : tokenRef
2196
+ const resolved = this._tokenResolver(name) ?? this._tokenResolver(tokenRef)
2197
+ if (resolved === undefined) {
2198
+ diagnostics.push(
2199
+ createDiagnostic(
2200
+ 'unresolved-token',
2201
+ `Design token "${tokenRef}" is not defined in project tokens`,
2202
+ 'warning',
2203
+ {
2204
+ nodeId: node.id,
2205
+ nodeKind: node.kind,
2206
+ range: node.range,
2207
+ },
2208
+ ),
2209
+ )
2210
+ }
2211
+ }
2212
+
2213
+ return diagnostics.length > 0 ? diagnostics : null
2214
+ },
2215
+ })
479
2216
  }
480
2217
 
481
2218
  /**