Line data Source code
1 : import * as dashql from '@ankoh/dashql-core';
2 :
3 : import { Decoration, DecorationSet, EditorView, ViewPlugin, ViewUpdate } from '@codemirror/view';
4 : import { EditorState, Extension, StateEffect, type StateEffectType, StateField, Transaction, RangeSetBuilder } from '@codemirror/state';
5 : import { highlightingFor } from '@codemirror/language';
6 : import { tags as CODEMIRROR_TAGS, Tag } from '@lezer/highlight';
7 :
8 : import './dashql_decorations.css';
9 :
10 1 : export const commentTag = CODEMIRROR_TAGS.comment;
11 1 : const relationNameTag = CODEMIRROR_TAGS.typeName;
12 1 : const functionNameTag = CODEMIRROR_TAGS.function(CODEMIRROR_TAGS.variableName);
13 :
14 1 : const PROTO_TAG_MAPPING: Map<dashql.buffers.parser.ScannerTokenType, Tag> = new Map([
15 1 : [dashql.buffers.parser.ScannerTokenType.KEYWORD, CODEMIRROR_TAGS.keyword],
16 1 : [dashql.buffers.parser.ScannerTokenType.OPERATOR, CODEMIRROR_TAGS.operator],
17 1 : [dashql.buffers.parser.ScannerTokenType.LITERAL_BINARY, CODEMIRROR_TAGS.literal],
18 1 : [dashql.buffers.parser.ScannerTokenType.LITERAL_BOOLEAN, CODEMIRROR_TAGS.bool],
19 1 : [dashql.buffers.parser.ScannerTokenType.LITERAL_FLOAT, CODEMIRROR_TAGS.float],
20 1 : [dashql.buffers.parser.ScannerTokenType.LITERAL_HEX, CODEMIRROR_TAGS.number],
21 1 : [dashql.buffers.parser.ScannerTokenType.LITERAL_STRING, CODEMIRROR_TAGS.string],
22 1 : [dashql.buffers.parser.ScannerTokenType.LITERAL_INTEGER, CODEMIRROR_TAGS.integer],
23 1 : [dashql.buffers.parser.ScannerTokenType.IDENTIFIER, CODEMIRROR_TAGS.name],
24 1 : ]);
25 :
26 1 : const CODEMIRROR_TAGS_USED: Set<Tag> = new Set([commentTag, relationNameTag, functionNameTag]);
27 1 : for (const [_token, tag] of PROTO_TAG_MAPPING) {
28 1 : CODEMIRROR_TAGS_USED.add(tag);
29 : }
30 :
31 : /// Build the tag -> decoration lookup for the current editor state (theme dependent).
32 : /// `extraClass` is added to the highlight class of every tag.
33 1 : function buildTagDecorations(state: EditorState, extraClass: string | null): Map<Tag, Decoration> {
34 1 : const decorations: Map<Tag, Decoration> = new Map();
35 1 : for (const tag of CODEMIRROR_TAGS_USED) {
36 1 : const highlightClass = highlightingFor(state, [tag]) ?? '';
37 1 : const classes = [highlightClass, tag === commentTag ? 'dashql-comment' : null, extraClass];
38 1 : decorations.set(
39 1 : tag,
40 1 : Decoration.mark({
41 1 : class: classes.filter(c => c).join(' '),
42 1 : }),
43 1 : );
44 1 : }
45 1 : return decorations;
46 1 : }
47 :
48 : /// A piece of a tagged comment region: the comment markers around its lines
49 : interface TagPiece {
50 : from: number;
51 : to: number;
52 : }
53 :
54 : /// The pieces of the tagged comment regions of an update, sorted by offset. The lines of the tags are no pieces,
55 : /// they keep the highlighting of comments.
56 1 : function tagPieces(update: dashql.buffers.editor.EditorUpdateT): TagPiece[] {
57 1 : const pieces: TagPiece[] = [];
58 1 : for (const tag of update.commentTags) {
59 1 : if (tag.textSpan == null) continue;
60 1 : let at = Number(tag.textSpan.offset);
61 1 : for (const line of tag.lines) {
62 1 : const from = Number(line.offset);
63 1 : const to = Number(line.offset + line.length);
64 1 : if (from > at) pieces.push({ from: at, to: from });
65 1 : at = to;
66 1 : }
67 1 : const end = Number(tag.textSpan.offset + tag.textSpan.length);
68 1 : if (end > at) pieces.push({ from: at, to: end });
69 1 : }
70 1 : return pieces;
71 1 : }
72 :
73 : /// Build syntax highlighting decorations for the tokens that fall into the given ranges.
74 : ///
75 : /// This only touches the tokens overlapping `ranges` (the editor viewport), not the whole
76 : /// document. On a large script decorating every token synchronously blocks the main thread;
77 : /// slicing the sorted portable syntax spans to the viewport keeps it O(visible).
78 1 : export function buildDecorationsForRanges(
79 1 : state: EditorState,
80 1 : update: dashql.buffers.editor.EditorUpdateT,
81 1 : ranges: readonly { from: number; to: number }[],
82 1 : ): DecorationSet {
83 1 : const builder = new RangeSetBuilder<Decoration>();
84 1 : const decorations = buildTagDecorations(state, null);
85 : // The tags and the tokens of their content are highlighted quieter than the SQL around them
86 1 : const commentTokenDecorations = buildTagDecorations(state, 'dashql-comment-token');
87 : // The comment markers of every tagged region step back
88 1 : const markerDecoration = buildTagDecorations(state, 'dashql-comment-tag').get(commentTag)!;
89 1 : const pieces = tagPieces(update);
90 1 : let pieceCursor = 0;
91 1 : const addComment = (from: number, to: number, decoration: Decoration) => {
92 1 : let at = from;
93 1 : while (pieceCursor < pieces.length && pieces[pieceCursor].to <= at) ++pieceCursor;
94 1 : for (let i = pieceCursor; i < pieces.length && pieces[i].from < to; ++i) {
95 1 : const piece = pieces[i];
96 1 : const pieceFrom = Math.max(piece.from, at);
97 1 : const pieceTo = Math.min(piece.to, to);
98 1 : if (pieceFrom > at) builder.add(at, pieceFrom, decoration);
99 1 : builder.add(pieceFrom, pieceTo, markerDecoration);
100 1 : at = pieceTo;
101 1 : }
102 1 : if (to > at) builder.add(at, to, decoration);
103 1 : };
104 1 : const semanticNames = update.semanticSpans.filter(semantic =>
105 1 : semantic.textSpan != null && (
106 1 : semantic.kind === dashql.buffers.editor.EditorSemanticReferenceKind.TABLE ||
107 1 : semantic.kind === dashql.buffers.editor.EditorSemanticReferenceKind.FUNCTION
108 : ),
109 1 : );
110 1 : let semanticCursor = 0;
111 1 : let spanCursor = 0;
112 1 : for (const { from, to } of ranges) {
113 1 : while (spanCursor < update.syntaxSpans.length) {
114 1 : const syntax = update.syntaxSpans[spanCursor];
115 1 : const span = syntax.textSpan;
116 1 : if (span == null) {
117 1 : ++spanCursor;
118 1 : continue;
119 1 : }
120 1 : const spanFrom = Number(span.offset);
121 1 : const spanTo = Number(span.offset + span.length);
122 1 : if (spanTo <= from) {
123 1 : ++spanCursor;
124 1 : continue;
125 1 : }
126 1 : if (spanFrom >= to) break;
127 1 : let tag = syntax.tokenType === dashql.buffers.parser.ScannerTokenType.COMMENT
128 1 : ? commentTag
129 1 : : PROTO_TAG_MAPPING.get(syntax.tokenType);
130 1 : if (syntax.tokenType === dashql.buffers.parser.ScannerTokenType.IDENTIFIER) {
131 1 : while (semanticCursor < semanticNames.length) {
132 1 : const semanticSpan = semanticNames[semanticCursor].textSpan!;
133 1 : if (Number(semanticSpan.offset + semanticSpan.length) > spanFrom) break;
134 1 : ++semanticCursor;
135 1 : }
136 1 : const semantic = semanticNames[semanticCursor];
137 1 : const semanticSpan = semantic?.textSpan;
138 1 : const semanticKind = semanticSpan != null && Number(semanticSpan.offset) <= spanFrom &&
139 1 : Number(semanticSpan.offset + semanticSpan.length) >= spanTo
140 1 : ? semantic.kind
141 1 : : null;
142 1 : switch (semanticKind) {
143 1 : case dashql.buffers.editor.EditorSemanticReferenceKind.TABLE:
144 1 : tag = relationNameTag;
145 1 : break;
146 1 : case dashql.buffers.editor.EditorSemanticReferenceKind.FUNCTION:
147 1 : tag = functionNameTag;
148 : break;
149 1 : }
150 1 : }
151 1 : const tagDecorations = syntax.inComment && tag !== commentTag ? commentTokenDecorations : decorations;
152 1 : if (tag === commentTag) {
153 1 : addComment(spanFrom, spanTo, tagDecorations.get(tag)!);
154 1 : } else if (tag) {
155 1 : builder.add(spanFrom, spanTo, tagDecorations.get(tag)!);
156 : }
157 1 : ++spanCursor;
158 1 : }
159 : }
160 1 : return builder.finish();
161 1 : }
162 :
163 : /// A ViewPlugin that highlights only the visible tokens.
164 : ///
165 : /// `getUpdate` resolves the portable editor projection for the current view: the integrated editor reads it
166 : /// from the processor state field, the standalone preview from an effect-backed field. Decorations
167 : /// are recomputed when the viewport scrolls, the document changes, or the projection is swapped.
168 1 : export function createScannerHighlightPlugin(
169 1 : getUpdate: (view: EditorView) => dashql.buffers.editor.EditorUpdateT | null,
170 1 : ): Extension {
171 1 : return ViewPlugin.fromClass(
172 1 : class {
173 1 : decorations: DecorationSet;
174 1 : lastUpdate: dashql.buffers.editor.EditorUpdateT | null;
175 :
176 1 : constructor(view: EditorView) {
177 1 : this.lastUpdate = getUpdate(view);
178 1 : this.decorations = this.lastUpdate
179 1 : ? buildDecorationsForRanges(view.state, this.lastUpdate, view.visibleRanges)
180 1 : : Decoration.none;
181 1 : console.debug('[session-editor-debug] initialized syntax highlighting', {
182 1 : hasProjection: this.lastUpdate != null,
183 1 : textVersion: this.lastUpdate?.textVersion,
184 1 : stateRevision: this.lastUpdate?.stateRevision,
185 1 : syntaxSpanCount: this.lastUpdate?.syntaxSpans.length ?? 0,
186 1 : semanticSpanCount: this.lastUpdate?.semanticSpans.length ?? 0,
187 1 : visibleRanges: view.visibleRanges.map(range => `${range.from}-${range.to}`).join(','),
188 1 : documentLength: view.state.doc.length,
189 1 : decorationCount: this.decorations.size,
190 1 : });
191 1 : }
192 1 : update(u: ViewUpdate) {
193 1 : const update = getUpdate(u.view);
194 : // An update that omits an unchanged analysis keeps the spans
195 1 : const projectionChanged = update?.syntaxSpans !== this.lastUpdate?.syntaxSpans
196 1 : || update?.semanticSpans !== this.lastUpdate?.semanticSpans;
197 1 : if (u.viewportChanged || u.docChanged || projectionChanged) {
198 1 : this.lastUpdate = update;
199 1 : this.decorations = update
200 1 : ? buildDecorationsForRanges(u.view.state, update, u.view.visibleRanges)
201 1 : : Decoration.none;
202 1 : console.debug('[session-editor-debug] rebuilt syntax highlighting', {
203 1 : hasProjection: update != null,
204 1 : projectionChanged,
205 1 : viewportChanged: u.viewportChanged,
206 1 : docChanged: u.docChanged,
207 1 : textVersion: update?.textVersion,
208 1 : stateRevision: update?.stateRevision,
209 1 : syntaxSpanCount: update?.syntaxSpans.length ?? 0,
210 1 : semanticSpanCount: update?.semanticSpans.length ?? 0,
211 1 : visibleRanges: u.view.visibleRanges.map(range => `${range.from}-${range.to}`).join(','),
212 1 : documentLength: u.view.state.doc.length,
213 1 : decorationCount: this.decorations.size,
214 1 : });
215 1 : }
216 1 : }
217 1 : },
218 1 : { decorations: v => v.decorations },
219 1 : );
220 1 : }
221 :
222 1 : export const DashQLScannerDecorationUpdateEffect: StateEffectType<dashql.buffers.editor.EditorUpdateT | null> =
223 1 : StateEffect.define<dashql.buffers.editor.EditorUpdateT | null>();
224 :
225 : /// Holds the editor projection pushed in from outside via DashQLScannerDecorationUpdateEffect.
226 : /// The highlight ViewPlugin reads it back out to decorate the viewport.
227 1 : const StandaloneUpdateField: StateField<dashql.buffers.editor.EditorUpdateT | null> =
228 1 : StateField.define<dashql.buffers.editor.EditorUpdateT | null>({
229 1 : create: () => null,
230 1 : update: (update, transaction: Transaction) => {
231 1 : for (const effect of transaction.effects) {
232 1 : if (effect.is(DashQLScannerDecorationUpdateEffect)) {
233 1 : update = effect.value;
234 : }
235 : }
236 1 : return update;
237 1 : },
238 1 : });
239 :
240 1 : export const DashQLStandaloneScannerDecorationPlugin = [
241 1 : StandaloneUpdateField,
242 1 : createScannerHighlightPlugin(view => view.state.field(StandaloneUpdateField)),
243 1 : ];
|