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

            Line data    Source code
       1              : import * as dashql from '@ankoh/dashql-core';
       2              : 
       3              : import {
       4              :     EditorSelection,
       5              :     EditorState,
       6              :     Prec,
       7              :     StateEffect,
       8              :     StateEffectType,
       9              :     StateField,
      10              :     Transaction,
      11              : } from '@codemirror/state';
      12              : import { Decoration, DecorationSet, EditorView, keymap } from '@codemirror/view';
      13              : 
      14              : import { DashQLProcessorPlugin } from './dashql_processor.js';
      15              : 
      16              : export type TombstoneQuote = '"' | "'" | '`';
      17              : 
      18              : export interface QuoteTombstone {
      19              :     leading: number;
      20              :     trailing: number;
      21              :     char: TombstoneQuote;
      22              : }
      23              : 
      24            1 : const ArmTombstoneEffect: StateEffectType<QuoteTombstone> = StateEffect.define<QuoteTombstone>();
      25            1 : const ClearTombstoneEffect: StateEffectType<null> = StateEffect.define<null>();
      26              : 
      27            1 : const isTombstoneQuote = (c: string): c is TombstoneQuote => c === '"' || c === "'" || c === '`';
      28              : 
      29            1 : const tombstoneMark = Decoration.mark({ class: 'dashql-quote-tombstoned' });
      30              : 
      31            1 : export const QuoteTombstoneField: StateField<QuoteTombstone | null> = StateField.define<QuoteTombstone | null>({
      32            1 :     create: () => null,
      33            1 :     update: (state, tr) => {
      34            1 :         let armed: QuoteTombstone | null | 'cleared' = null;
      35            1 :         for (const effect of tr.effects) {
      36            1 :             if (effect.is(ArmTombstoneEffect)) {
      37            1 :                 armed = effect.value;
      38            1 :             } else if (effect.is(ClearTombstoneEffect)) {
      39            1 :                 armed = 'cleared';
      40              :             }
      41              :         }
      42            1 :         if (armed === 'cleared') return null;
      43            1 :         if (armed) return armed;
      44            1 :         if (!state) return null;
      45              : 
      46              :         // Map positions through doc changes; clear if the quotes are gone
      47            1 :         let leading = state.leading;
      48            1 :         let trailing = state.trailing;
      49            1 :         if (tr.docChanged) {
      50            1 :             leading = tr.changes.mapPos(state.leading, -1);
      51            1 :             trailing = tr.changes.mapPos(state.trailing, 1);
      52            1 :             const doc = tr.newDoc;
      53            1 :             if (leading < 0 || trailing >= doc.length) return null;
      54            1 :             if (leading >= trailing) return null;
      55            1 :             if (doc.sliceString(leading, leading + 1) !== state.char) return null;
      56            1 :             if (doc.sliceString(trailing, trailing + 1) !== state.char) return null;
      57            1 :         }
      58              : 
      59              :         // Clear when the cursor leaves the token span [leading, trailing+1]
      60            1 :         const sel = tr.newSelection.main;
      61            1 :         if (sel.head < leading || sel.head > trailing + 1) return null;
      62            1 :         if (sel.anchor < leading || sel.anchor > trailing + 1) return null;
      63              : 
      64            1 :         if (leading !== state.leading || trailing !== state.trailing) {
      65            1 :             return { ...state, leading, trailing };
      66              :         }
      67            1 :         return state;
      68            1 :     },
      69            1 : });
      70              : 
      71            1 : const QuoteTombstoneDecorationsField: StateField<DecorationSet> = StateField.define<DecorationSet>({
      72            1 :     create: () => Decoration.none,
      73            1 :     update: (_deco, tr) => {
      74            1 :         const t = tr.state.field(QuoteTombstoneField);
      75            1 :         if (!t) return Decoration.none;
      76            1 :         return Decoration.set([
      77            1 :             tombstoneMark.range(t.leading, t.leading + 1),
      78            1 :             tombstoneMark.range(t.trailing, t.trailing + 1),
      79            1 :         ]);
      80            1 :     },
      81            1 :     provide: f => EditorView.decorations.from(f),
      82            1 : });
      83              : 
      84              : export interface QuotedTokenSpan {
      85              :     leading: number;
      86              :     trailing: number;
      87              : }
      88              : 
      89              : /// Finder for the quoted token containing `quotePos`.
      90              : /// Default lookup uses the DashQL processor's scanner tokens; tests can inject a fake.
      91              : export type QuotedTokenFinder = (state: EditorState, quotePos: number) => QuotedTokenSpan | null;
      92              : 
      93            1 : export const defaultQuotedTokenFinder: QuotedTokenFinder = (state, quotePos) => {
      94            1 :     const processor = state.field(DashQLProcessorPlugin, false);
      95            1 :     if (!processor) return null;
      96            1 :     const token = processor.editorUpdate?.syntaxSpans.find(({ textSpan }) => {
      97            1 :         if (textSpan == null) return false;
      98            1 :         const from = Number(textSpan.offset);
      99            1 :         return from <= quotePos && quotePos < from + Number(textSpan.length);
     100            1 :     });
     101            1 :     const offset = Number(token?.textSpan?.offset ?? 0n);
     102            1 :     const length = Number(token?.textSpan?.length ?? 0n);
     103            1 :     if (length < 2) return null;
     104            1 :     return {
     105            1 :         leading: offset,
     106            1 :         trailing: offset + length - 1,
     107            1 :     };
     108            1 : };
     109              : 
     110              : interface QuoteTokenInfo {
     111              :     leading: number;
     112              :     trailing: number;
     113              :     char: TombstoneQuote;
     114              :     targetIsTrailing: boolean;
     115              : }
     116              : 
     117            1 : function resolveQuoteToken(
     118            1 :     state: EditorState,
     119            1 :     quotePos: number,
     120            1 :     finder: QuotedTokenFinder,
     121            1 : ): QuoteTokenInfo | null {
     122            1 :     if (quotePos < 0 || quotePos >= state.doc.length) return null;
     123            1 :     const ch = state.doc.sliceString(quotePos, quotePos + 1);
     124            1 :     if (!isTombstoneQuote(ch)) return null;
     125            1 :     const span = finder(state, quotePos);
     126            1 :     if (!span) return null;
     127            1 :     if (quotePos !== span.leading && quotePos !== span.trailing) return null;
     128            1 :     if (state.doc.sliceString(span.leading, span.leading + 1) !== ch) return null;
     129            1 :     if (state.doc.sliceString(span.trailing, span.trailing + 1) !== ch) return null;
     130            1 :     return { leading: span.leading, trailing: span.trailing, char: ch, targetIsTrailing: quotePos === span.trailing };
     131            1 : }
     132              : 
     133              : // Replace the entire tombstoned token with its inner text in one change so the doc->script
     134              : // mirror in DashQLProcessorPlugin sees a single contiguous edit (multi-change deletes use
     135              : // original-doc offsets that the script's sequential erase can't apply correctly).
     136            1 : function dispatchTombstoneCommit(
     137            1 :     view: EditorView,
     138            1 :     tombstone: QuoteTombstone,
     139            1 :     cursor: number,
     140            1 :     userEvent: 'delete.backward' | 'delete.forward',
     141            1 : ): void {
     142            1 :     const inner = view.state.doc.sliceString(tombstone.leading + 1, tombstone.trailing);
     143            1 :     let nextCursor = cursor;
     144            1 :     if (cursor > tombstone.leading) nextCursor -= 1;
     145            1 :     if (cursor > tombstone.trailing) nextCursor -= 1;
     146            1 :     view.dispatch({
     147            1 :         changes: { from: tombstone.leading, to: tombstone.trailing + 1, insert: inner },
     148            1 :         selection: EditorSelection.cursor(nextCursor),
     149            1 :         effects: ClearTombstoneEffect.of(null),
     150            1 :         annotations: Transaction.userEvent.of(userEvent),
     151            1 :     });
     152            1 : }
     153              : 
     154            1 : export function tombstoneBackspace(view: EditorView, finder: QuotedTokenFinder): boolean {
     155            1 :     const state = view.state;
     156            1 :     const sel = state.selection.main;
     157            1 :     if (!sel.empty) return false;
     158            1 :     const cursor = sel.head;
     159            1 :     const tombstone = state.field(QuoteTombstoneField, false);
     160              : 
     161            1 :     if (tombstone) {
     162            1 :         const target = cursor - 1;
     163            1 :         if (target === tombstone.leading || target === tombstone.trailing) {
     164            1 :             dispatchTombstoneCommit(view, tombstone, cursor, 'delete.backward');
     165            1 :             return true;
     166            1 :         }
     167            1 :         return false;
     168            1 :     }
     169              : 
     170            1 :     if (cursor === 0) return false;
     171            1 :     const info = resolveQuoteToken(state, cursor - 1, finder);
     172            1 :     if (!info) return false;
     173            1 :     const tomb: QuoteTombstone = { leading: info.leading, trailing: info.trailing, char: info.char };
     174            1 :     if (info.targetIsTrailing) {
     175            1 :         view.dispatch({
     176            1 :             selection: EditorSelection.cursor(cursor - 1),
     177            1 :             effects: ArmTombstoneEffect.of(tomb),
     178            1 :         });
     179              :     } else {
     180            1 :         view.dispatch({
     181            1 :             effects: ArmTombstoneEffect.of(tomb),
     182            1 :         });
     183              :     }
     184            1 :     return true;
     185            1 : }
     186              : 
     187            1 : export function tombstoneForwardDelete(view: EditorView, finder: QuotedTokenFinder): boolean {
     188            1 :     const state = view.state;
     189            1 :     const sel = state.selection.main;
     190            1 :     if (!sel.empty) return false;
     191            1 :     const cursor = sel.head;
     192            1 :     const tombstone = state.field(QuoteTombstoneField, false);
     193              : 
     194            1 :     if (tombstone) {
     195            1 :         const target = cursor;
     196            1 :         if (target === tombstone.leading || target === tombstone.trailing) {
     197            1 :             dispatchTombstoneCommit(view, tombstone, cursor, 'delete.forward');
     198            1 :             return true;
     199            1 :         }
     200            0 :         return false;
     201            0 :     }
     202              : 
     203            1 :     if (cursor >= state.doc.length) return false;
     204            1 :     const info = resolveQuoteToken(state, cursor, finder);
     205            1 :     if (!info) return false;
     206            1 :     const tomb: QuoteTombstone = { leading: info.leading, trailing: info.trailing, char: info.char };
     207            1 :     view.dispatch({
     208            1 :         effects: ArmTombstoneEffect.of(tomb),
     209            1 :     });
     210            1 :     return true;
     211            1 : }
     212              : 
     213            1 : export const onTombstoneBackspace = (view: EditorView): boolean => tombstoneBackspace(view, defaultQuotedTokenFinder);
     214            1 : export const onTombstoneForwardDelete = (view: EditorView): boolean =>
     215            0 :     tombstoneForwardDelete(view, defaultQuotedTokenFinder);
     216              : 
     217            1 : export const DashQLQuoteTombstonePlugin = [
     218            1 :     QuoteTombstoneField,
     219            1 :     QuoteTombstoneDecorationsField,
     220            1 :     Prec.highest(
     221            1 :         keymap.of([
     222            1 :             { key: 'Backspace', run: onTombstoneBackspace },
     223            1 :             { key: 'Delete', run: onTombstoneForwardDelete },
     224            1 :         ]),
     225            1 :     ),
     226            1 : ];
        

Generated by: LCOV version 2.0-1