@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.
- package/dist/Visuals/lyne.css +117 -6
- package/dist/Visuals/osu.css +36 -5
- package/dist/index.js +9664 -3282
- package/dist/index.mjs +9535 -3283
- package/dist/types/Analysis/Contracts/AnalysisReport.d.ts +22 -0
- package/dist/types/Analysis/Contracts/Contribution.d.ts +68 -0
- package/dist/types/Analysis/Contracts/Pass.d.ts +59 -0
- package/dist/types/Analysis/Contracts/PipelineContext.d.ts +34 -0
- package/dist/types/Analysis/Passes/Analysis/ColorUsageAnalyzer.d.ts +56 -0
- package/dist/types/Analysis/Passes/Analysis/GradientAnalyzer.d.ts +111 -0
- package/dist/types/Analysis/Passes/Analysis/MergeableColorAnalyzer.d.ts +26 -0
- package/dist/types/Analysis/Passes/Analysis/RainbowAnalyzer.d.ts +47 -0
- package/dist/types/Analysis/Passes/Analysis/SymbolAnalyzer.d.ts +89 -0
- package/dist/types/Analysis/Passes/Analysis/WaveAnalyzer.d.ts +44 -0
- package/dist/types/Analysis/Passes/Analysis/__tests__/GradientAnalyzer.test.d.ts +4 -0
- package/dist/types/Analysis/Passes/Analysis/__tests__/MergeableColorAnalyzer.test.d.ts +4 -0
- package/dist/types/Analysis/Passes/Analysis/__tests__/RainbowAnalyzer.test.d.ts +4 -0
- package/dist/types/Analysis/Passes/Analysis/__tests__/WaveAnalyzer.test.d.ts +4 -0
- package/dist/types/Analysis/Passes/Decision/DefaultDecision.d.ts +30 -0
- package/dist/types/Analysis/Passes/Decision/PaletteRemapDecision.d.ts +169 -0
- package/dist/types/Analysis/Passes/Decision/__tests__/DefaultDecision.test.d.ts +4 -0
- package/dist/types/Analysis/Passes/Transform/CollapseGradientTransform.d.ts +37 -0
- package/dist/types/Analysis/Passes/Transform/MergeColorsTransform.d.ts +28 -0
- package/dist/types/Analysis/Passes/Transform/RainbowCollapseTransform.d.ts +19 -0
- package/dist/types/Analysis/Passes/Transform/WaveCollapseTransform.d.ts +20 -0
- package/dist/types/Analysis/Passes/Utility/CharacterCountAnalyzer.d.ts +19 -0
- package/dist/types/Analysis/Pipeline/Pipeline.d.ts +69 -0
- package/dist/types/Analysis/Pipeline/PipelineBuilder.d.ts +31 -0
- package/dist/types/Analysis/Pipeline/PipelineStage.d.ts +17 -0
- package/dist/types/Analysis/Utils/color-utils.d.ts +60 -0
- package/dist/types/Analysis/__tests__/Integration.test.d.ts +8 -0
- package/dist/types/Analysis/__tests__/PaletteRemap.test.d.ts +14 -0
- package/dist/types/Analysis/__tests__/Pipeline.test.d.ts +10 -0
- package/dist/types/Analysis/__tests__/RoundTrip.test.d.ts +30 -0
- package/dist/types/Analysis/__tests__/SymbolAnalyzer.test.d.ts +11 -0
- package/dist/types/Analysis/index.d.ts +40 -0
- package/dist/types/BBCode/BBCodeDocumentModel.d.ts +92 -0
- package/dist/types/BBCode/BBCodeToGreenNode.d.ts +111 -0
- package/dist/types/BBCode/Parser.d.ts +78 -0
- package/dist/types/BBCode/index.d.ts +4 -0
- package/dist/types/Collab/positions.d.ts +55 -0
- package/dist/types/Commands/Command.d.ts +37 -0
- package/dist/types/Commands/CommandRegistry.d.ts +48 -0
- package/dist/types/Commands/DeleteNode.d.ts +7 -0
- package/dist/types/Commands/InsertText.d.ts +8 -0
- package/dist/types/Commands/SplitMerge.d.ts +9 -0
- package/dist/types/Commands/WrapInTag.d.ts +8 -0
- package/dist/types/Commands/index.d.ts +6 -0
- package/dist/types/Diff/TreeDiffer.d.ts +52 -0
- package/dist/types/Diff/__tests__/TreeDiffer.test.d.ts +1 -0
- package/dist/types/Diff/index.d.ts +2 -0
- package/dist/types/Edits/EditPlan.d.ts +144 -0
- package/dist/types/Edits/Optimizer.d.ts +78 -0
- package/dist/types/Edits/Rules/Rule.d.ts +91 -0
- package/dist/types/Edits/Rules/dropEmptyTags.d.ts +40 -0
- package/dist/types/Edits/Rules/dropRedundantNesting.d.ts +59 -0
- package/dist/types/Edits/Rules/index.d.ts +9 -0
- package/dist/types/Edits/Rules/mergeAdjacent.d.ts +93 -0
- package/dist/types/Edits/Rules/reorderWrappers.d.ts +42 -0
- package/dist/types/Edits/Rules/shortenHex.d.ts +30 -0
- package/dist/types/Edits/Rules/tagValue.d.ts +56 -0
- package/dist/types/Edits/Rules/unwrapInvisibleColor.d.ts +47 -0
- package/dist/types/Edits/applyEdits.d.ts +36 -0
- package/dist/types/Edits/index.d.ts +6 -0
- package/dist/types/Events/EventBus.d.ts +103 -0
- package/dist/types/Events/index.d.ts +2 -0
- package/dist/types/Formatter/Formatter.d.ts +32 -0
- package/dist/types/Formatter/index.d.ts +2 -0
- package/dist/types/HTML/HTMLDocumentModel.d.ts +13 -0
- package/dist/types/HTML/HTMLToGreenNode.d.ts +4 -0
- package/dist/types/Incremental/BracketIndex.d.ts +101 -0
- package/dist/types/Incremental/ChangeTracker.d.ts +69 -0
- package/dist/types/Incremental/IncrementalParser.d.ts +246 -0
- package/dist/types/Incremental/__tests__/BracketIndex.test.d.ts +1 -0
- package/dist/types/Incremental/__tests__/IncrementalParser.test.d.ts +1 -0
- package/dist/types/Incremental/index.d.ts +4 -0
- package/dist/types/Lexer/BBCodeLexer.d.ts +74 -0
- package/dist/types/Lexer/Lexer.d.ts +54 -0
- package/dist/types/Lexer/index.d.ts +4 -0
- package/dist/types/Linter/Linter.d.ts +58 -0
- package/dist/types/Linter/index.d.ts +2 -0
- package/dist/types/Markdown/MarkdownAST.d.ts +122 -0
- package/dist/types/Markdown/MarkdownDocumentModel.d.ts +15 -0
- package/dist/types/Markdown/MarkdownLexer.d.ts +155 -0
- package/dist/types/Markdown/MarkdownParser.d.ts +42 -0
- package/dist/types/Markdown/MarkdownToGreenNode.d.ts +6 -0
- package/dist/types/MilHibri/MilHibriDocumentModel.d.ts +21 -0
- package/dist/types/Model/DocumentModel.d.ts +318 -0
- package/dist/types/Model/NodeFactory.d.ts +29 -0
- package/dist/types/Model/TagRegistry.d.ts +165 -0
- package/dist/types/Model/index.d.ts +5 -0
- package/dist/types/Plugins/PluginAPI.d.ts +44 -0
- package/dist/types/Plugins/PluginRegistry.d.ts +81 -0
- package/dist/types/Plugins/index.d.ts +3 -0
- package/dist/types/Queries/QueryEngine.d.ts +32 -0
- package/dist/types/Queries/index.d.ts +1 -0
- package/dist/types/Reconciler/SurgicalReconciler.d.ts +23 -0
- package/dist/types/RenderPipeline/RenderPipeline.d.ts +46 -0
- package/dist/types/RenderPipeline/RenderTree.d.ts +55 -0
- package/dist/types/RenderPipeline/index.d.ts +4 -0
- package/dist/types/Repair/NestingRepair.d.ts +48 -0
- package/dist/types/Repair/index.d.ts +1 -0
- package/dist/types/Semantic/SemanticAnalyzer.d.ts +321 -0
- package/dist/types/Semantic/index.d.ts +2 -0
- package/dist/types/Symbols/SymbolTable.d.ts +47 -0
- package/dist/types/Symbols/index.d.ts +1 -0
- package/dist/types/Syntax/GreenNode.d.ts +154 -0
- package/dist/types/Syntax/GreenNodePool.d.ts +134 -0
- package/dist/types/Syntax/NodeMatcher.d.ts +75 -0
- package/dist/types/Syntax/RedNode.d.ts +280 -0
- package/dist/types/Syntax/RedNodeStore.d.ts +116 -0
- package/dist/types/Syntax/TreeBuilder.d.ts +54 -0
- package/dist/types/Syntax/__tests__/GreenNode.test.d.ts +1 -0
- package/dist/types/Syntax/__tests__/RedNode.test.d.ts +1 -0
- package/dist/types/Syntax/__tests__/RedNodeStore.test.d.ts +1 -0
- package/dist/types/Syntax/greenEdit.d.ts +64 -0
- package/dist/types/Syntax/hash.d.ts +17 -0
- package/dist/types/Syntax/index.d.ts +8 -0
- package/dist/types/Syntax/nodeAttr.d.ts +63 -0
- package/dist/types/Syntax/partition.d.ts +67 -0
- package/dist/types/Syntax/preserveNodeIds.d.ts +47 -0
- package/dist/types/Tokens/TokenResolver.d.ts +25 -0
- package/dist/types/Tokens/index.d.ts +1 -0
- package/dist/types/Transactions/Transaction.d.ts +36 -0
- package/dist/types/Transactions/UndoManager.d.ts +63 -0
- package/dist/types/Transactions/index.d.ts +3 -0
- package/dist/types/Transformers/ASTOptimizer.d.ts +34 -0
- package/dist/types/Transformers/GradientTransformer.d.ts +18 -0
- package/dist/types/Transformers/GrowTransformer.d.ts +16 -0
- package/dist/types/Transformers/RainbowTransformer.d.ts +17 -0
- package/dist/types/Transformers/SineWaveTransformer.d.ts +17 -0
- package/dist/types/Transformers/Transformer.d.ts +17 -0
- package/dist/types/Types/core.d.ts +104 -0
- package/dist/types/Types/diagnostics.d.ts +85 -0
- package/dist/types/Types/index.d.ts +11 -0
- package/dist/types/Types/operations.d.ts +109 -0
- package/dist/types/Types/queries.d.ts +67 -0
- package/dist/types/Types/symbols.d.ts +42 -0
- package/dist/types/Types/tokens.d.ts +75 -0
- package/dist/types/Utils/BBCodeGenerator.d.ts +21 -0
- package/dist/types/Utils/ColorMath.d.ts +92 -0
- package/dist/types/Utils/EffectMath.d.ts +643 -0
- package/dist/types/Utils/TagVocabulary.d.ts +104 -0
- package/dist/types/Utils/TagVocabulary.test.d.ts +1 -0
- package/dist/types/Utils/color.d.ts +29 -0
- package/dist/types/Utils/dom-to-svg.d.ts +28 -0
- package/dist/types/Utils/dom-to-svg.test.d.ts +1 -0
- package/dist/types/Utils/treeTransformers.d.ts +160 -0
- package/dist/types/Visitors/BBBlocksExporter.d.ts +27 -0
- package/dist/types/Visitors/BBCodeExporter.d.ts +90 -0
- package/dist/types/Visitors/BlockPatcher.d.ts +91 -0
- package/dist/types/Visitors/DOMMorpher.d.ts +13 -0
- package/dist/types/Visitors/HTMLRenderer.d.ts +380 -0
- package/dist/types/Visitors/JSONExporter.d.ts +42 -0
- package/dist/types/Visitors/MarkdownExporter.d.ts +28 -0
- package/dist/types/Visitors/SVGRenderer.d.ts +16 -0
- package/dist/types/Visitors/TiptapExporter.d.ts +40 -0
- package/dist/types/Visitors/Visitor.d.ts +35 -0
- package/dist/types/Visitors/index.d.ts +9 -0
- package/dist/types/Visuals/BoxDrawer.d.ts +49 -0
- package/dist/types/Visuals/index.d.ts +38 -0
- package/dist/types/index.d.ts +154 -0
- package/package.json +14 -10
- package/src/Analysis/Passes/Analysis/ColorUsageAnalyzer.ts +103 -0
- package/src/Analysis/Passes/Analysis/GradientAnalyzer.ts +294 -20
- package/src/Analysis/Passes/Analysis/SymbolAnalyzer.ts +257 -0
- package/src/Analysis/Passes/Decision/PaletteRemapDecision.ts +425 -0
- package/src/Analysis/Pipeline/Pipeline.ts +15 -2
- package/src/Analysis/index.ts +8 -2
- package/src/BBCode/BBCodeDocumentModel.ts +3 -0
- package/src/BBCode/BBCodeToGreenNode.ts +18 -12
- package/src/BBCode/Parser.ts +38 -1
- package/src/Edits/EditPlan.ts +290 -0
- package/src/Edits/Optimizer.ts +185 -0
- package/src/Edits/Rules/Rule.ts +145 -0
- package/src/Edits/Rules/dropEmptyTags.ts +110 -0
- package/src/Edits/Rules/dropRedundantNesting.ts +120 -0
- package/src/Edits/Rules/index.ts +28 -0
- package/src/Edits/Rules/mergeAdjacent.ts +224 -0
- package/src/Edits/Rules/reorderWrappers.ts +154 -0
- package/src/Edits/Rules/shortenHex.ts +79 -0
- package/src/Edits/Rules/tagValue.ts +105 -0
- package/src/Edits/Rules/unwrapInvisibleColor.ts +93 -0
- package/src/Edits/applyEdits.ts +54 -0
- package/src/Edits/index.ts +22 -0
- package/src/Events/EventBus.ts +18 -0
- package/src/HTML/HTMLToGreenNode.ts +185 -37
- package/src/Incremental/BracketIndex.ts +249 -0
- package/src/Incremental/IncrementalParser.ts +464 -41
- package/src/Lexer/BBCodeLexer.ts +25 -3
- package/src/Lexer/index.ts +1 -1
- package/src/Markdown/MarkdownAST.ts +65 -2
- package/src/Markdown/MarkdownLexer.ts +69 -14
- package/src/Markdown/MarkdownParser.ts +749 -62
- package/src/Markdown/MarkdownToGreenNode.ts +148 -27
- package/src/MilHibri/MilHibriDocumentModel.ts +56 -0
- package/src/Model/DocumentModel.ts +138 -5
- package/src/Model/TagRegistry.ts +129 -134
- package/src/Reconciler/SurgicalReconciler.ts +460 -0
- package/src/Repair/NestingRepair.ts +116 -0
- package/src/Repair/index.ts +1 -0
- package/src/Semantic/SemanticAnalyzer.ts +1802 -65
- package/src/Syntax/GreenNodePool.ts +12 -12
- package/src/Syntax/RedNode.ts +17 -3
- package/src/Syntax/RedNodeStore.ts +9 -1
- package/src/Syntax/nodeAttr.ts +172 -0
- package/src/Tokens/TokenResolver.ts +55 -0
- package/src/Tokens/index.ts +1 -0
- package/src/Types/core.ts +15 -0
- package/src/Utils/ColorMath.ts +276 -55
- package/src/Utils/EffectMath.ts +2462 -0
- package/src/Utils/TagVocabulary.ts +141 -0
- package/src/Utils/dom-to-svg.ts +28 -12
- package/src/Visitors/BBBlocksExporter.ts +15 -1
- package/src/Visitors/BBCodeExporter.ts +319 -32
- package/src/Visitors/BlockPatcher.ts +87 -11
- package/src/Visitors/DOMMorpher.ts +15 -5
- package/src/Visitors/HTMLRenderer.ts +374 -151
- package/src/Visitors/MarkdownExporter.ts +98 -41
- package/src/Visitors/TiptapExporter.ts +1 -0
- package/src/Visuals/index.ts +7 -4
- package/src/Visuals/lyne.css +117 -6
- package/src/Visuals/osu.css +36 -5
- package/src/index.ts +172 -5
- package/dist/index.d.mts +0 -4612
- package/dist/index.d.ts +0 -4612
- package/src/Analysis/Passes/Analysis/__tests__/GradientAnalyzer.test.ts +0 -135
- package/src/Analysis/Passes/Analysis/__tests__/MergeableColorAnalyzer.test.ts +0 -84
- package/src/Analysis/Passes/Analysis/__tests__/RainbowAnalyzer.test.ts +0 -99
- package/src/Analysis/Passes/Analysis/__tests__/WaveAnalyzer.test.ts +0 -119
- package/src/Analysis/Passes/Decision/__tests__/DefaultDecision.test.ts +0 -179
- package/src/Analysis/__tests__/Integration.test.ts +0 -162
- package/src/Analysis/__tests__/Pipeline.test.ts +0 -133
- package/src/Diff/__tests__/TreeDiffer.test.ts +0 -65
- package/src/Incremental/__tests__/IncrementalParser.test.ts +0 -164
- package/src/Syntax/__tests__/GreenNode.test.ts +0 -33
- package/src/Syntax/__tests__/RedNode.test.ts +0 -81
- package/src/Syntax/__tests__/RedNodeStore.test.ts +0 -104
- package/src/Tests/ASTOptimizerIdempotence.test.ts +0 -77
- package/src/Tests/BlockPatcher.test.ts +0 -437
- package/src/Tests/BlockPatcherWindowed.test.ts +0 -364
- package/src/Tests/BoxDrawer.test.ts +0 -217
- package/src/Tests/BoxRichTitle.test.ts +0 -105
- package/src/Tests/Chars500kBenchmark.test.ts +0 -151
- package/src/Tests/Chars500kEdits.test.ts +0 -321
- package/src/Tests/CollabPositions.test.ts +0 -146
- package/src/Tests/CompilerPathProfiling.test.ts +0 -186
- package/src/Tests/DOMMorpher.test.ts +0 -142
- package/src/Tests/DomPatchPerf.test.ts +0 -60
- package/src/Tests/EffectSegments.snapshot.json +0 -616
- package/src/Tests/EffectSegments.test.ts +0 -68
- package/src/Tests/FindNodeAtOffset.test.ts +0 -65
- package/src/Tests/Fuzzer.test.ts +0 -166
- package/src/Tests/GreenNodePool.test.ts +0 -153
- package/src/Tests/Lexer.test.ts +0 -238
- package/src/Tests/LyneMode.test.ts +0 -291
- package/src/Tests/ModelCoherence.test.ts +0 -180
- package/src/Tests/Partition.test.ts +0 -238
- package/src/Tests/PluginTags.test.ts +0 -150
- package/src/Tests/ProblematicSection.test.ts +0 -46
- package/src/Tests/ProblematicSectionHTML.test.ts +0 -58
- package/src/Tests/RedReuse.test.ts +0 -134
- package/src/Tests/ReproDelete20k.test.ts +0 -62
- package/src/Tests/SemanticValidators.test.ts +0 -136
- package/src/Tests/StableNodeIds.test.ts +0 -210
- package/src/Tests/StudioColorBloat.test.ts +0 -25
- package/src/Tests/StudioDebugText.test.ts +0 -27
- package/src/Tests/StudioTrailingChar.test.ts +0 -25
- package/src/Tests/StudioValidText.test.ts +0 -25
- package/src/Tests/UrlImgBug.test.ts +0 -23
- package/src/Tests/VisualBuilderFidelity.test.ts +0 -105
- package/src/Tests/referenceDocument.ts +0 -119
- 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
|
-
|
|
50
|
-
|
|
127
|
+
const own = detachRange(result[i])
|
|
128
|
+
addDiagnostic(diagnostics, own)
|
|
129
|
+
node.diagnostics.push(own)
|
|
51
130
|
}
|
|
52
131
|
} else {
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
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
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
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
|
|
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 (
|
|
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
|
|
192
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
1563
|
+
return {
|
|
305
1564
|
get allNodes(): Map<string, RedNode> {
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
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: ['
|
|
356
|
-
validate: (node) => {
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
{
|
|
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
|
-
//
|
|
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
|
-
{
|
|
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
|
/**
|