|
1 | | -import type { Text } from "@codemirror/state"; |
2 | | -import { RangeSetBuilder } from "@codemirror/state"; |
| 1 | +import { syntaxTree } from "@codemirror/language"; |
3 | 2 | import type { DecorationSet, ViewUpdate } from "@codemirror/view"; |
4 | 3 | import { Decoration, EditorView, ViewPlugin } from "@codemirror/view"; |
| 4 | +import type { SyntaxNode } from "@lezer/common"; |
5 | 5 |
|
6 | | -export const defaultRainbowColors: string[] = [ |
7 | | - "red", |
8 | | - "orange", |
9 | | - "yellow", |
10 | | - "green", |
11 | | - "blue", |
12 | | - "indigo", |
13 | | - "violet", |
14 | | -]; |
15 | | - |
16 | | -interface ThemeRules { |
17 | | - [selector: string]: { color: string }; |
18 | | -} |
| 6 | +const COLORS = ["gold", "orchid", "lightblue"]; |
19 | 7 |
|
20 | | -interface DepthCache { |
21 | | - balances: Int32Array; |
22 | | - prefixDepth: Int32Array; |
23 | | - version: number; |
24 | | -} |
| 8 | +// Token types that should be skipped (brackets inside these are not colored) |
| 9 | +const SKIP_CONTEXTS = new Set([ |
| 10 | + "String", |
| 11 | + "TemplateString", |
| 12 | + "Comment", |
| 13 | + "LineComment", |
| 14 | + "BlockComment", |
| 15 | + "RegExp", |
| 16 | +]); |
25 | 17 |
|
26 | | -interface RainbowBracketsOptions { |
27 | | - colors?: string[]; |
28 | | - useLight?: boolean; |
29 | | -} |
30 | | - |
31 | | -interface ViewportRange { |
| 18 | +interface BracketInfo { |
32 | 19 | from: number; |
33 | 20 | to: number; |
| 21 | + depth: number; |
| 22 | + char: string; |
34 | 23 | } |
35 | 24 |
|
36 | | -/** |
37 | | - * Build a base theme for N colors. |
38 | | - */ |
39 | | -function rainbowTheme(colors: string[]) { |
40 | | - const rules: ThemeRules = {}; |
41 | | - // Depth k (1..N) maps to class .cm-rb-dk |
42 | | - for (let i = 0; i < colors.length; i++) { |
43 | | - const depth = i + 1; |
44 | | - rules[`.cm-rb-d${depth}`] = { color: colors[i] }; |
45 | | - } |
46 | | - return EditorView.baseTheme(rules); |
47 | | -} |
48 | | - |
49 | | -function lineBalance(text: string): number { |
50 | | - let bal = 0; |
51 | | - for (let i = 0, n = text.length; i < n; i++) { |
52 | | - const ch = text.charCodeAt(i); |
53 | | - // Quick switch on a few ASCII codes |
54 | | - // '(', ')', '[', ']', '{', '}' |
55 | | - if (ch === 40 || ch === 91 || ch === 123) bal++; |
56 | | - else if (ch === 41 || ch === 93 || ch === 125) bal--; |
57 | | - } |
58 | | - return bal; |
59 | | -} |
60 | | - |
61 | | -function computeDepthCache(doc: Text): DepthCache { |
62 | | - const lineCount = doc.lines; |
63 | | - const balances = new Int32Array(lineCount); |
64 | | - const prefixDepth = new Int32Array(lineCount + 1); // prefixDepth[1] for line 1 |
65 | | - // Iterate once through all lines |
66 | | - for (let ln = 1; ln <= lineCount; ln++) { |
67 | | - const t = doc.line(ln).text; |
68 | | - const bal = lineBalance(t); |
69 | | - balances[ln - 1] = bal; |
70 | | - prefixDepth[ln] = prefixDepth[ln - 1] + bal; |
71 | | - } |
72 | | - return { balances, prefixDepth, version: doc.length }; // track length as a cheap change marker |
73 | | -} |
| 25 | +const rainbowBracketsPlugin = ViewPlugin.fromClass( |
| 26 | + class { |
| 27 | + decorations: DecorationSet; |
74 | 28 |
|
75 | | -function firstChangedLine(update: ViewUpdate): number { |
76 | | - let min = Number.POSITIVE_INFINITY; |
77 | | - update.changes.iterChanges( |
78 | | - (fromA: number, _toA: number, fromB: number, _toB: number) => { |
79 | | - const ln = update.state.doc.lineAt(fromB).number; |
80 | | - if (ln < min) min = ln; |
81 | | - }, |
82 | | - ); |
83 | | - if (min === Number.POSITIVE_INFINITY) return 1; |
84 | | - return Math.max(1, min); |
85 | | -} |
| 29 | + constructor(view: EditorView) { |
| 30 | + this.decorations = this.buildDecorations(view); |
| 31 | + } |
86 | 32 |
|
87 | | -function recomputeDepthCache( |
88 | | - prevCache: DepthCache | null, |
89 | | - prevDoc: Text | null, |
90 | | - newDoc: Text, |
91 | | - startLine = 1, |
92 | | -): DepthCache { |
93 | | - const lineCount = newDoc.lines; |
94 | | - const balances = new Int32Array(lineCount); |
95 | | - const prefixDepth = new Int32Array(lineCount + 1); |
96 | | - |
97 | | - // Copy prefix for unchanged prefix lines |
98 | | - const copyEnd = Math.max(1, Math.min(startLine - 1, lineCount)); |
99 | | - if (prevCache && prevDoc) { |
100 | | - for (let ln = 1; ln <= copyEnd; ln++) { |
101 | | - balances[ln - 1] = prevCache.balances[ln - 1] || 0; |
102 | | - prefixDepth[ln] = prevCache.prefixDepth[ln] || 0; |
| 33 | + update(update: ViewUpdate) { |
| 34 | + if (update.docChanged || update.viewportChanged) { |
| 35 | + this.decorations = this.buildDecorations(update.view); |
| 36 | + } |
103 | 37 | } |
104 | | - } |
105 | 38 |
|
106 | | - // If nothing to copy, ensure prefixDepth[0] = 0 |
107 | | - if (copyEnd === 0) prefixDepth[0] = 0; |
| 39 | + buildDecorations(view: EditorView): DecorationSet { |
| 40 | + const decorations: { from: number; to: number; color: string }[] = []; |
| 41 | + const tree = syntaxTree(view.state); |
108 | 42 |
|
109 | | - // Start depth for startLine |
110 | | - const startDepth = prefixDepth[startLine - 1] || 0; |
111 | | - prefixDepth[startLine - 1] = startDepth; // make sure defined |
| 43 | + // Process only visible ranges for performance |
| 44 | + for (const { from, to } of view.visibleRanges) { |
| 45 | + this.processRange(view, tree, from, to, decorations); |
| 46 | + } |
112 | 47 |
|
113 | | - for (let ln = startLine; ln <= lineCount; ln++) { |
114 | | - const t = newDoc.line(ln).text; |
115 | | - const bal = lineBalance(t); |
116 | | - balances[ln - 1] = bal; |
117 | | - prefixDepth[ln] = prefixDepth[ln - 1] + bal; |
118 | | - } |
| 48 | + // Sort by position (required for Decoration.set) |
| 49 | + decorations.sort((a, b) => a.from - b.from); |
119 | 50 |
|
120 | | - return { balances, prefixDepth, version: newDoc.length }; |
121 | | -} |
| 51 | + // Build decoration marks |
| 52 | + const marks = decorations.map((d) => |
| 53 | + Decoration.mark({ class: `cm-bracket-${d.color}` }).range(d.from, d.to), |
| 54 | + ); |
122 | 55 |
|
123 | | -function buildDecorationBank(maxDepth: number): Decoration[] { |
124 | | - const arr: Decoration[] = new Array(maxDepth); |
125 | | - for (let i = 0; i < maxDepth; i++) { |
126 | | - const cls = `cm-rb-d${i + 1}`; |
127 | | - arr[i] = Decoration.mark({ class: cls }); |
128 | | - } |
129 | | - return arr; |
130 | | -} |
| 56 | + return Decoration.set(marks); |
| 57 | + } |
131 | 58 |
|
132 | | -/** |
133 | | - * The main extension factory. |
134 | | - */ |
135 | | -export function rainbowBrackets(options: RainbowBracketsOptions = {}) { |
136 | | - const palette = options.colors || defaultRainbowColors; |
137 | | - const theme = rainbowTheme(palette); |
138 | | - const bank = buildDecorationBank(palette.length); |
139 | | - |
140 | | - class RainbowPlugin { |
141 | | - view: EditorView; |
142 | | - cache: DepthCache; |
143 | | - decorations: DecorationSet; |
| 59 | + processRange( |
| 60 | + view: EditorView, |
| 61 | + tree: ReturnType<typeof syntaxTree>, |
| 62 | + from: number, |
| 63 | + to: number, |
| 64 | + decorations: { from: number; to: number; color: string }[], |
| 65 | + ): void { |
| 66 | + const { doc } = view.state; |
| 67 | + const openBrackets: BracketInfo[] = []; |
| 68 | + |
| 69 | + // Iterate through the document in the visible range |
| 70 | + for (let pos = from; pos < to; pos++) { |
| 71 | + const char = doc.sliceString(pos, pos + 1); |
| 72 | + |
| 73 | + // Check if this is a bracket character |
| 74 | + if (!this.isBracketChar(char)) continue; |
| 75 | + |
| 76 | + // Use syntax tree to check if this bracket should be colored |
| 77 | + if (this.isInSkipContext(tree, pos)) continue; |
| 78 | + |
| 79 | + if (char === "(" || char === "[" || char === "{") { |
| 80 | + // Opening bracket - push to stack with current depth |
| 81 | + openBrackets.push({ |
| 82 | + from: pos, |
| 83 | + to: pos + 1, |
| 84 | + depth: openBrackets.length, |
| 85 | + char, |
| 86 | + }); |
| 87 | + } else if (char === ")" || char === "]" || char === "}") { |
| 88 | + // Closing bracket - find matching open bracket |
| 89 | + const matchingOpen = this.getMatchingOpenBracket(char); |
| 90 | + let matchFound = false; |
| 91 | + |
| 92 | + // Search backwards for matching open bracket |
| 93 | + for (let i = openBrackets.length - 1; i >= 0; i--) { |
| 94 | + if (openBrackets[i].char === matchingOpen) { |
| 95 | + const open = openBrackets[i]; |
| 96 | + const depth = open.depth; |
| 97 | + const color = COLORS[depth % COLORS.length]; |
| 98 | + |
| 99 | + // Add decorations for both brackets |
| 100 | + decorations.push( |
| 101 | + { from: open.from, to: open.to, color }, |
| 102 | + { from: pos, to: pos + 1, color }, |
| 103 | + ); |
| 104 | + |
| 105 | + // Remove matched bracket and all unmatched brackets after it |
| 106 | + openBrackets.splice(i); |
| 107 | + matchFound = true; |
| 108 | + break; |
| 109 | + } |
| 110 | + } |
144 | 111 |
|
145 | | - constructor(view: EditorView) { |
146 | | - this.view = view; |
147 | | - this.cache = computeDepthCache(view.state.doc); |
148 | | - this.decorations = this.compute(); |
| 112 | + // If no match found, this is an unmatched closing bracket |
| 113 | + if (!matchFound) { |
| 114 | + // Unmatched closing bracket |
| 115 | + } |
| 116 | + } |
| 117 | + } |
149 | 118 | } |
150 | 119 |
|
151 | | - update(update: ViewUpdate): void { |
152 | | - if (update.docChanged) { |
153 | | - const startLn = firstChangedLine(update); |
154 | | - this.cache = recomputeDepthCache( |
155 | | - this.cache, |
156 | | - update.startState.doc, |
157 | | - update.state.doc, |
158 | | - startLn, |
159 | | - ); |
160 | | - } |
161 | | - if (update.docChanged || update.viewportChanged) { |
162 | | - this.decorations = this.compute(); |
163 | | - } |
| 120 | + isBracketChar(char: string): boolean { |
| 121 | + return ( |
| 122 | + char === "(" || |
| 123 | + char === ")" || |
| 124 | + char === "[" || |
| 125 | + char === "]" || |
| 126 | + char === "{" || |
| 127 | + char === "}" |
| 128 | + ); |
164 | 129 | } |
165 | 130 |
|
166 | | - compute(): DecorationSet { |
167 | | - const { view } = this; |
168 | | - const { cache } = this; |
169 | | - const builder = new RangeSetBuilder<Decoration>(); |
170 | | - const colorCount = palette.length; |
171 | | - if (!colorCount) return builder.finish(); |
| 131 | + isInSkipContext(tree: ReturnType<typeof syntaxTree>, pos: number): boolean { |
| 132 | + let node: SyntaxNode | null = tree.resolveInner(pos, 1); |
172 | 133 |
|
173 | | - const margin = 200; |
174 | | - const windows: ViewportRange[] = []; |
175 | | - for (const { from, to } of view.visibleRanges) { |
176 | | - const start = Math.max(0, from - margin); |
177 | | - const end = Math.min(view.state.doc.length, to + margin); |
178 | | - if (start < end) windows.push({ from: start, to: end }); |
179 | | - } |
180 | | - windows.sort((a, b) => a.from - b.from || a.to - b.to); |
181 | | - const merged: ViewportRange[] = []; |
182 | | - for (const w of windows) { |
183 | | - if (!merged.length || w.from > merged[merged.length - 1].to) { |
184 | | - merged.push({ ...w }); |
185 | | - } else { |
186 | | - merged[merged.length - 1].to = Math.max( |
187 | | - merged[merged.length - 1].to, |
188 | | - w.to, |
189 | | - ); |
| 134 | + // Walk up the tree to check if we're inside a skip context |
| 135 | + while (node) { |
| 136 | + if (SKIP_CONTEXTS.has(node.name)) { |
| 137 | + return true; |
190 | 138 | } |
| 139 | + node = node.parent; |
191 | 140 | } |
192 | 141 |
|
193 | | - for (const { from: winStart, to: winEnd } of merged) { |
194 | | - // Start scanning exactly at window start to keep builder.add calls sorted |
195 | | - const startLine = view.state.doc.lineAt(winStart); |
196 | | - let depth = cache.prefixDepth[startLine.number - 1]; |
197 | | - // Adjust depth if starting mid-line |
198 | | - const startOffset = winStart - startLine.from; |
199 | | - if (startOffset > 0) { |
200 | | - depth += lineBalance(startLine.text.slice(0, startOffset)); |
201 | | - } |
| 142 | + return false; |
| 143 | + } |
202 | 144 |
|
203 | | - let pos = winStart; |
204 | | - while (pos < winEnd) { |
205 | | - const line = view.state.doc.lineAt(pos); |
206 | | - const text = line.text; |
207 | | - const lineStart = line.from; |
208 | | - const upto = Math.min(line.to, winEnd); |
209 | | - const iStart = Math.max(0, pos - lineStart); |
210 | | - for (let i = iStart, n = upto - lineStart; i < n; i++) { |
211 | | - const ch = text.charCodeAt(i); |
212 | | - if (ch === 40 || ch === 91 || ch === 123) { |
213 | | - const clsIndex = ((depth % colorCount) + colorCount) % colorCount; |
214 | | - builder.add(lineStart + i, lineStart + i + 1, bank[clsIndex]); |
215 | | - depth++; |
216 | | - } else if (ch === 41 || ch === 93 || ch === 125) { |
217 | | - depth = Math.max(depth - 1, 0); |
218 | | - const clsIndex = ((depth % colorCount) + colorCount) % colorCount; |
219 | | - builder.add(lineStart + i, lineStart + i + 1, bank[clsIndex]); |
220 | | - } |
221 | | - } |
222 | | - const nextLineNo = line.number + 1; |
223 | | - if (nextLineNo <= view.state.doc.lines) |
224 | | - depth = cache.prefixDepth[nextLineNo - 1]; |
225 | | - pos = line.to + 1; |
226 | | - } |
| 145 | + getMatchingOpenBracket(closing: string): string | null { |
| 146 | + switch (closing) { |
| 147 | + case ")": |
| 148 | + return "("; |
| 149 | + case "]": |
| 150 | + return "["; |
| 151 | + case "}": |
| 152 | + return "{"; |
| 153 | + default: |
| 154 | + return null; |
227 | 155 | } |
228 | | - return builder.finish(); |
229 | 156 | } |
230 | | - } |
231 | | - |
232 | | - const vp = ViewPlugin.fromClass(RainbowPlugin, { |
| 157 | + }, |
| 158 | + { |
233 | 159 | decorations: (v) => v.decorations, |
234 | | - }); |
235 | | - |
236 | | - return [vp, theme]; |
| 160 | + }, |
| 161 | +); |
| 162 | + |
| 163 | +const theme = EditorView.baseTheme({ |
| 164 | + ".cm-bracket-gold": { color: "#FFD700 !important" }, |
| 165 | + ".cm-bracket-gold > span": { color: "#FFD700 !important" }, |
| 166 | + ".cm-bracket-orchid": { color: "#DA70D6 !important" }, |
| 167 | + ".cm-bracket-orchid > span": { color: "#DA70D6 !important" }, |
| 168 | + ".cm-bracket-lightblue": { color: "#179FFF !important" }, |
| 169 | + ".cm-bracket-lightblue > span": { color: "#179FFF !important" }, |
| 170 | +}); |
| 171 | + |
| 172 | +export function rainbowBrackets() { |
| 173 | + return [rainbowBracketsPlugin, theme]; |
237 | 174 | } |
238 | 175 |
|
239 | 176 | export default rainbowBrackets; |
0 commit comments