Skip to content

Commit 8f07798

Browse files
committed
refactor: rainbow brackets with syntax tree
1 parent 7928440 commit 8f07798

File tree

2 files changed

+143
-209
lines changed

2 files changed

+143
-209
lines changed

src/cm/lsp/clientManager.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -478,10 +478,7 @@ export class LspClientManager {
478478
);
479479
return true;
480480
},
481-
"$/typescriptVersion": (
482-
_client: LSPClient,
483-
params: unknown,
484-
): boolean => {
481+
"$/typescriptVersion": (_client: LSPClient, params: unknown): boolean => {
485482
interface TypeScriptVersionParams {
486483
version?: string;
487484
source?: string;

src/cm/rainbowBrackets.ts

Lines changed: 142 additions & 205 deletions
Original file line numberDiff line numberDiff line change
@@ -1,239 +1,176 @@
1-
import type { Text } from "@codemirror/state";
2-
import { RangeSetBuilder } from "@codemirror/state";
1+
import { syntaxTree } from "@codemirror/language";
32
import type { DecorationSet, ViewUpdate } from "@codemirror/view";
43
import { Decoration, EditorView, ViewPlugin } from "@codemirror/view";
4+
import type { SyntaxNode } from "@lezer/common";
55

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"];
197

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+
]);
2517

26-
interface RainbowBracketsOptions {
27-
colors?: string[];
28-
useLight?: boolean;
29-
}
30-
31-
interface ViewportRange {
18+
interface BracketInfo {
3219
from: number;
3320
to: number;
21+
depth: number;
22+
char: string;
3423
}
3524

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;
7428

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+
}
8632

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+
}
10337
}
104-
}
10538

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);
10842

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+
}
11247

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);
11950

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+
);
12255

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+
}
13158

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+
}
144111

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+
}
149118
}
150119

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+
);
164129
}
165130

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);
172133

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;
190138
}
139+
node = node.parent;
191140
}
192141

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+
}
202144

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;
227155
}
228-
return builder.finish();
229156
}
230-
}
231-
232-
const vp = ViewPlugin.fromClass(RainbowPlugin, {
157+
},
158+
{
233159
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];
237174
}
238175

239176
export default rainbowBrackets;

0 commit comments

Comments
 (0)