LCOV - code coverage report
Current view: top level - dashql-app/src/session/editor - dashql_decorations_standalone.ts Coverage Total Hit
Test: DashQL 915828b Lines: 100.0 % 192 192
Test Date: 2026-11-06 19:30:32

            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 : ];
        

Generated by: LCOV version 2.0-1