@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
@@ -56,8 +56,18 @@ import { RedNode } from '../Syntax/RedNode'
56
56
  import { GreenNode } from '../Syntax/GreenNode'
57
57
  import { spliceGreen, withChildrenSpliced, type SpineStep } from '../Syntax/greenEdit'
58
58
  import { tagToNodeKind } from '../BBCode/BBCodeToGreenNode'
59
+ import { BracketDepthIndex } from './BracketIndex'
59
60
  import type { TextChange } from './ChangeTracker'
60
61
 
62
+ /**
63
+ * A span of the source, `[start, end)`, in the coordinates of the text the
64
+ * result describes.
65
+ */
66
+ export interface SourceSpan {
67
+ start: number
68
+ end: number
69
+ }
70
+
61
71
  export interface EditOperation {
62
72
  kind: 'insert' | 'delete' | 'replace'
63
73
  start: number
@@ -97,6 +107,25 @@ export interface ReparseResult {
97
107
  path: 'incremental' | 'full_rebuild'
98
108
  /** When `full_rebuild`, why the incremental path was declined. */
99
109
  reason?: FallbackReason
110
+ /**
111
+ * The span of `newSource` that went through the parser, in NEW coordinates,
112
+ * or `null` after a full rebuild (where the answer is "all of it").
113
+ *
114
+ * This is the contract the incremental semantic analysis is built on. The
115
+ * red tree that comes back is derived from a green tree that shares every
116
+ * subtree outside this span by reference with the previous one, so — with
117
+ * red-subtree reuse — every red node outside the span is the SAME object it
118
+ * was before the edit, and everything anyone computed about it (its
119
+ * diagnostics, above all) is still true of it, up to a shift in position.
120
+ * The nodes inside the span, plus the ancestors on the path down to it
121
+ * (rebuilt because their child lists changed), are the only ones that are
122
+ * new. `SemanticAnalyzer.analyzeWindow` re-validates exactly those.
123
+ *
124
+ * The span is closed on the widened window, not on the edit: the parser
125
+ * re-parses the sibling on each side of the change too (see
126
+ * `findReparseWindow`), and those siblings are new nodes as well.
127
+ */
128
+ window: SourceSpan | null
100
129
  }
101
130
 
102
131
  export type FallbackReason =
@@ -108,6 +137,11 @@ export type FallbackReason =
108
137
  | 'region-not-isolated'
109
138
  /** An unclosed `[` before the region could claim a `]` the edit creates. */
110
139
  | 'open-bracket-before'
140
+ /**
141
+ * The window changed what the parser's `autoClosed` set holds when the text
142
+ * after it is read — see `pendingAutoClosePreserved`.
143
+ */
144
+ | 'pending-auto-close'
111
145
  /** The container covers so much of the document that a rebuild is cheaper. */
112
146
  | 'region-too-large'
113
147
  /** The document is small enough that rebuilding it outright costs less. */
@@ -165,17 +199,296 @@ const MIN_SOURCE_LENGTH = 2500
165
199
  /** An orphaned closing tag, as the parser preserves it: a `text` leaf of `[/tag]`. */
166
200
  const ORPHAN_CLOSE_RE = /^\[\/([a-zA-Z0-9_*-]+)\]$/
167
201
 
202
+ /**
203
+ * What a stretch of tree does to the parser's `autoClosed` set.
204
+ *
205
+ * `autoClosed` is the one piece of parser state that flows RIGHTWARDS past a
206
+ * window without being visible in the tree at the window's edge (see
207
+ * `pendingAutoClosePreserved`). A stretch of text does three things to it:
208
+ *
209
+ * - ADDS a name, when a `[/x]` overtakes an inner `[y]` and closes it
210
+ * without its delimiter — the element ends up with `trailingWidth === 0`;
211
+ * - CONSUMES one, when a `[/y]` arrives while `y` is pending: the parser
212
+ * keeps it as a `discarded_tag` and drops the name;
213
+ * - DELETES one, on every opening tag, because reopening `[y]` retires the
214
+ * `y` that was still pending.
215
+ */
216
+ interface AutoCloseProfile {
217
+ /**
218
+ * For each kind, the LAST thing the stretch did to it: `add` (a crossing
219
+ * left it pending) or `del` (an opening tag or a `discarded_tag` retired
220
+ * it). Only the last one matters — the operations are per-name and
221
+ * idempotent, so whatever happened before it has already been overwritten.
222
+ */
223
+ last: Map<string, 'add' | 'del'>
224
+ /**
225
+ * The kinds still open when the stretch ENDS — the rightmost chain. Those
226
+ * are not adds yet: whatever closes them sits past the window and adds them
227
+ * all at once there, so they override everything in `last`.
228
+ */
229
+ chain: Set<string>
230
+ }
231
+
232
+ function emptyProfile(): AutoCloseProfile {
233
+ return { last: new Map(), chain: new Set() }
234
+ }
235
+
236
+ /**
237
+ * A node the parser had on its tag stack, as opposed to a leaf or a grouping
238
+ * wrapper: exactly the nodes that own an opening delimiter. Leaves always
239
+ * carry a leading width of 0 (see `greenLeaf`), and so do `paragraph`,
240
+ * `group` and the document root, which the parser synthesises.
241
+ */
242
+ function wasStackFrame(node: GreenNode): boolean {
243
+ return node.leadingWidth > 0
244
+ }
245
+
246
+ /**
247
+ * Fill `out` from one node, in document order.
248
+ *
249
+ * `onChain` marks the rightmost chain of nodes still open when the stretch
250
+ * ends: they carry no closing delimiter because there was nothing left to
251
+ * close them, not because a crossing did.
252
+ */
253
+ function profileNode(node: GreenNode, onChain: boolean, out: AutoCloseProfile): void {
254
+ if (node.kind === 'discarded_tag') {
255
+ const match = ORPHAN_CLOSE_RE.exec(node.text)
256
+ // Named by kind like every other event, so both sides compare on the same
257
+ // alphabet.
258
+ out.last.set(match === null ? node.text : tagToNodeKind(match[1]), 'del')
259
+ return
260
+ }
261
+ if (wasStackFrame(node)) out.last.set(node.kind, 'del')
262
+
263
+ const children = node.children as readonly GreenNode[]
264
+ // A node that owns a closing delimiter was closed where it stands, so
265
+ // nothing below it is still open at the end of the stretch.
266
+ const stillOpen = onChain && node.trailingWidth === 0
267
+ for (let i = 0; i < children.length; i++) {
268
+ profileNode(children[i], stillOpen && i === children.length - 1, out)
269
+ }
270
+
271
+ if (node.trailingWidth > 0) {
272
+ // This delimiter closed the author's tag and, on the way, every frame
273
+ // still open inside it — the rightmost chain, which is what the parser's
274
+ // `autoClosedHere` collects.
275
+ for (
276
+ let c = children[children.length - 1] as GreenNode | undefined;
277
+ c !== undefined && wasStackFrame(c) && c.trailingWidth === 0;
278
+ c = c.children[c.children.length - 1] as GreenNode | undefined
279
+ ) {
280
+ out.last.set(c.kind, 'add')
281
+ }
282
+ } else if (onChain && wasStackFrame(node)) {
283
+ out.chain.add(node.kind)
284
+ }
285
+ }
286
+
287
+ function profileSiblings(children: readonly GreenNode[], out: AutoCloseProfile): AutoCloseProfile {
288
+ for (let i = 0; i < children.length; i++) profileNode(children[i], i === children.length - 1, out)
289
+ return out
290
+ }
291
+
292
+ /**
293
+ * Does the re-parsed window hand the text AFTER it the same `autoClosed` set
294
+ * the old one did?
295
+ *
296
+ * This is the leak the differential fuzz found, and it is invisible in the
297
+ * window itself. `[notice]
298
+ [b] [/notice]` auto-closes the `[b]`, leaving `b`
299
+ * pending; a `[/b]` further down the document is then a `discarded_tag` —
300
+ * invisible, not exported. Delete a `]` somewhere earlier so that the whole
301
+ * run gets swallowed into one literal-text token, and the `[b]` never opens,
302
+ * so the `[/b]` outside the window becomes visible text. The window parses
303
+ * correctly; the tree keeps the stale `discarded_tag` it adopted.
304
+ *
305
+ * ─── Why one entry per kind is the whole answer ─────────────────────────────
306
+ *
307
+ * `autoClosed` is a set of NAMES, and every operation on it names exactly one:
308
+ * a crossing adds one, an opening tag or a spent `[/tag]` deletes one. Names
309
+ * never interact, so the window's effect factorises into one function per
310
+ * kind, and each of those has only three possible shapes — leave the name as
311
+ * it arrived (the window did nothing to it), force it pending (the last thing
312
+ * the window did was add it), or force it absent (the last thing was a
313
+ * delete). That is why only the LAST operation per kind is recorded, and why a
314
+ * crossing the window resolves on the spot — `[b]` auto-closed by `[/notice]`
315
+ * and then reopened, which the fixture does 24 times — cancels out and costs
316
+ * nothing.
317
+ *
318
+ * The frames left open at the window's end are the exception in placement
319
+ * only: whatever closes them does so past the window, after every other
320
+ * operation, so `chain` overrides `last`.
321
+ *
322
+ * Two shapes still disagree without it mattering: "did nothing" and "deleted"
323
+ * are the same function whenever the name was not pending on the way in, which
324
+ * is a fact about the prefix that {@link PendingSpans} answers from a few
325
+ * dozen measured stretches. Without that relaxation an ordinary
326
+ * `[heading]TOP[/heading]` typed above the fixture's first block loses its
327
+ * window, because the block opens 190 tags and the new one is a 191st.
328
+ *
329
+ * The one approximation is naming an operation by its element KIND rather than
330
+ * by the tag as written, so two spellings of the same element — `[centre]` and
331
+ * `[center]` — look alike to it. Getting that wrong needs a pending cross of
332
+ * one of the two spellings over the window AND the edit to swap the window
333
+ * between them; it is the same order of blind spot as the plugin-tag note
334
+ * above, and it is recorded here rather than paid for on every keystroke.
335
+ */
336
+ function pendingAutoClosePreserved(
337
+ oldChildren: readonly GreenNode[],
338
+ from: number,
339
+ to: number,
340
+ region: GreenNode,
341
+ pending: PendingSpans,
342
+ windowStart: number,
343
+ ): boolean {
344
+ const after = profileSiblings(region.children as readonly GreenNode[], emptyProfile())
345
+ const before = profileSiblings(oldChildren.slice(from, to), emptyProfile())
346
+
347
+ const kinds = new Set<string>(before.last.keys())
348
+ for (const kind of after.last.keys()) kinds.add(kind)
349
+ for (const kind of before.chain) kinds.add(kind)
350
+ for (const kind of after.chain) kinds.add(kind)
351
+
352
+ for (const kind of kinds) {
353
+ const b = before.chain.has(kind) ? 'add' : before.last.get(kind) ?? 'none'
354
+ const a = after.chain.has(kind) ? 'add' : after.last.get(kind) ?? 'none'
355
+ if (b === a) continue
356
+ // `del` and `none` are the same function on a name that was not pending.
357
+ if (b !== 'add' && a !== 'add' && !pending.covers(kind, windowStart)) continue
358
+ return false
359
+ }
360
+ return true
361
+ }
362
+
363
+ /** A stretch of the document over which `kind` sits in the parser's `autoClosed`. */
364
+ interface PendingSpan {
365
+ kind: string
366
+ start: number
367
+ /** `Infinity` for a name nothing ever retired. */
368
+ end: number
369
+ }
370
+
371
+ /**
372
+ * Where in the document a name is pending in the parser's `autoClosed`.
373
+ *
374
+ * The parser's own rules, replayed over the tree in one walk: a closing
375
+ * delimiter makes every frame it overtook pending from that point; an opening
376
+ * tag of the same name retires it (reopening `[b]` retires the pending `b`);
377
+ * so does a `discarded_tag`, which is the pending name being spent. What comes
378
+ * out is a handful of short spans — on the 547 KB fixture, a couple of dozen,
379
+ * none longer than a few KB — and outside them the incoming set is empty,
380
+ * which is what lets the window guard ignore the deletes almost always.
381
+ *
382
+ * The walk is O(nodes) and runs once per green root. An incremental splice
383
+ * carries it forward instead (`shifted`), because the guard that consults it
384
+ * only passes edits that leave the crossings where they were.
385
+ */
386
+ class PendingSpans {
387
+ constructor(private readonly spans: readonly PendingSpan[]) {}
388
+
389
+ covers(kind: string, offset: number): boolean {
390
+ for (const span of this.spans) {
391
+ if (span.kind === kind && span.start <= offset && offset < span.end) return true
392
+ }
393
+ return false
394
+ }
395
+
396
+ /**
397
+ * The same spans over the text an edit produced.
398
+ *
399
+ * Anything wholly before the window is untouched and anything wholly after
400
+ * it moves by `delta`. An endpoint that falls INSIDE the window is clamped
401
+ * outwards, to the window's own edges: the guard has already established
402
+ * that the crossing and the tag that retires it are both still there, but
403
+ * not exactly where, and a span that claims to be pending for slightly
404
+ * longer than it is can only cost a rebuild, never correctness.
405
+ */
406
+ shifted(windowStart: number, windowEndOld: number, delta: number): PendingSpans {
407
+ const windowEndNew = windowEndOld + delta
408
+ const move = (at: number): number => {
409
+ if (at === Number.POSITIVE_INFINITY || at >= windowEndOld) return at + delta
410
+ return at
411
+ }
412
+ const spans: PendingSpan[] = []
413
+ for (const span of this.spans) {
414
+ const start = span.start < windowStart ? span.start : Math.max(windowStart, move(span.start))
415
+ const end = span.end <= windowStart ? span.end : Math.max(windowEndNew, move(span.end))
416
+ spans.push({ kind: span.kind, start, end })
417
+ }
418
+ return new PendingSpans(spans)
419
+ }
420
+ }
421
+
422
+ function collectPendingSpans(root: GreenNode): PendingSpans {
423
+ const spans: PendingSpan[] = []
424
+ const open = new Map<string, number>()
425
+ const retire = (kind: string, at: number): void => {
426
+ const start = open.get(kind)
427
+ if (start === undefined) return
428
+ open.delete(kind)
429
+ spans.push({ kind, start, end: at })
430
+ }
431
+
432
+ const visit = (node: GreenNode, start: number): void => {
433
+ if (node.kind === 'discarded_tag') {
434
+ const match = ORPHAN_CLOSE_RE.exec(node.text)
435
+ if (match !== null) retire(tagToNodeKind(match[1]), start)
436
+ return
437
+ }
438
+ if (wasStackFrame(node)) retire(node.kind, start)
439
+
440
+ const children = node.children as readonly GreenNode[]
441
+ let offset = start + node.leadingWidth
442
+ for (let i = 0; i < children.length; i++) {
443
+ visit(children[i], offset)
444
+ offset += children[i].width
445
+ }
446
+
447
+ if (node.trailingWidth > 0) {
448
+ const at = start + node.width
449
+ for (
450
+ let c = children[children.length - 1] as GreenNode | undefined;
451
+ c !== undefined && wasStackFrame(c) && c.trailingWidth === 0;
452
+ c = c.children[c.children.length - 1] as GreenNode | undefined
453
+ ) {
454
+ if (!open.has(c.kind)) open.set(c.kind, at)
455
+ }
456
+ }
457
+ }
458
+ visit(root, 0)
459
+
460
+ // Whatever the document never retired stays pending to its end and beyond:
461
+ // an edit appending text reads it with the name still set.
462
+ for (const [kind, start] of open) spans.push({ kind, start, end: Number.POSITIVE_INFINITY })
463
+ return new PendingSpans(spans)
464
+ }
465
+
168
466
  /**
169
467
  * Can this window be parsed on its own and mean the same thing it means in
170
468
  * context? Four ways it cannot:
171
469
  *
172
- * - A closing tag with no opener inside the window, whose name matches an
173
- * ANCESTOR. In isolation it is literal text; in the whole document it
174
- * closes that ancestor, which moves the ancestor's own boundary. An orphan
175
- * that matches no ancestor is text either way and is perfectly safe — being
176
- * conservative here cost the incremental path most of its opportunities,
177
- * since a half-typed `[/color]` is the single most common transient state
178
- * while editing.
470
+ * - A closing tag with no opener inside the window, in one of two situations.
471
+ * In isolation it is literal text; in the whole document it may be
472
+ * something else, and the window cannot tell which:
473
+ * · if the name matches an ANCESTOR it closes that ancestor, which moves
474
+ * the ancestor's own boundary;
475
+ * · otherwise it is a `discarded_tag` invisible, not exported exactly
476
+ * when the parser auto-closed a tag of that name EARLIER in the
477
+ * document and no later `[/name]` has claimed it since (see
478
+ * `autoClosed` in `Parser.ts`). That is a fact about the prefix, which
479
+ * a window parse never sees: the differential found a `[/b]` typed
480
+ * after `[quote][b]x[/quote]` coming back as visible text where the
481
+ * full parse discards it. {@link PendingSpans} is what makes that fact
482
+ * answerable here without reading the prefix — and answerable
483
+ * precisely, which matters: refusing every stray closer of a KNOWN tag
484
+ * instead cost the 20 KB mid-document delete its window on the 500 KB
485
+ * fixture, and a large delete strands a closer almost by definition.
486
+ * Anything else is text either way and is perfectly safe — the common case
487
+ * while editing, since a half-typed `[/colo` matches nothing and a finished
488
+ * `[/color]` normally closes a `[color]` inside the window or above it.
489
+ * Plugin tags are the one blind spot: `tagToNodeKind` sees the built-in
490
+ * dialects, not a document's registry, so a stray closer of a plugin tag is
491
+ * compared as `custom`.
179
492
  * - A lone `[` with no `]` after it. The lexer's bracket matching would find
180
493
  * a `]` beyond the window.
181
494
  * - An unclosed `[code]`. Raw blocks swallow everything up to their closing
@@ -200,6 +513,7 @@ function regionIsSelfContained(
200
513
  region: GreenNode,
201
514
  ancestorKinds: ReadonlySet<string>,
202
515
  window: { reachesEnd: boolean },
516
+ pendingKind: (kind: string) => boolean,
203
517
  ): boolean {
204
518
  // Checked on the PARSED region rather than on a second token scan. Lexing the
205
519
  // region twice — once to vet it, once to parse it — cost more than the whole
@@ -209,24 +523,38 @@ function regionIsSelfContained(
209
523
  // Everything the guard needs survives into the tree, because the parser now
210
524
  // keeps what it used to drop: a bare `[` and an orphaned `[/tag]` are both
211
525
  // `text` leaves holding exactly their own source.
526
+ //
527
+ // Leaves inside a raw block are content, not syntax: `[code][/b][/code]`
528
+ // holds a text leaf that IS `[/b]`, and the lexer never matched brackets
529
+ // in there to begin with. The flag rides the stack beside the node.
212
530
  const stack: GreenNode[] = [region]
531
+ const inCode: boolean[] = [false]
213
532
  while (stack.length > 0) {
214
533
  const node = stack.pop()!
534
+ const code = inCode.pop()! || node.kind === 'code' || node.kind === 'inline_code'
215
535
 
216
536
  if (node.children.length === 0) {
217
- if (node.kind === 'text') {
537
+ if (node.kind === 'text' && !code) {
218
538
  // The lexer emits a bare '[' as text exactly when it found no matching
219
539
  // bracket — the one case where its decision depends on what follows.
220
540
  if (node.text === '[') return false
221
541
 
542
+ // A stray closer that is not text after all: an ancestor's, or one
543
+ // the prefix left pending — see the header. Compared by node kind,
544
+ // not tag name: `[centre]` and `[center]` are the same element and
545
+ // either spelling closes it.
222
546
  const orphan = ORPHAN_CLOSE_RE.exec(node.text)
223
- // Compare by node kind, not tag name: `[centre]` and `[center]` are
224
- // the same element and either spelling closes it.
225
- if (orphan !== null && ancestorKinds.has(tagToNodeKind(orphan[1]))) return false
547
+ if (orphan !== null) {
548
+ const kind = tagToNodeKind(orphan[1])
549
+ if (ancestorKinds.has(kind) || pendingKind(kind)) return false
550
+ }
226
551
  }
227
552
  continue
228
553
  }
229
- for (const child of node.children as readonly GreenNode[]) stack.push(child)
554
+ for (const child of node.children as readonly GreenNode[]) {
555
+ stack.push(child)
556
+ inCode.push(code)
557
+ }
230
558
  }
231
559
 
232
560
  // Tags still open at the end of the window are exactly the rightmost chain of
@@ -250,29 +578,6 @@ function regionIsSelfContained(
250
578
  return true
251
579
  }
252
580
 
253
- /**
254
- * Does every `[` before `end` find its `]` before `end` too?
255
- *
256
- * If one does not, the lexer's bracket matching for it scans onward into the
257
- * region we are about to re-parse — and an edit that adds a `]` there (or
258
- * deletes a `[` that was keeping the nesting depth up) changes what that
259
- * OUTSIDE bracket means. The region would be re-parsed correctly and the text
260
- * before it would silently become something else.
261
- *
262
- * A plain depth count is enough and is exact for this question: the lexer
263
- * pairs brackets with a stack, so a `[` is unmatched precisely when the depth
264
- * never returns to its level.
265
- */
266
- function bracketsCloseBefore(source: string, end: number): boolean {
267
- let depth = 0
268
- for (let i = 0; i < end; i++) {
269
- const c = source.charCodeAt(i)
270
- if (c === 91 /* [ */) depth++
271
- else if (c === 93 /* ] */ && depth > 0) depth--
272
- }
273
- return depth === 0
274
- }
275
-
276
581
  export interface IncrementalParserOptions {
277
582
  /** Override `MIN_SOURCE_LENGTH`. Set to 0 to always attempt a splice. */
278
583
  minSourceLength?: number
@@ -284,6 +589,37 @@ export class IncrementalParser {
284
589
  private readonly minSourceLength: number
285
590
  private readonly maxRegionFraction: number
286
591
 
592
+ /**
593
+ * Bracket-depth summary of the source, for the boundary check below.
594
+ *
595
+ * Keyed on the green root it was last synchronised with: a reparse whose
596
+ * `oldGreen` is that root brings the index across the edit by re-reading a
597
+ * few KB around it; any other root (a `rebuild`, a model handed a foreign
598
+ * tree) rebuilds it with one scan — the same scan every keystroke used to
599
+ * pay. See `BracketDepthIndex` for why the summary is exact.
600
+ */
601
+ private readonly brackets = new BracketDepthIndex()
602
+ private bracketsRoot: GreenNode | null = null
603
+ /**
604
+ * Whether, within the current `reparse` call, the index has been brought to
605
+ * describe `newSource`. Explicit rather than inferred: a length comparison
606
+ * would confuse an unsynchronised index over an older text of the same
607
+ * length (insert one character, delete one) with a synchronised one.
608
+ */
609
+ private bracketsSynced = false
610
+
611
+ /**
612
+ * Where the document carries a pending `autoClosed` name, memoised on the
613
+ * green root it was measured over — see {@link PendingSpans}.
614
+ *
615
+ * It survives an incremental splice by construction: the window guard that
616
+ * consults it only lets through edits that leave the crossings on both
617
+ * sides of the window as they were, so the spans need only be moved. Any
618
+ * other outcome drops the key and the next edit pays one walk.
619
+ */
620
+ private pendingRoot: GreenNode | null = null
621
+ private pendingSpans = new PendingSpans([])
622
+
287
623
  /**
288
624
  * The thresholds are constructor options because they are performance
289
625
  * tuning, not semantics: the tree that comes out is the same either way, so
@@ -296,6 +632,57 @@ export class IncrementalParser {
296
632
  this.maxRegionFraction = options.maxRegionFraction ?? MAX_REGION_FRACTION
297
633
  }
298
634
 
635
+ /**
636
+ * Characters the last boundary check actually read — the tail of one index
637
+ * piece, never the prefix. Exposed so a test can pin the bound.
638
+ */
639
+ get lastBoundaryScan(): number {
640
+ return this.brackets.lastScanned
641
+ }
642
+
643
+ /**
644
+ * Does every `[` before `end` find its `]` before `end` too?
645
+ *
646
+ * If one does not, the lexer's bracket matching for it scans onward into
647
+ * the region we are about to re-parse — and an edit that adds a `]` there
648
+ * (or deletes a `[` that was keeping the nesting depth up) changes what that
649
+ * OUTSIDE bracket means. The region would be re-parsed correctly and the
650
+ * text before it would silently become something else.
651
+ *
652
+ * A clamped depth count is exact for this question: the lexer pairs
653
+ * brackets with a stack, so a `[` is unmatched precisely when the depth
654
+ * never returns to its level. The count used to be a scan of the whole
655
+ * prefix on every keystroke — 22% of a keystroke with the caret at the end
656
+ * of a post, 1.4 ms on the 547 KB fixture. The index answers it from piece
657
+ * summaries, reading at most one piece of text.
658
+ *
659
+ * The index is brought across the edit here, not earlier: the paths that
660
+ * return before this point never needed it, and on the next call the
661
+ * root-key mismatch simply rebuilds it. The caller re-keys it on whatever
662
+ * green root it returns.
663
+ */
664
+ private bracketsCloseBefore(
665
+ oldGreen: GreenNode,
666
+ change: TextChange,
667
+ newSource: string,
668
+ end: number,
669
+ ): boolean {
670
+ const inSync = this.bracketsRoot === oldGreen
671
+ // Unkeyed while it is being moved: should the parse callback throw
672
+ // halfway through this call, no later call can mistake the half-moved
673
+ // index for a description of any tree.
674
+ this.bracketsRoot = null
675
+ if (inSync) {
676
+ this.brackets.applyChange(newSource, change.start, change.end, change.text.length)
677
+ } else {
678
+ this.brackets.rebuild(newSource)
679
+ }
680
+ // Describes `newSource` from here on, whatever the outcome; the key is
681
+ // set once the root that owns that text exists (see `keyed`).
682
+ this.bracketsSynced = true
683
+ return this.brackets.depthAt(newSource, end) === 0
684
+ }
685
+
299
686
  /**
300
687
  * Reparse a tree after a text change.
301
688
  *
@@ -313,6 +700,23 @@ export class IncrementalParser {
313
700
  const startTime = performance.now()
314
701
  const delta = change.text.length - (change.end - change.start)
315
702
 
703
+ // Whatever the outcome, the bracket index ends up keyed on the tree whose
704
+ // text it describes — or on nothing, so the next call rebuilds it. A call
705
+ // that returns before the boundary check never moved the index, and the
706
+ // tree it returns describes a text the index does not; the key it had
707
+ // (the previous root) is useless from here on, since the next call comes
708
+ // in with this call's root.
709
+ this.bracketsSynced = false
710
+ // The pending spans carry over only on the incremental path (see the
711
+ // field); anything else re-keys them to nothing so the next call remeasures.
712
+ let carriedSpans: PendingSpans | null = null
713
+ const keyed = (result: ReparseResult): ReparseResult => {
714
+ this.bracketsRoot = this.bracketsSynced ? result.green : null
715
+ this.pendingRoot = carriedSpans === null ? null : result.green
716
+ if (carriedSpans !== null) this.pendingSpans = carriedSpans
717
+ return result
718
+ }
719
+
316
720
  const fullRebuild = (reason: FallbackReason, tFind: number): ReparseResult => {
317
721
  const t0 = performance.now()
318
722
  const green = parseCallback(newSource)
@@ -321,7 +725,7 @@ export class IncrementalParser {
321
725
  const red = buildRedCallback(green)
322
726
  const tBuild = performance.now() - t1
323
727
  const total = performance.now() - startTime
324
- return {
728
+ return keyed({
325
729
  green,
326
730
  red,
327
731
  affectedNodes: [red],
@@ -336,7 +740,8 @@ export class IncrementalParser {
336
740
  },
337
741
  path: 'full_rebuild',
338
742
  reason,
339
- }
743
+ window: null,
744
+ })
340
745
  }
341
746
 
342
747
  if (newSource.length < this.minSourceLength) {
@@ -373,7 +778,7 @@ export class IncrementalParser {
373
778
  if (region.length > (newSource.length + 1) * this.maxRegionFraction) {
374
779
  return fullRebuild('region-too-large', tFind)
375
780
  }
376
- if (!bracketsCloseBefore(newSource, windowStart)) {
781
+ if (!this.bracketsCloseBefore(oldGreen, change, newSource, windowStart)) {
377
782
  return fullRebuild('open-bracket-before', tFind)
378
783
  }
379
784
  const tBoundary = performance.now() - tBoundary0
@@ -385,10 +790,26 @@ export class IncrementalParser {
385
790
  const parsedRegion = parseCallback(region, { normalizeParagraphs: isRoot })
386
791
  const tParse = performance.now() - tParse0
387
792
 
793
+ // Measured on the OLD tree, which is the one the incoming set is a fact
794
+ // about; the guard below is what keeps the answer true of the new one.
795
+ // Both guards want it, so it is settled before either runs.
796
+ if (this.pendingRoot !== oldGreen) {
797
+ this.pendingSpans = collectPendingSpans(oldGreen)
798
+ this.pendingRoot = oldGreen
799
+ }
800
+ const spans = this.pendingSpans
801
+ const pendingKind = (kind: string): boolean => spans.covers(kind, windowStart)
802
+
388
803
  const reachesEnd = to === parent.children.length
389
- if (!regionIsSelfContained(parsedRegion, ancestorKinds, { reachesEnd })) {
804
+ if (!regionIsSelfContained(parsedRegion, ancestorKinds, { reachesEnd }, pendingKind)) {
390
805
  return fullRebuild('region-not-isolated', tFind)
391
806
  }
807
+ if (!pendingAutoClosePreserved(
808
+ parent.children as readonly GreenNode[], from, to, parsedRegion, spans, windowStart,
809
+ )) {
810
+ return fullRebuild('pending-auto-close', tFind)
811
+ }
812
+ carriedSpans = this.pendingSpans.shifted(windowStart, windowEnd, delta)
392
813
 
393
814
  // No rebasing step: the parsed region has widths, not offsets, so it is
394
815
  // already correct wherever it ends up.
@@ -405,7 +826,7 @@ export class IncrementalParser {
405
826
  const tBuild = performance.now() - tBuild0
406
827
 
407
828
  const total = performance.now() - startTime
408
- return {
829
+ return keyed({
409
830
  green: newGreenRoot,
410
831
  red: newRed,
411
832
  affectedNodes: [newRed],
@@ -419,7 +840,9 @@ export class IncrementalParser {
419
840
  other: Math.max(0, total - tFind - tBoundary - tParse - tBuild - tMutate),
420
841
  },
421
842
  path: 'incremental',
422
- }
843
+ // The region, in the coordinates of the text it now describes.
844
+ window: { start: windowStart, end: windowEnd + delta },
845
+ })
423
846
  }
424
847
 
425
848
  /**
@@ -56,6 +56,28 @@ export type BBCodeToken =
56
56
  | BBCodeTextToken
57
57
  | BBCodeNewlineToken
58
58
 
59
+ // ─── Raw blocks ────────────────────────────────────────────────
60
+
61
+ /**
62
+ * Tags whose content is strictly literal: no inner tags, no newline tokens.
63
+ *
64
+ * Exported because the lexer is the ONLY authority on this. Consumers that
65
+ * have to reproduce the rule — a syntax highlighter deciding where to stop
66
+ * tokenising, an editor deciding whether to indent an inserted body — used to
67
+ * carry their own copy of `['code', 'c']`, which is a second lexer waiting to
68
+ * disagree with this one. Adding a raw tag here now reaches them.
69
+ */
70
+ export const BBCODE_RAW_TAGS: readonly string[] = Object.freeze(['code', 'c'])
71
+
72
+ /**
73
+ * Set lookup instead of the two `===` it replaces.
74
+ *
75
+ * This branch runs once per OPENING TAG, not per character — 760 times on the
76
+ * reference document — so it is not on the per-character hot path the rest of
77
+ * this file is tuned around. Correctness of the shared list wins here.
78
+ */
79
+ const RAW_TAG_SET: ReadonlySet<string> = new Set(BBCODE_RAW_TAGS)
80
+
59
81
  // ─── Tag name validation ───────────────────────────────────────
60
82
  //
61
83
  // Tag names used to be handled with two regexes: one tested CHARACTER BY
@@ -295,9 +317,9 @@ export function scanBBCode(source: string): BBCodeToken[] {
295
317
  })
296
318
  pos = closeBracket + 1
297
319
 
298
- // ── RAW BLOCK HANDLING (code, c) ──
299
- // Contents of code blocks are strictly literal. No inner tags or newline tokens.
300
- if (tagName === 'code' || tagName === 'c') {
320
+ // ── RAW BLOCK HANDLING (see BBCODE_RAW_TAGS) ──
321
+ // Contents of raw blocks are strictly literal. No inner tags or newline tokens.
322
+ if (RAW_TAG_SET.has(tagName)) {
301
323
  const endTag = `[/${tagName}]`
302
324
  const closeIdx = lowerOf().indexOf(endTag, pos)
303
325
 
@@ -1,6 +1,6 @@
1
1
  export { Lexer } from './Lexer'
2
2
  export type { LexerOptions, LexerRule } from './Lexer'
3
- export { scanBBCode, formatTokens } from './BBCodeLexer'
3
+ export { scanBBCode, formatTokens, BBCODE_RAW_TAGS } from './BBCodeLexer'
4
4
  export type {
5
5
  BBCodeToken,
6
6
  BBCodeOpenToken,