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