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

            Line data    Source code
       1              : import { EditorView, keymap } from '@codemirror/view';
       2              : import { EditorSelection, Prec, Transaction } from '@codemirror/state';
       3              : 
       4            1 : const PAIRS: Record<string, string> = { '(': ')', '[': ']', '{': '}' };
       5            1 : const QUOTES = new Set(['"', "'", '`']);
       6            1 : const CLOSERS = new Set([')', ']', '}']);
       7            1 : const BEFORE_CLOSE = /^[\s)\]};,]?$/;
       8              : 
       9            1 : function delimiterImbalance(text: string): number {
      10            1 :     const openers: string[] = [];
      11            1 :     let unmatchedClosers = 0;
      12            1 :     let quote = '';
      13            1 :     let lineComment = false;
      14            1 :     let blockCommentDepth = 0;
      15              : 
      16            1 :     for (let i = 0; i < text.length; ++i) {
      17            1 :         const char = text[i];
      18            1 :         const next = text[i + 1] ?? '';
      19              : 
      20            1 :         if (lineComment) {
      21            1 :             if (char === '\n') lineComment = false;
      22            1 :             continue;
      23            1 :         }
      24            1 :         if (blockCommentDepth > 0) {
      25            1 :             if (char === '/' && next === '*') {
      26            1 :                 blockCommentDepth += 1;
      27            1 :                 ++i;
      28            1 :             } else if (char === '*' && next === '/') {
      29            1 :                 blockCommentDepth -= 1;
      30            1 :                 ++i;
      31            1 :             }
      32            1 :             continue;
      33            1 :         }
      34            1 :         if (quote) {
      35            1 :             if (char === quote) {
      36            1 :                 if (next === quote) {
      37            0 :                     ++i;
      38              :                 } else {
      39            1 :                     quote = '';
      40              :                 }
      41            1 :             }
      42            1 :             continue;
      43            1 :         }
      44            1 :         if (QUOTES.has(char)) {
      45            1 :             quote = char;
      46            1 :             continue;
      47            1 :         }
      48            1 :         if (char === '-' && next === '-') {
      49            1 :             lineComment = true;
      50            1 :             ++i;
      51            1 :             continue;
      52            1 :         }
      53            1 :         if (char === '/' && next === '*') {
      54            1 :             blockCommentDepth = 1;
      55            1 :             ++i;
      56            1 :             continue;
      57            1 :         }
      58            1 :         if (char === ';') {
      59            1 :             openers.length = 0;
      60            1 :             unmatchedClosers = 0;
      61            1 :             continue;
      62            1 :         }
      63            1 :         if (PAIRS[char]) {
      64            1 :             openers.push(char);
      65            1 :         } else if (CLOSERS.has(char)) {
      66            1 :             const opener = openers[openers.length - 1];
      67            1 :             if (opener && PAIRS[opener] === char) {
      68            1 :                 openers.pop();
      69              :             } else {
      70            1 :                 unmatchedClosers += 1;
      71              :             }
      72            1 :         }
      73            1 :     }
      74            1 :     return openers.length + unmatchedClosers;
      75            1 : }
      76              : 
      77            1 : export function handleInput(
      78            1 :     view: EditorView,
      79            1 :     from: number,
      80            1 :     to: number,
      81            1 :     insert: string,
      82            1 :     defaultInsert: () => Transaction = () => view.state.update({
      83            1 :         changes: { from, to, insert },
      84            1 :         selection: EditorSelection.cursor(from + insert.length),
      85            1 :         annotations: Transaction.userEvent.of('input.type'),
      86            1 :     }),
      87            1 : ): boolean {
      88            1 :     if (insert.length !== 1) return false;
      89              : 
      90              :     // Overtype: user types a closer and the char at cursor matches
      91            1 :     if (CLOSERS.has(insert) || QUOTES.has(insert)) {
      92            1 :         const doc = view.state.doc;
      93            1 :         const atCursor = from < doc.length ? doc.sliceString(from, from + 1) : '';
      94            1 :         if (atCursor === insert && from === to) {
      95            1 :             view.dispatch({
      96            1 :                 selection: EditorSelection.cursor(from + 1),
      97            1 :                 annotations: Transaction.userEvent.of('input.type'),
      98            1 :             });
      99            1 :             return true;
     100            1 :         }
     101            1 :         if (CLOSERS.has(insert)) {
     102            1 :             view.dispatch(defaultInsert());
     103            1 :             return true;
     104            1 :         }
     105            1 :     }
     106              : 
     107              :     // Auto-close quotes
     108            1 :     if (QUOTES.has(insert)) {
     109            1 :         const doc = view.state.doc;
     110            1 :         const before = from > 0 ? doc.sliceString(from - 1, from) : '';
     111            1 :         const after = from < doc.length ? doc.sliceString(to, to + 1) : '';
     112              :         // Don't auto-close if preceded by a word char (e.g. contractions like it's)
     113            1 :         if (/\w/.test(before)) return false;
     114            1 :         if (!BEFORE_CLOSE.test(after)) return false;
     115              : 
     116            1 :         view.dispatch({
     117            1 :             changes: { from, to, insert: insert + insert },
     118            1 :             selection: EditorSelection.cursor(from + 1),
     119            1 :             annotations: Transaction.userEvent.of('input.type'),
     120            1 :         });
     121            1 :         return true;
     122            1 :     }
     123              : 
     124              :     // Auto-close brackets
     125            1 :     const closer = PAIRS[insert];
     126            1 :     if (!closer) return false;
     127              : 
     128            1 :     const doc = view.state.doc;
     129            1 :     const after = from < doc.length ? doc.sliceString(to, to + 1) : '';
     130            1 :     if (!BEFORE_CLOSE.test(after)) return false;
     131              : 
     132            1 :     const withoutSelection = doc.sliceString(0, from) + doc.sliceString(to);
     133            1 :     const withOpener = doc.sliceString(0, from) + insert + doc.sliceString(to);
     134            1 :     if (delimiterImbalance(withOpener) < delimiterImbalance(withoutSelection)) {
     135            1 :         view.dispatch(defaultInsert());
     136            1 :         return true;
     137            1 :     }
     138              : 
     139            1 :     view.dispatch({
     140            1 :         changes: { from, to, insert: insert + closer },
     141            1 :         selection: EditorSelection.cursor(from + 1),
     142            1 :         annotations: Transaction.userEvent.of('input.type'),
     143            1 :     });
     144            1 :     return true;
     145            1 : }
     146              : 
     147            1 : export function onBackspace(view: EditorView): boolean {
     148            1 :     const state = view.state;
     149            1 :     const sel = state.selection.main;
     150            1 :     if (!sel.empty || sel.from === 0) return false;
     151              : 
     152            1 :     const doc = state.doc;
     153            1 :     const before = doc.sliceString(sel.from - 1, sel.from);
     154            1 :     const after = sel.from < doc.length ? doc.sliceString(sel.from, sel.from + 1) : '';
     155              : 
     156            1 :     if (PAIRS[before] === after || (QUOTES.has(before) && before === after)) {
     157            1 :         view.dispatch({
     158            1 :             changes: { from: sel.from - 1, to: sel.from + 1 },
     159            1 :             selection: EditorSelection.cursor(sel.from - 1),
     160            1 :             annotations: Transaction.userEvent.of('delete.backward'),
     161            1 :         });
     162            1 :         return true;
     163            1 :     }
     164            1 :     return false;
     165            1 : }
     166              : 
     167            1 : export const DashQLAutoclosePlugin = [
     168            1 :     EditorView.inputHandler.of(handleInput),
     169            1 :     Prec.high(keymap.of([{ key: 'Backspace', run: onBackspace }])),
     170            1 : ];
        

Generated by: LCOV version 2.0-1