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