@adecore/editor-core 0.44.2 → 0.44.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -52,11 +52,22 @@ function completeBraced(source, lineIndex, caret, options) {
52
52
  const context = source.context(end);
53
53
  const first = statementStart(source, lineIndex, options.language);
54
54
  const firstStart = source.line(first).start;
55
- const startsWithBrace = /^\s*\}/.test(line.text);
55
+ const startsWithBrace = /^\s*\}/.test(line.text) && source.context(line.start + whitespaceOf(line.text).length).bracket?.block !== false;
56
56
  let inline = context.mode === 'quote' ? (context.quote ?? '') : '';
57
57
  let open = context.bracket;
58
+ let resume = null;
58
59
  for (; open !== undefined && open.template !== true && !(open.close === '}' && open.block === true) && open.at >= firstStart; open = open.previous) {
59
- inline += open.close;
60
+ const closer = source.closerOf(open.at);
61
+ if (closer === undefined) {
62
+ inline += open.close;
63
+ }
64
+ else {
65
+ resume = closer;
66
+ }
67
+ }
68
+ // A later line closes a bracket of this one, so the statement goes on there and ends there.
69
+ if (resume !== null) {
70
+ return completeBraced(source, source.lineAt(resume), resume, options);
60
71
  }
61
72
  const tail = source.slice(end, line.end);
62
73
  const newline = source.newline(lineIndex);
@@ -121,8 +132,18 @@ function completePython(source, lineIndex, caret, options) {
121
132
  return startNewLine(source, lineIndex, options);
122
133
  }
123
134
  let inline = '';
135
+ let resume = null;
124
136
  for (let open = source.context(end).bracket; open !== undefined; open = open.previous) {
125
- inline += open.close;
137
+ const closer = source.closerOf(open.at);
138
+ if (closer === undefined) {
139
+ inline += open.close;
140
+ }
141
+ else {
142
+ resume = closer;
143
+ }
144
+ }
145
+ if (resume !== null) {
146
+ return completePython(source, source.lineAt(resume), resume, options);
126
147
  }
127
148
  const head = (source.slice(line.start, end) + inline).trim();
128
149
  const tail = source.slice(end, line.end);
@@ -136,21 +157,25 @@ function completePython(source, lineIndex, caret, options) {
136
157
  export function planCompleteStatement(source, selections, options) {
137
158
  const complete = isBraced(options.language) ? completeBraced : /^(python|py)$/i.test(options.language) ? completePython : null;
138
159
  const pieces = new Map();
160
+ const byStart = new Map();
139
161
  for (const selection of selections) {
140
162
  const line = source.lineAt(selection.head);
141
- pieces.set(line, complete === null ? startNewLine(source, line, options) : complete(source, line, selection.head, options));
163
+ const piece = complete === null ? startNewLine(source, line, options) : complete(source, line, selection.head, options);
164
+ // Carets on lines of one statement end up at the same edit, which applies once.
165
+ pieces.set(line, byStart.get(piece.from) ?? piece);
166
+ byStart.set(piece.from, pieces.get(line));
142
167
  }
143
- const ordered = [...pieces.entries()].sort((left, right) => left[1].from - right[1].from);
168
+ const ordered = [...new Set(pieces.values())].sort((left, right) => left.from - right.from);
144
169
  const carets = new Map();
145
170
  let delta = 0;
146
- for (const [line, piece] of ordered) {
147
- carets.set(line, piece.from + delta + piece.caret);
171
+ for (const piece of ordered) {
172
+ carets.set(piece, piece.from + delta + piece.caret);
148
173
  delta += piece.text.length - (piece.to - piece.from);
149
174
  }
150
175
  return {
151
- edits: ordered.map(([, piece]) => ({ from: piece.from, to: piece.to, text: piece.text })),
176
+ edits: ordered.map((piece) => ({ from: piece.from, to: piece.to, text: piece.text })),
152
177
  selections: selections.map((selection) => {
153
- const caret = carets.get(source.lineAt(selection.head));
178
+ const caret = carets.get(pieces.get(source.lineAt(selection.head)));
154
179
  return { anchor: caret, head: caret };
155
180
  })
156
181
  };
@@ -146,6 +146,8 @@ export declare class DocumentModel {
146
146
  unwrapTargets(offset: number, language: string, indentUnit: string): UnwrapTarget[];
147
147
  private editSource;
148
148
  private isUnclosedBrace;
149
+ private closerOf;
150
+ private bracketIndexAt;
149
151
  private scriptSource;
150
152
  private scriptBracketIndex;
151
153
  private bracketIndex;
package/dist/document.js CHANGED
@@ -1569,7 +1569,8 @@ export class DocumentModel {
1569
1569
  newline: (index) => this.newlineAt(index),
1570
1570
  context: (offset) => this.typingContext(offset, language),
1571
1571
  region: (line) => (/^vue$/i.test(language) ? vueRegionAt((index) => this.getLine(index).text, line) : null),
1572
- unclosedBrace: (offset) => this.isUnclosedBrace(offset, language)
1572
+ unclosedBrace: (offset) => this.isUnclosedBrace(offset, language),
1573
+ closerOf: (offset) => this.closerOf(offset, language)
1573
1574
  };
1574
1575
  }
1575
1576
  /*
@@ -1577,8 +1578,7 @@ export class DocumentModel {
1577
1578
  * it, which it took from that block. Such a closer sits shallower than the brace's line and leaves an opener before it unmatched.
1578
1579
  */
1579
1580
  isUnclosedBrace(offset, language) {
1580
- const context = isPhp(language) ? this.typingContext(offset, language) : undefined;
1581
- const index = context?.embedded === 'javascript' && context.embedStart !== undefined ? this.scriptBracketIndex(context.embedStart) : this.bracketIndex(language);
1581
+ const index = this.bracketIndexAt(offset, language);
1582
1582
  if (index === undefined) {
1583
1583
  return false;
1584
1584
  }
@@ -1592,6 +1592,20 @@ export class DocumentModel {
1592
1592
  }
1593
1593
  return [...index.unmatched].some((at) => at < offset && this.rope.charAt(at) === '{');
1594
1594
  }
1595
+ /* Where the closer of the opening bracket at `offset` stands; undefined where it has none, or the document is too large to scan. */
1596
+ closerOf(offset, language) {
1597
+ const index = this.bracketIndexAt(offset, language);
1598
+ if (index === undefined || index.unmatched.has(offset)) {
1599
+ return undefined;
1600
+ }
1601
+ return index.pairs.get(offset);
1602
+ }
1603
+ bracketIndexAt(offset, language) {
1604
+ const context = isPhp(language) ? this.typingContext(offset, language) : undefined;
1605
+ return context?.embedded === 'javascript' && context.embedStart !== undefined
1606
+ ? this.scriptBracketIndex(context.embedStart)
1607
+ : this.bracketIndex(language);
1608
+ }
1595
1609
  scriptSource(start, limit) {
1596
1610
  if (this.scriptCache?.revision !== this.revision) {
1597
1611
  this.scriptCache = { revision: this.revision, sources: new Map() };
@@ -12,6 +12,7 @@ export interface EditSource {
12
12
  context(offset: number): TypingContext;
13
13
  region(line: number): VueRegion | null;
14
14
  unclosedBrace(offset: number): boolean;
15
+ closerOf(offset: number): number | undefined;
15
16
  }
16
17
  export interface EditPlan {
17
18
  edits: TextEdit[];
package/dist/hippie.js CHANGED
@@ -43,19 +43,25 @@ function isHump(word, index) {
43
43
  const startsHump = character !== character.toLowerCase() && before !== before.toUpperCase();
44
44
  return startsHump || /[\d_-]/.test(before);
45
45
  }
46
- function continues(word, prefix, wordIndex, prefixIndex) {
46
+ /* `failed` holds the positions known to lead nowhere, so each of the word times prefix positions is tried once. */
47
+ function continues(word, prefix, wordIndex, prefixIndex, failed) {
47
48
  if (prefixIndex === prefix.length) {
48
49
  return true;
49
50
  }
51
+ const position = wordIndex * prefix.length + prefixIndex;
52
+ if (failed.has(position)) {
53
+ return false;
54
+ }
50
55
  for (let i = wordIndex; i < word.length; i++) {
51
56
  // The character right after the last match continues it; any other has to start a hump.
52
57
  if (i > wordIndex && !isHump(word, i)) {
53
58
  continue;
54
59
  }
55
- if (word[i].toLowerCase() === prefix[prefixIndex].toLowerCase() && continues(word, prefix, i + 1, prefixIndex + 1)) {
60
+ if (word[i].toLowerCase() === prefix[prefixIndex].toLowerCase() && continues(word, prefix, i + 1, prefixIndex + 1, failed)) {
56
61
  return true;
57
62
  }
58
63
  }
64
+ failed.add(position);
59
65
  return false;
60
66
  }
61
67
  /* Whether a word is longer than the prefix and starts with it: the first character as typed, the rest by letters of any case or camel humps. */
@@ -63,7 +69,7 @@ export function hippieMatches(word, prefix) {
63
69
  if (word.length <= prefix.length) {
64
70
  return false;
65
71
  }
66
- return prefix === '' || (word[0] === prefix[0] && continues(word, prefix, 1, 1));
72
+ return prefix === '' || (word[0] === prefix[0] && continues(word, prefix, 1, 1, new Set()));
67
73
  }
68
74
  /* The words of this document that complete the prefix: those before the primary caret ascending, then those after it. */
69
75
  function variantsHere(text, carets, primary, prefix, hyphenated) {
@@ -160,12 +166,13 @@ export function planHippie(text, carets, primary, cycle, direction, options) {
160
166
  }
161
167
  // With nothing left to offer, a cycle that is under way gives the prefix back.
162
168
  const proposal = variant?.text ?? prefix;
163
- const length = cycle?.length ?? prefix.length;
164
169
  const edits = [];
165
170
  const landed = [];
166
171
  let end = 0;
167
172
  let delta = 0;
168
173
  for (const caret of carets) {
174
+ // A cycle under way holds the same text at every caret; a first press replaces what each caret has typed.
175
+ const length = cycle?.length ?? caret - hippiePrefixStart(text, caret, hyphenated);
169
176
  const from = Math.max(caret - length, end);
170
177
  edits.push({ from, to: caret, text: proposal });
171
178
  landed.push(from + delta + proposal.length);
@@ -312,10 +312,12 @@ function apply(source, selections, moves, options) {
312
312
  const moved = [];
313
313
  const lines = [];
314
314
  const ordered = moves.toSorted((left, right) => left.region.first - right.region.first);
315
+ // Where a region stands after the edits of the regions before it.
316
+ let delta = 0;
315
317
  for (const { region, order } of ordered) {
316
318
  const parts = [];
317
319
  const placed = [];
318
- let at = source.line(region.first).start;
320
+ let at = source.line(region.first).start + delta;
319
321
  for (const [index, slot] of order.entries()) {
320
322
  const info = source.line(slot.line);
321
323
  const target = source.line(region.first + index);
@@ -328,8 +330,10 @@ function apply(source, selections, moves, options) {
328
330
  lines.push({ from: slot.line, to: region.first + index });
329
331
  }
330
332
  }
331
- edits.push({ from: source.line(region.first).start, to: source.line(region.last).next, text: parts.join('') });
333
+ const edit = { from: source.line(region.first).start, to: source.line(region.last).next, text: parts.join('') };
334
+ edits.push(edit);
332
335
  moved.push(placed);
336
+ delta += edit.text.length - (edit.to - edit.from);
333
337
  }
334
338
  const mapPoint = (offset, keepWithLineAbove) => {
335
339
  const line = source.lineAt(keepWithLineAbove ? offset - 1 : offset);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/editor-core",
3
- "version": "0.44.2",
3
+ "version": "0.44.3",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -89,12 +89,25 @@ function completeBraced(source: EditSource, lineIndex: number, caret: number, op
89
89
  const context = source.context(end);
90
90
  const first = statementStart(source, lineIndex, options.language);
91
91
  const firstStart = source.line(first).start;
92
- const startsWithBrace = /^\s*\}/.test(line.text);
92
+ const startsWithBrace = /^\s*\}/.test(line.text) && source.context(line.start + whitespaceOf(line.text).length).bracket?.block !== false;
93
93
  let inline = context.mode === 'quote' ? (context.quote ?? '') : '';
94
94
  let open = context.bracket;
95
95
 
96
+ let resume: number | null = null;
97
+
96
98
  for (; open !== undefined && open.template !== true && !(open.close === '}' && open.block === true) && open.at >= firstStart; open = open.previous) {
97
- inline += open.close;
99
+ const closer = source.closerOf(open.at);
100
+
101
+ if (closer === undefined) {
102
+ inline += open.close;
103
+ } else {
104
+ resume = closer;
105
+ }
106
+ }
107
+
108
+ // A later line closes a bracket of this one, so the statement goes on there and ends there.
109
+ if (resume !== null) {
110
+ return completeBraced(source, source.lineAt(resume), resume, options);
98
111
  }
99
112
 
100
113
  const tail = source.slice(end, line.end);
@@ -184,9 +197,20 @@ function completePython(source: EditSource, lineIndex: number, caret: number, op
184
197
  }
185
198
 
186
199
  let inline = '';
200
+ let resume: number | null = null;
187
201
 
188
202
  for (let open = source.context(end).bracket; open !== undefined; open = open.previous) {
189
- inline += open.close;
203
+ const closer = source.closerOf(open.at);
204
+
205
+ if (closer === undefined) {
206
+ inline += open.close;
207
+ } else {
208
+ resume = closer;
209
+ }
210
+ }
211
+
212
+ if (resume !== null) {
213
+ return completePython(source, source.lineAt(resume), resume, options);
190
214
  }
191
215
 
192
216
  const head = (source.slice(line.start, end) + inline).trim();
@@ -205,25 +229,30 @@ function completePython(source: EditSource, lineIndex: number, caret: number, op
205
229
  export function planCompleteStatement(source: EditSource, selections: readonly Selection[], options: CompleteStatementOptions): EditPlan {
206
230
  const complete = isBraced(options.language) ? completeBraced : /^(python|py)$/i.test(options.language) ? completePython : null;
207
231
  const pieces = new Map<number, Piece>();
232
+ const byStart = new Map<number, Piece>();
208
233
 
209
234
  for (const selection of selections) {
210
235
  const line = source.lineAt(selection.head);
211
- pieces.set(line, complete === null ? startNewLine(source, line, options) : complete(source, line, selection.head, options));
236
+ const piece = complete === null ? startNewLine(source, line, options) : complete(source, line, selection.head, options);
237
+
238
+ // Carets on lines of one statement end up at the same edit, which applies once.
239
+ pieces.set(line, byStart.get(piece.from) ?? piece);
240
+ byStart.set(piece.from, pieces.get(line)!);
212
241
  }
213
242
 
214
- const ordered = [...pieces.entries()].sort((left, right) => left[1].from - right[1].from);
215
- const carets = new Map<number, number>();
243
+ const ordered = [...new Set(pieces.values())].sort((left, right) => left.from - right.from);
244
+ const carets = new Map<Piece, number>();
216
245
  let delta = 0;
217
246
 
218
- for (const [line, piece] of ordered) {
219
- carets.set(line, piece.from + delta + piece.caret);
247
+ for (const piece of ordered) {
248
+ carets.set(piece, piece.from + delta + piece.caret);
220
249
  delta += piece.text.length - (piece.to - piece.from);
221
250
  }
222
251
 
223
252
  return {
224
- edits: ordered.map(([, piece]) => ({ from: piece.from, to: piece.to, text: piece.text })),
253
+ edits: ordered.map((piece) => ({ from: piece.from, to: piece.to, text: piece.text })),
225
254
  selections: selections.map((selection) => {
226
- const caret = carets.get(source.lineAt(selection.head))!;
255
+ const caret = carets.get(pieces.get(source.lineAt(selection.head))!)!;
227
256
 
228
257
  return { anchor: caret, head: caret };
229
258
  })
package/src/document.ts CHANGED
@@ -2224,7 +2224,8 @@ export class DocumentModel {
2224
2224
  newline: (index) => this.newlineAt(index),
2225
2225
  context: (offset) => this.typingContext(offset, language),
2226
2226
  region: (line) => (/^vue$/i.test(language) ? vueRegionAt((index) => this.getLine(index).text, line) : null),
2227
- unclosedBrace: (offset) => this.isUnclosedBrace(offset, language)
2227
+ unclosedBrace: (offset) => this.isUnclosedBrace(offset, language),
2228
+ closerOf: (offset) => this.closerOf(offset, language)
2228
2229
  };
2229
2230
  }
2230
2231
 
@@ -2233,10 +2234,7 @@ export class DocumentModel {
2233
2234
  * it, which it took from that block. Such a closer sits shallower than the brace's line and leaves an opener before it unmatched.
2234
2235
  */
2235
2236
  private isUnclosedBrace(offset: number, language: string): boolean {
2236
- const context = isPhp(language) ? this.typingContext(offset, language) : undefined;
2237
-
2238
- const index =
2239
- context?.embedded === 'javascript' && context.embedStart !== undefined ? this.scriptBracketIndex(context.embedStart) : this.bracketIndex(language);
2237
+ const index = this.bracketIndexAt(offset, language);
2240
2238
 
2241
2239
  if (index === undefined) {
2242
2240
  return false;
@@ -2257,6 +2255,25 @@ export class DocumentModel {
2257
2255
  return [...index.unmatched].some((at) => at < offset && this.rope.charAt(at) === '{');
2258
2256
  }
2259
2257
 
2258
+ /* Where the closer of the opening bracket at `offset` stands; undefined where it has none, or the document is too large to scan. */
2259
+ private closerOf(offset: number, language: string): number | undefined {
2260
+ const index = this.bracketIndexAt(offset, language);
2261
+
2262
+ if (index === undefined || index.unmatched.has(offset)) {
2263
+ return undefined;
2264
+ }
2265
+
2266
+ return index.pairs.get(offset);
2267
+ }
2268
+
2269
+ private bracketIndexAt(offset: number, language: string): BracketIndex | undefined {
2270
+ const context = isPhp(language) ? this.typingContext(offset, language) : undefined;
2271
+
2272
+ return context?.embedded === 'javascript' && context.embedStart !== undefined
2273
+ ? this.scriptBracketIndex(context.embedStart)
2274
+ : this.bracketIndex(language);
2275
+ }
2276
+
2260
2277
  private scriptSource(start: number, limit: number): { limit: number; text?: string; index?: BracketIndex } {
2261
2278
  if (this.scriptCache?.revision !== this.revision) {
2262
2279
  this.scriptCache = { revision: this.revision, sources: new Map() };
@@ -16,6 +16,8 @@ export interface EditSource {
16
16
  region(line: number): VueRegion | null;
17
17
  /* Whether a `{` at this offset has no closer of its own, so Enter after it adds one. */
18
18
  unclosedBrace(offset: number): boolean;
19
+ /* Where the closer of the opening bracket at this offset stands, undefined where the document has none. */
20
+ closerOf(offset: number): number | undefined;
19
21
  }
20
22
 
21
23
  /* Edits to apply together and the selections that stand after them. */
package/src/hippie.ts CHANGED
@@ -103,22 +103,31 @@ function isHump(word: string, index: number): boolean {
103
103
  return startsHump || /[\d_-]/.test(before);
104
104
  }
105
105
 
106
- function continues(word: string, prefix: string, wordIndex: number, prefixIndex: number): boolean {
106
+ /* `failed` holds the positions known to lead nowhere, so each of the word times prefix positions is tried once. */
107
+ function continues(word: string, prefix: string, wordIndex: number, prefixIndex: number, failed: Set<number>): boolean {
107
108
  if (prefixIndex === prefix.length) {
108
109
  return true;
109
110
  }
110
111
 
112
+ const position = wordIndex * prefix.length + prefixIndex;
113
+
114
+ if (failed.has(position)) {
115
+ return false;
116
+ }
117
+
111
118
  for (let i = wordIndex; i < word.length; i++) {
112
119
  // The character right after the last match continues it; any other has to start a hump.
113
120
  if (i > wordIndex && !isHump(word, i)) {
114
121
  continue;
115
122
  }
116
123
 
117
- if (word[i]!.toLowerCase() === prefix[prefixIndex]!.toLowerCase() && continues(word, prefix, i + 1, prefixIndex + 1)) {
124
+ if (word[i]!.toLowerCase() === prefix[prefixIndex]!.toLowerCase() && continues(word, prefix, i + 1, prefixIndex + 1, failed)) {
118
125
  return true;
119
126
  }
120
127
  }
121
128
 
129
+ failed.add(position);
130
+
122
131
  return false;
123
132
  }
124
133
 
@@ -128,7 +137,7 @@ export function hippieMatches(word: string, prefix: string): boolean {
128
137
  return false;
129
138
  }
130
139
 
131
- return prefix === '' || (word[0] === prefix[0] && continues(word, prefix, 1, 1));
140
+ return prefix === '' || (word[0] === prefix[0] && continues(word, prefix, 1, 1, new Set()));
132
141
  }
133
142
 
134
143
  /* The words of this document that complete the prefix: those before the primary caret ascending, then those after it. */
@@ -263,13 +272,14 @@ export function planHippie(
263
272
 
264
273
  // With nothing left to offer, a cycle that is under way gives the prefix back.
265
274
  const proposal = variant?.text ?? prefix;
266
- const length = cycle?.length ?? prefix.length;
267
275
  const edits: TextEdit[] = [];
268
276
  const landed: number[] = [];
269
277
  let end = 0;
270
278
  let delta = 0;
271
279
 
272
280
  for (const caret of carets) {
281
+ // A cycle under way holds the same text at every caret; a first press replaces what each caret has typed.
282
+ const length = cycle?.length ?? caret - hippiePrefixStart(text, caret, hyphenated);
273
283
  const from = Math.max(caret - length, end);
274
284
 
275
285
  edits.push({ from, to: caret, text: proposal });
@@ -473,10 +473,13 @@ function apply(source: EditSource, selections: readonly Selection[], moves: read
473
473
 
474
474
  const ordered = moves.toSorted((left, right) => left.region.first - right.region.first);
475
475
 
476
+ // Where a region stands after the edits of the regions before it.
477
+ let delta = 0;
478
+
476
479
  for (const { region, order } of ordered) {
477
480
  const parts: string[] = [];
478
481
  const placed: Placed[] = [];
479
- let at = source.line(region.first).start;
482
+ let at = source.line(region.first).start + delta;
480
483
 
481
484
  for (const [index, slot] of order.entries()) {
482
485
  const info = source.line(slot.line);
@@ -492,8 +495,11 @@ function apply(source: EditSource, selections: readonly Selection[], moves: read
492
495
  }
493
496
  }
494
497
 
495
- edits.push({ from: source.line(region.first).start, to: source.line(region.last).next, text: parts.join('') });
498
+ const edit = { from: source.line(region.first).start, to: source.line(region.last).next, text: parts.join('') };
499
+
500
+ edits.push(edit);
496
501
  moved.push(placed);
502
+ delta += edit.text.length - (edit.to - edit.from);
497
503
  }
498
504
 
499
505
  const mapPoint = (offset: number, keepWithLineAbove: boolean): number => {