@adecore/editor-core 0.17.0-beta.2 → 0.18.0

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.
@@ -37,6 +37,7 @@ export declare class DocumentModel {
37
37
  private selectionHistory;
38
38
  private typingContexts;
39
39
  private statementClosers;
40
+ private arrows;
40
41
  private cloneSession;
41
42
  private occurrenceSession;
42
43
  occurrencesExhausted: boolean;
@@ -67,6 +68,8 @@ export declare class DocumentModel {
67
68
  replaceAll(query: string, replacement: string, options?: ReplaceAllOptions): number;
68
69
  getFoldingRanges(options?: FoldingOptions): FoldingRange[];
69
70
  typeText(text: string, options?: TypeTextOptions): boolean;
71
+ private takeBackArrows;
72
+ private completeArrows;
70
73
  paste(text: string, options?: CommandOptions & {
71
74
  wholeLines?: boolean;
72
75
  }): boolean;
@@ -78,6 +81,7 @@ export declare class DocumentModel {
78
81
  private shouldClosePair;
79
82
  private completesTripleQuote;
80
83
  private hasMatch;
84
+ private endsOperand;
81
85
  private rememberStatementClosers;
82
86
  private rememberPairs;
83
87
  private insertTab;
@@ -106,8 +110,10 @@ export declare class DocumentModel {
106
110
  private startNewLineBefore;
107
111
  private editLines;
108
112
  private editSource;
113
+ private isUnclosedBrace;
109
114
  private bracketIndex;
110
115
  private deleteCharacter;
116
+ private unindent;
111
117
  private isEmptyPair;
112
118
  private backspaceStart;
113
119
  private deleteRanges;
package/dist/document.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { CloserTracker } from './closer-tracker.js';
2
2
  import { planBlockComment, planLineComments } from './comments.js';
3
3
  import { planEnter } from './enter.js';
4
- import { planAutoIndent, planJoinLines, planToggleCase } from './line-commands.js';
4
+ import { planAutoIndent, planJoinLines, planToggleCase, properIndent, spacingBetween } from './line-commands.js';
5
5
  import { mapOffset } from './offsets.js';
6
6
  import { planPaste } from './paste.js';
7
7
  import { isPlainText, vueRegionAt } from './languages.js';
@@ -10,7 +10,7 @@ import { applyFoldHints, markFileHeader } from './fold-roles.js';
10
10
  import { lexicalFolds } from './lexical-folds.js';
11
11
  import { isQuote, replacesComparison, surround, swapQuotes } from './typing-handlers.js';
12
12
  import { scanBrackets } from './brackets.js';
13
- import { hasSmartSemicolon } from './lexical.js';
13
+ import { hasSmartSemicolon, isPhp } from './lexical.js';
14
14
  import { clampInteger, TextRope } from './rope.js';
15
15
  import { findMatches, replacementText, splitsSurrogate } from './search.js';
16
16
  import { dedupeFoldingRanges, deriveFoldingRanges, indentationColumn, scanStructure, tabWidth } from './structure.js';
@@ -27,6 +27,8 @@ const bracketScanLimit = 2_000_000;
27
27
  const matchScanLimit = 400_000;
28
28
  const pairs = { '(': ')', '[': ']', '{': '}', '"': '"', "'": "'", '`': '`' };
29
29
  const identifier = /[\p{L}\p{N}\p{M}\p{Pc}$]/u;
30
+ /* What can follow `->`: the first letter of a member name, or the `{` of a dynamic one. */
31
+ const arrowContinues = /^[\p{L}_{]/u;
30
32
  const wordCommand = /^(select|delete)?(word|camel)(Left|Right)$/i;
31
33
  const graphemes = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
32
34
  function rangeOf(selection) {
@@ -223,6 +225,8 @@ export class DocumentModel {
223
225
  selectionHistory = [];
224
226
  typingContexts = new TypingContexts((line) => this.getLine(line));
225
227
  statementClosers = new Map();
228
+ /* The carets that stand right after a `->` the editor completed, where the next keystroke decides whether it stays. */
229
+ arrows = new Set();
226
230
  cloneSession;
227
231
  occurrenceSession;
228
232
  /* The last select next occurrence found no more, which a host may say. */
@@ -291,6 +295,7 @@ export class DocumentModel {
291
295
  this.cloneSession = undefined;
292
296
  this.selectionHistory = [];
293
297
  this.statementClosers.clear();
298
+ this.arrows.clear();
294
299
  if (sameSelections(this.selections, normalized)) {
295
300
  return;
296
301
  }
@@ -459,8 +464,8 @@ export class DocumentModel {
459
464
  /*
460
465
  * Types `text` at every caret as one keystroke would: it wraps a selection in a bracket or quote,
461
466
  * closes an opener, types over a closer that is already there and, in TypeScript, JavaScript and
462
- * PHP, moves a `;` to the end of a statement. Comments and string contents are left alone.
463
- * Returns whether anything changed, a swallowed closer included.
467
+ * PHP, moves a `;` to the end of a statement. In PHP a `-` after a variable becomes `->`. Comments
468
+ * and string contents are left alone. Returns whether anything changed, a swallowed closer included.
464
469
  */
465
470
  typeText(text, options = {}) {
466
471
  if (options.expectedRevision !== undefined && options.expectedRevision !== this.revision) {
@@ -469,6 +474,11 @@ export class DocumentModel {
469
474
  if (!text) {
470
475
  return false;
471
476
  }
477
+ const arrows = this.arrows;
478
+ this.arrows = new Set();
479
+ if (arrows.size > 0 && text !== '>' && !arrowContinues.test(text)) {
480
+ this.takeBackArrows();
481
+ }
472
482
  const language = options.language ?? 'typescript';
473
483
  const single = text.length === 1;
474
484
  const pairing = options.autoClosingPairs !== false && single && !isPlainText(language);
@@ -479,6 +489,8 @@ export class DocumentModel {
479
489
  quotes: pairing && options.autoClosingQuotes !== false,
480
490
  surround: options.surroundSelection !== false && single,
481
491
  smartSemicolon: options.smartSemicolon !== false && hasSmartSemicolon(language),
492
+ smartArrow: options.smartArrow !== false && single && isPhp(language),
493
+ overArrows: text === '>' ? arrows : new Set(),
482
494
  dedent: options.smartEnter !== false
483
495
  };
484
496
  const plans = this.selections.map((selection) => this.planTyping(selection, input));
@@ -490,9 +502,31 @@ export class DocumentModel {
490
502
  if (this.revision === appliedRevision) {
491
503
  this.rememberStatementClosers(plans, transaction, language);
492
504
  this.rememberPairs(plans, transaction);
505
+ this.completeArrows(plans, transaction);
493
506
  }
494
507
  return changed || plans.some((plan) => plan.consumed);
495
508
  }
509
+ /*
510
+ * A key after a `->` the editor completed that cannot follow it means the `-` was a minus. The `>` is the
511
+ * last undo step, so it goes without a redo, and the key joins the typing of the `-`.
512
+ */
513
+ takeBackArrows() {
514
+ this.undo();
515
+ this.redoStack.pop();
516
+ this.groupOpen = true;
517
+ }
518
+ /* Adds the `>` after every `-` that became an arrow, as its own undo step, so the first undo leaves the `-` that was typed. */
519
+ completeArrows(plans, transaction) {
520
+ const at = transaction.selections.flatMap((selection, index) => (plans[index]?.arrow ? [selection.head] : [])).sort((left, right) => left - right);
521
+ if (at.length === 0) {
522
+ return;
523
+ }
524
+ const moved = (offset) => offset + at.filter((inserted) => inserted <= offset).length;
525
+ const selections = this.selections.map((selection) => ({ anchor: moved(selection.anchor), head: moved(selection.head) }));
526
+ if (this.applyEdits(at.map((offset) => ({ from: offset, to: offset, text: '>' })), { source: 'input', selections })) {
527
+ this.arrows = new Set(at.map(moved));
528
+ }
529
+ }
496
530
  /*
497
531
  * Pastes `text` at every selection. With as many lines as carets each caret takes one; `wholeLines`
498
532
  * marks text copied from a bare caret, which goes above the lines of bare carets.
@@ -549,11 +583,11 @@ export class DocumentModel {
549
583
  }
550
584
  const tabSize = tabWidth(options);
551
585
  const language = options.language ?? 'typescript';
586
+ const unit = options.insertSpaces === false ? '\t' : ' '.repeat(tabSize);
552
587
  if (command === 'smartBackspace' || command === 'deleteForward') {
553
588
  const pairing = options.autoClosingPairs !== false && !isPlainText(language);
554
- return this.deleteCharacter(command === 'smartBackspace', tabSize, { brackets: pairing && options.autoClosingBrackets !== false, quotes: pairing && options.autoClosingQuotes !== false }, language);
589
+ return this.deleteCharacter(command === 'smartBackspace', { tabSize, unit }, { brackets: pairing && options.autoClosingBrackets !== false, quotes: pairing && options.autoClosingQuotes !== false }, language);
555
590
  }
556
- const unit = options.insertSpaces === false ? '\t' : ' '.repeat(tabSize);
557
591
  const enter = { language, unit, tabSize, smart: options.smartEnter !== false, reach: true };
558
592
  if (command === 'insertNewline' || command === 'startNewLine' || command === 'splitLine') {
559
593
  return this.insertNewline(enter, command === 'insertNewline' ? 'enter' : command === 'startNewLine' ? 'below' : 'split');
@@ -615,12 +649,19 @@ export class DocumentModel {
615
649
  const after = this.rope.charAt(to);
616
650
  const quote = isQuote(text);
617
651
  const pairing = quote ? input.quotes : input.brackets;
652
+ if (text === '>' && from === to && input.overArrows.has(from)) {
653
+ return { from, to, text: '', anchor: 0, head: 0, skip: true, consumed: true };
654
+ }
618
655
  const pendingClosers = this.statementClosers.get(from);
619
656
  if (input.brackets && smartSemicolon && from === to && pendingClosers?.language === language && pendingClosers.text.startsWith(text)) {
620
657
  return { from, to, text: '', anchor: 0, head: 0, skip: true, consumed: true, completedClosers: pendingClosers.text.slice(1) };
621
658
  }
622
- const needsContext = text.length === 1 && (/[()[\]{}'"`<]/.test(text) || (text === ';' && smartSemicolon));
659
+ const arrow = text === '-' && input.smartArrow && from === to;
660
+ const needsContext = text.length === 1 && (/[()[\]{}'"`<]/.test(text) || (text === ';' && smartSemicolon) || arrow);
623
661
  const context = needsContext ? this.typingContext(from, language) : undefined;
662
+ if (arrow && context?.mode === 'code' && context.word !== 'control-close' && this.endsOperand(from)) {
663
+ return { from, to, text, anchor: 1, head: 1, arrow: true };
664
+ }
624
665
  const insideQuote = context && (context.mode === 'quote' || context.mode === 'template') && context.quote === text && !context.escaped;
625
666
  if (pairing && from === to && after === text && (quote ? insideQuote : context?.mode === 'code' && context.bracket?.close === text)) {
626
667
  return { from, to, text: '', anchor: 1, head: 1, skip: true };
@@ -723,6 +764,23 @@ export class DocumentModel {
723
764
  const scanned = scanBrackets(text.slice(0, offset) + opener + text.slice(offset), language);
724
765
  return scanned.pairs.has(offset);
725
766
  }
767
+ /*
768
+ * Whether the text before `offset` ends in something a member is read from: a variable, the name of a
769
+ * property or method after `->` or `?->`, or a `)` or `]`.
770
+ */
771
+ endsOperand(offset) {
772
+ const line = this.rope.lineBounds(this.rope.lineAt(offset));
773
+ const before = this.slice(line.start, offset);
774
+ if (/[)\]]$/.test(before)) {
775
+ return true;
776
+ }
777
+ const name = /[\p{L}_][\p{L}\p{N}_]*$/u.exec(before);
778
+ if (name === null) {
779
+ return false;
780
+ }
781
+ const ahead = before.slice(0, name.index);
782
+ return ahead.endsWith('$') || ahead.endsWith('->');
783
+ }
726
784
  /* Remembers the closers a smart semicolon stepped over for the caret that did it, so typing them again is absorbed. */
727
785
  rememberStatementClosers(plans, transaction, language) {
728
786
  for (const [index, selection] of transaction.selections.entries()) {
@@ -1259,9 +1317,28 @@ export class DocumentModel {
1259
1317
  newline: (index) => this.newlineAt(index),
1260
1318
  context: (offset) => this.typingContext(offset, language),
1261
1319
  region: (line) => (/^vue$/i.test(language) ? vueRegionAt((index) => this.getLine(index).text, line) : null),
1262
- unmatchedBrace: (offset) => this.bracketIndex(language)?.unmatched.has(offset) ?? false
1320
+ unclosedBrace: (offset) => this.isUnclosedBrace(offset, language)
1263
1321
  };
1264
1322
  }
1323
+ /*
1324
+ * Whether the `{` at `offset` has no closer of its own: none in the document, or only the closer of a block around
1325
+ * it, which it took from that block. Such a closer sits shallower than the brace's line and leaves an opener before it unmatched.
1326
+ */
1327
+ isUnclosedBrace(offset, language) {
1328
+ const index = this.bracketIndex(language);
1329
+ if (index === undefined) {
1330
+ return false;
1331
+ }
1332
+ if (index.unmatched.has(offset)) {
1333
+ return true;
1334
+ }
1335
+ const closer = index.pairs.get(offset);
1336
+ const indentAt = (at) => this.getLine(this.rope.lineAt(at)).text.match(/^[\t ]*/)[0].length;
1337
+ if (closer === undefined || indentAt(closer) >= indentAt(offset)) {
1338
+ return false;
1339
+ }
1340
+ return [...index.unmatched].some((at) => at < offset && this.rope.charAt(at) === '{');
1341
+ }
1265
1342
  /* Undefined for a document too large to scan on every Enter. */
1266
1343
  bracketIndex(language) {
1267
1344
  if (this.rope.length > bracketScanLimit) {
@@ -1275,24 +1352,60 @@ export class DocumentModel {
1275
1352
  this.bracketCache = { revision: this.revision, language, index };
1276
1353
  return index;
1277
1354
  }
1278
- deleteCharacter(backwards, tabSize, pairing, language) {
1355
+ deleteCharacter(backwards, indent, pairing, language) {
1279
1356
  return this.deleteRanges(this.selections.map((selection) => {
1280
1357
  let { from, to } = rangeOf(selection);
1281
1358
  if (from === to) {
1282
1359
  if (!backwards) {
1283
1360
  to = deletionStep(this.rope, to, 1);
1284
1361
  }
1362
+ else if (this.arrows.has(from)) {
1363
+ from -= 2;
1364
+ }
1285
1365
  else if ((isQuote(this.rope.charAt(from - 1)) ? pairing.quotes : pairing.brackets) && this.isEmptyPair(from, language)) {
1286
1366
  from--;
1287
1367
  to++;
1288
1368
  }
1289
1369
  else {
1290
- from = this.backspaceStart(from, tabSize);
1370
+ return this.unindent(from, indent, language) ?? { from: this.backspaceStart(from, indent.tabSize), to, text: '' };
1291
1371
  }
1292
1372
  }
1293
1373
  return { from, to, text: '' };
1294
1374
  }));
1295
1375
  }
1376
+ /*
1377
+ * Backspace with only whitespace before the caret, after the platform's smart backspace: a line indented past
1378
+ * where its brackets put it goes back to there, and one at or before it joins the line above, or takes the place
1379
+ * of that line when it is blank. Null where the brackets say nothing.
1380
+ */
1381
+ unindent(offset, indent, language) {
1382
+ const index = this.rope.lineAt(offset);
1383
+ const line = this.rope.lineBounds(index);
1384
+ if (!/^[\t ]*$/.test(this.slice(line.start, offset))) {
1385
+ return null;
1386
+ }
1387
+ const proper = properIndent(this.editSource(language), index, { language, unit: indent.unit });
1388
+ if (proper === null) {
1389
+ return null;
1390
+ }
1391
+ let end = offset;
1392
+ while (end < line.end && /[\t ]/.test(this.rope.charAt(end))) {
1393
+ end++;
1394
+ }
1395
+ if (indentationColumn(this.slice(line.start, end), indent.tabSize) > indentationColumn(proper, indent.tabSize)) {
1396
+ return { from: line.start, to: end, text: proper };
1397
+ }
1398
+ if (index === 0) {
1399
+ return { from: 0, to: end, text: '' };
1400
+ }
1401
+ const previous = this.getLine(index - 1);
1402
+ const start = previous.end - trailingBlankLength(previous.text);
1403
+ if (start !== previous.start || start === 0) {
1404
+ const before = this.slice(previous.start, start);
1405
+ return { from: start, to: end, text: before === '' ? '' : spacingBetween(before, this.slice(end, line.end).trimEnd()) };
1406
+ }
1407
+ return { from: start, to: end, text: proper };
1408
+ }
1296
1409
  /* Whether the caret sits between an opener and its own closer, which backspace removes together. */
1297
1410
  isEmptyPair(offset, language) {
1298
1411
  const opener = this.rope.charAt(offset - 1);
@@ -1699,6 +1812,7 @@ export class DocumentModel {
1699
1812
  this.structureCache = undefined;
1700
1813
  this.selectionHistory = [];
1701
1814
  this.statementClosers.clear();
1815
+ this.arrows.clear();
1702
1816
  }
1703
1817
  restore(state, changes) {
1704
1818
  const previous = this.rope;
@@ -11,7 +11,7 @@ export interface EditSource {
11
11
  newline(index: number): string;
12
12
  context(offset: number): TypingContext;
13
13
  region(line: number): VueRegion | null;
14
- unmatchedBrace(offset: number): boolean;
14
+ unclosedBrace(offset: number): boolean;
15
15
  }
16
16
  export interface EditPlan {
17
17
  edits: TextEdit[];
package/dist/enter.js CHANGED
@@ -120,7 +120,7 @@ class Enter {
120
120
  if (next === closers[last]) {
121
121
  return { from, to: afterWhitespace, text: newline + inner + newline + start, caret: newline.length + inner.length };
122
122
  }
123
- const closing = last === '{' && source.unmatchedBrace(context.bracket.at) ? this.closeBrace(from, afterWhitespace, line, rest, newline, start, inner) : null;
123
+ const closing = last === '{' && source.unclosedBrace(context.bracket.at) ? this.closeBrace(from, afterWhitespace, line, rest, newline, start, inner) : null;
124
124
  return closing ?? plain(inner);
125
125
  }
126
126
  const tag = this.openTag(trimmed, index);
@@ -100,6 +100,12 @@ export function applyFoldHints(ranges, hints, lineAt, getLine, slice, minimum) {
100
100
  for (const hint of hints.ranges ?? []) {
101
101
  const startLine = lineAt(hint.from);
102
102
  const endLine = lineAt(hint.to);
103
+ const same = result.find((range) => range.startLine === startLine && range.endLine === endLine);
104
+ // The text's own fold of the same lines keeps its kind and role, and takes where the server hides from and shows again from.
105
+ if (same !== undefined) {
106
+ Object.assign(same, edgesOf(hint));
107
+ continue;
108
+ }
103
109
  if (endLine - startLine < minimum || starts.has(startLine)) {
104
110
  continue;
105
111
  }
@@ -113,11 +119,15 @@ export function applyFoldHints(ranges, hints, lineAt, getLine, slice, minimum) {
113
119
  from: first.start + indent,
114
120
  to: getLine(endLine).end,
115
121
  kind: 'server',
116
- ...(role === undefined ? {} : { role })
122
+ ...(role === undefined ? {} : { role }),
123
+ ...edgesOf(hint)
117
124
  });
118
125
  }
119
126
  return result.sort((left, right) => left.startLine - right.startLine || right.endLine - left.endLine);
120
127
  }
128
+ function edgesOf(hint) {
129
+ return { ...(hint.head === undefined ? {} : { head: hint.head }), ...(hint.tail === undefined ? {} : { tail: hint.tail }) };
130
+ }
121
131
  /* The fold that is the body of a symbol, and what kind of body it is. */
122
132
  function bodyOf(ranges, symbol, startLine, endLine, slice) {
123
133
  const brace = ranges
@@ -4,6 +4,8 @@ export interface LineCommandOptions {
4
4
  language: string;
5
5
  unit: string;
6
6
  }
7
+ export declare function spacingBetween(previous: string, next: string): string;
7
8
  export declare function planJoinLines(source: EditSource, selections: readonly Selection[], options: LineCommandOptions): EditPlan | null;
8
9
  export declare function planToggleCase(source: EditSource, selections: readonly Selection[]): EditPlan | null;
9
10
  export declare function planAutoIndent(source: EditSource, lines: readonly number[], options: LineCommandOptions): EditPlan | null;
11
+ export declare function properIndent(source: EditSource, line: number, options: LineCommandOptions): string | null;
@@ -27,7 +27,7 @@ function skipWhitespace(source, from) {
27
27
  return offset;
28
28
  }
29
29
  /* What goes where two lines meet: nothing before a closer, a comma or a dot and after an opener, else a space. */
30
- function spacingBetween(previous, next) {
30
+ export function spacingBetween(previous, next) {
31
31
  if (next === '' || /^[)\],;]/.test(next) || /^\.(?![.\d])/.test(next) || /[([]$/.test(previous)) {
32
32
  return '';
33
33
  }
@@ -236,6 +236,14 @@ export function planAutoIndent(source, lines, options) {
236
236
  }
237
237
  return edits.length === 0 ? null : { edits, selections: [] };
238
238
  }
239
+ /* The indentation the brackets give a line, or null where they say nothing: outside code, in markup and in a language that is not bracketed. */
240
+ export function properIndent(source, line, options) {
241
+ const context = source.context(source.line(line).start);
242
+ if (!indentsByBrackets(options.language) || context.mode !== 'code' || markupGuard(source, options.language)(line)) {
243
+ return null;
244
+ }
245
+ return targetIndent(source, options, line, context, (index) => whitespaceOf(source.line(index).text));
246
+ }
239
247
  function targetIndent(source, options, line, context, indentOf) {
240
248
  const text = source.line(line).text.trimStart();
241
249
  const open = frames(context);
@@ -14,6 +14,8 @@ export interface FoldingRange {
14
14
  to: number;
15
15
  kind: 'bracket' | 'comment' | 'indentation' | 'imports' | 'line-comments' | 'region' | 'block' | 'section' | 'server';
16
16
  role?: FoldRole;
17
+ head?: number;
18
+ tail?: number;
17
19
  }
18
20
  export interface FoldingOptions {
19
21
  brackets?: boolean;
@@ -36,6 +38,8 @@ export interface FoldRangeHint {
36
38
  from: number;
37
39
  to: number;
38
40
  kind?: string;
41
+ head?: number;
42
+ tail?: number;
39
43
  }
40
44
  export interface FoldHints {
41
45
  symbols?: readonly FoldSymbolHint[];
package/dist/types.d.ts CHANGED
@@ -52,6 +52,7 @@ export interface CommandOptions {
52
52
  surroundSelection?: boolean;
53
53
  language?: string;
54
54
  smartSemicolon?: boolean;
55
+ smartArrow?: boolean;
55
56
  tabOutOfClosers?: boolean;
56
57
  smartEnter?: boolean;
57
58
  indentOnPaste?: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/editor-core",
3
- "version": "0.17.0-beta.2",
3
+ "version": "0.18.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
package/src/document.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { CloserTracker } from './closer-tracker.ts';
2
2
  import { planBlockComment, planLineComments } from './comments.ts';
3
3
  import { planEnter } from './enter.ts';
4
- import { planAutoIndent, planJoinLines, planToggleCase } from './line-commands.ts';
4
+ import { planAutoIndent, planJoinLines, planToggleCase, properIndent, spacingBetween } from './line-commands.ts';
5
5
  import { mapOffset } from './offsets.ts';
6
6
  import { planPaste } from './paste.ts';
7
7
  import type { EnterOptions, EnterPlan } from './enter.ts';
@@ -13,7 +13,7 @@ import { lexicalFolds } from './lexical-folds.ts';
13
13
  import { isQuote, replacesComparison, surround, swapQuotes } from './typing-handlers.ts';
14
14
  import { scanBrackets } from './brackets.ts';
15
15
  import type { BracketIndex } from './brackets.ts';
16
- import { hasSmartSemicolon } from './lexical.ts';
16
+ import { hasSmartSemicolon, isPhp } from './lexical.ts';
17
17
  import { clampInteger, TextRope } from './rope.ts';
18
18
  import type { DocumentLine } from './rope.ts';
19
19
  import { findMatches, replacementText, splitsSurrogate } from './search.ts';
@@ -88,6 +88,8 @@ interface SelectionEdit extends TextEdit {
88
88
  consumed?: boolean;
89
89
  /* An opener and the closer the editor added with it, with the caret between them. */
90
90
  paired?: boolean;
91
+ /* A `-` the editor completes to `->` in a step of its own, once the `-` is in. */
92
+ arrow?: boolean;
91
93
  }
92
94
 
93
95
  interface SelectionTransaction {
@@ -108,6 +110,9 @@ interface TypingInput {
108
110
  quotes: boolean;
109
111
  surround: boolean;
110
112
  smartSemicolon: boolean;
113
+ smartArrow: boolean;
114
+ /* The carets right after a `->` the editor completed, where a typed `>` goes over the one that is there. */
115
+ overArrows: ReadonlySet<number>;
111
116
  /* A closing bracket typed on a line of its own goes back to the indentation of its opener. */
112
117
  dedent: boolean;
113
118
  }
@@ -144,6 +149,8 @@ const bracketScanLimit = 2_000_000;
144
149
  const matchScanLimit = 400_000;
145
150
  const pairs: Readonly<Record<string, string>> = { '(': ')', '[': ']', '{': '}', '"': '"', "'": "'", '`': '`' };
146
151
  const identifier = /[\p{L}\p{N}\p{M}\p{Pc}$]/u;
152
+ /* What can follow `->`: the first letter of a member name, or the `{` of a dynamic one. */
153
+ const arrowContinues = /^[\p{L}_{]/u;
147
154
  const wordCommand = /^(select|delete)?(word|camel)(Left|Right)$/i;
148
155
  const graphemes = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
149
156
 
@@ -362,6 +369,8 @@ export class DocumentModel {
362
369
  private selectionHistory: Selection[][] = [];
363
370
  private typingContexts = new TypingContexts((line) => this.getLine(line));
364
371
  private statementClosers = new Map<number, { text: string; language: string }>();
372
+ /* The carets that stand right after a `->` the editor completed, where the next keystroke decides whether it stays. */
373
+ private arrows = new Set<number>();
365
374
  private cloneSession: { signature: string; frames: { direction: -1 | 1; carets: CaretClone[] }[] } | undefined;
366
375
  private occurrenceSession: { wholeWord: boolean; notFound: boolean; signature: string } | undefined;
367
376
  /* The last select next occurrence found no more, which a host may say. */
@@ -442,6 +451,7 @@ export class DocumentModel {
442
451
  this.cloneSession = undefined;
443
452
  this.selectionHistory = [];
444
453
  this.statementClosers.clear();
454
+ this.arrows.clear();
445
455
  if (sameSelections(this.selections, normalized)) {
446
456
  return;
447
457
  }
@@ -630,8 +640,8 @@ export class DocumentModel {
630
640
  /*
631
641
  * Types `text` at every caret as one keystroke would: it wraps a selection in a bracket or quote,
632
642
  * closes an opener, types over a closer that is already there and, in TypeScript, JavaScript and
633
- * PHP, moves a `;` to the end of a statement. Comments and string contents are left alone.
634
- * Returns whether anything changed, a swallowed closer included.
643
+ * PHP, moves a `;` to the end of a statement. In PHP a `-` after a variable becomes `->`. Comments
644
+ * and string contents are left alone. Returns whether anything changed, a swallowed closer included.
635
645
  */
636
646
  typeText(text: string, options: TypeTextOptions = {}): boolean {
637
647
  if (options.expectedRevision !== undefined && options.expectedRevision !== this.revision) {
@@ -640,6 +650,11 @@ export class DocumentModel {
640
650
  if (!text) {
641
651
  return false;
642
652
  }
653
+ const arrows = this.arrows;
654
+ this.arrows = new Set();
655
+ if (arrows.size > 0 && text !== '>' && !arrowContinues.test(text)) {
656
+ this.takeBackArrows();
657
+ }
643
658
  const language = options.language ?? 'typescript';
644
659
  const single = text.length === 1;
645
660
  const pairing = options.autoClosingPairs !== false && single && !isPlainText(language);
@@ -650,6 +665,8 @@ export class DocumentModel {
650
665
  quotes: pairing && options.autoClosingQuotes !== false,
651
666
  surround: options.surroundSelection !== false && single,
652
667
  smartSemicolon: options.smartSemicolon !== false && hasSmartSemicolon(language),
668
+ smartArrow: options.smartArrow !== false && single && isPhp(language),
669
+ overArrows: text === '>' ? arrows : new Set(),
653
670
  dedent: options.smartEnter !== false
654
671
  };
655
672
  const plans = this.selections.map((selection) => this.planTyping(selection, input));
@@ -665,10 +682,39 @@ export class DocumentModel {
665
682
  if (this.revision === appliedRevision) {
666
683
  this.rememberStatementClosers(plans, transaction, language);
667
684
  this.rememberPairs(plans, transaction);
685
+ this.completeArrows(plans, transaction);
668
686
  }
669
687
  return changed || plans.some((plan) => plan.consumed);
670
688
  }
671
689
 
690
+ /*
691
+ * A key after a `->` the editor completed that cannot follow it means the `-` was a minus. The `>` is the
692
+ * last undo step, so it goes without a redo, and the key joins the typing of the `-`.
693
+ */
694
+ private takeBackArrows(): void {
695
+ this.undo();
696
+ this.redoStack.pop();
697
+ this.groupOpen = true;
698
+ }
699
+
700
+ /* Adds the `>` after every `-` that became an arrow, as its own undo step, so the first undo leaves the `-` that was typed. */
701
+ private completeArrows(plans: readonly SelectionEdit[], transaction: SelectionTransaction): void {
702
+ const at = transaction.selections.flatMap((selection, index) => (plans[index]?.arrow ? [selection.head] : [])).sort((left, right) => left - right);
703
+ if (at.length === 0) {
704
+ return;
705
+ }
706
+ const moved = (offset: number): number => offset + at.filter((inserted) => inserted <= offset).length;
707
+ const selections = this.selections.map((selection) => ({ anchor: moved(selection.anchor), head: moved(selection.head) }));
708
+ if (
709
+ this.applyEdits(
710
+ at.map((offset) => ({ from: offset, to: offset, text: '>' })),
711
+ { source: 'input', selections }
712
+ )
713
+ ) {
714
+ this.arrows = new Set(at.map(moved));
715
+ }
716
+ }
717
+
672
718
  /*
673
719
  * Pastes `text` at every selection. With as many lines as carets each caret takes one; `wholeLines`
674
720
  * marks text copied from a bare caret, which goes above the lines of bare carets.
@@ -726,16 +772,16 @@ export class DocumentModel {
726
772
  }
727
773
  const tabSize = tabWidth(options);
728
774
  const language = options.language ?? 'typescript';
775
+ const unit = options.insertSpaces === false ? '\t' : ' '.repeat(tabSize);
729
776
  if (command === 'smartBackspace' || command === 'deleteForward') {
730
777
  const pairing = options.autoClosingPairs !== false && !isPlainText(language);
731
778
  return this.deleteCharacter(
732
779
  command === 'smartBackspace',
733
- tabSize,
780
+ { tabSize, unit },
734
781
  { brackets: pairing && options.autoClosingBrackets !== false, quotes: pairing && options.autoClosingQuotes !== false },
735
782
  language
736
783
  );
737
784
  }
738
- const unit = options.insertSpaces === false ? '\t' : ' '.repeat(tabSize);
739
785
  const enter = { language, unit, tabSize, smart: options.smartEnter !== false, reach: true };
740
786
  if (command === 'insertNewline' || command === 'startNewLine' || command === 'splitLine') {
741
787
  return this.insertNewline(enter, command === 'insertNewline' ? 'enter' : command === 'startNewLine' ? 'below' : 'split');
@@ -799,13 +845,20 @@ export class DocumentModel {
799
845
  const after = this.rope.charAt(to);
800
846
  const quote = isQuote(text);
801
847
  const pairing = quote ? input.quotes : input.brackets;
848
+ if (text === '>' && from === to && input.overArrows.has(from)) {
849
+ return { from, to, text: '', anchor: 0, head: 0, skip: true, consumed: true };
850
+ }
802
851
  const pendingClosers = this.statementClosers.get(from);
803
852
  if (input.brackets && smartSemicolon && from === to && pendingClosers?.language === language && pendingClosers.text.startsWith(text)) {
804
853
  return { from, to, text: '', anchor: 0, head: 0, skip: true, consumed: true, completedClosers: pendingClosers.text.slice(1) };
805
854
  }
806
855
 
807
- const needsContext = text.length === 1 && (/[()[\]{}'"`<]/.test(text) || (text === ';' && smartSemicolon));
856
+ const arrow = text === '-' && input.smartArrow && from === to;
857
+ const needsContext = text.length === 1 && (/[()[\]{}'"`<]/.test(text) || (text === ';' && smartSemicolon) || arrow);
808
858
  const context = needsContext ? this.typingContext(from, language) : undefined;
859
+ if (arrow && context?.mode === 'code' && context.word !== 'control-close' && this.endsOperand(from)) {
860
+ return { from, to, text, anchor: 1, head: 1, arrow: true };
861
+ }
809
862
  const insideQuote = context && (context.mode === 'quote' || context.mode === 'template') && context.quote === text && !context.escaped;
810
863
  if (pairing && from === to && after === text && (quote ? insideQuote : context?.mode === 'code' && context.bracket?.close === text)) {
811
864
  return { from, to, text: '', anchor: 1, head: 1, skip: true };
@@ -920,6 +973,24 @@ export class DocumentModel {
920
973
  return scanned.pairs.has(offset);
921
974
  }
922
975
 
976
+ /*
977
+ * Whether the text before `offset` ends in something a member is read from: a variable, the name of a
978
+ * property or method after `->` or `?->`, or a `)` or `]`.
979
+ */
980
+ private endsOperand(offset: number): boolean {
981
+ const line = this.rope.lineBounds(this.rope.lineAt(offset));
982
+ const before = this.slice(line.start, offset);
983
+ if (/[)\]]$/.test(before)) {
984
+ return true;
985
+ }
986
+ const name = /[\p{L}_][\p{L}\p{N}_]*$/u.exec(before);
987
+ if (name === null) {
988
+ return false;
989
+ }
990
+ const ahead = before.slice(0, name.index);
991
+ return ahead.endsWith('$') || ahead.endsWith('->');
992
+ }
993
+
923
994
  /* Remembers the closers a smart semicolon stepped over for the caret that did it, so typing them again is absorbed. */
924
995
  private rememberStatementClosers(plans: readonly SelectionEdit[], transaction: SelectionTransaction, language: string): void {
925
996
  for (const [index, selection] of transaction.selections.entries()) {
@@ -1513,10 +1584,30 @@ export class DocumentModel {
1513
1584
  newline: (index) => this.newlineAt(index),
1514
1585
  context: (offset) => this.typingContext(offset, language),
1515
1586
  region: (line) => (/^vue$/i.test(language) ? vueRegionAt((index) => this.getLine(index).text, line) : null),
1516
- unmatchedBrace: (offset) => this.bracketIndex(language)?.unmatched.has(offset) ?? false
1587
+ unclosedBrace: (offset) => this.isUnclosedBrace(offset, language)
1517
1588
  };
1518
1589
  }
1519
1590
 
1591
+ /*
1592
+ * Whether the `{` at `offset` has no closer of its own: none in the document, or only the closer of a block around
1593
+ * it, which it took from that block. Such a closer sits shallower than the brace's line and leaves an opener before it unmatched.
1594
+ */
1595
+ private isUnclosedBrace(offset: number, language: string): boolean {
1596
+ const index = this.bracketIndex(language);
1597
+ if (index === undefined) {
1598
+ return false;
1599
+ }
1600
+ if (index.unmatched.has(offset)) {
1601
+ return true;
1602
+ }
1603
+ const closer = index.pairs.get(offset);
1604
+ const indentAt = (at: number): number => this.getLine(this.rope.lineAt(at)).text.match(/^[\t ]*/)![0].length;
1605
+ if (closer === undefined || indentAt(closer) >= indentAt(offset)) {
1606
+ return false;
1607
+ }
1608
+ return [...index.unmatched].some((at) => at < offset && this.rope.charAt(at) === '{');
1609
+ }
1610
+
1520
1611
  /* Undefined for a document too large to scan on every Enter. */
1521
1612
  private bracketIndex(language: string): BracketIndex | undefined {
1522
1613
  if (this.rope.length > bracketScanLimit) {
@@ -1531,18 +1622,25 @@ export class DocumentModel {
1531
1622
  return index;
1532
1623
  }
1533
1624
 
1534
- private deleteCharacter(backwards: boolean, tabSize: number, pairing: { brackets: boolean; quotes: boolean }, language: string): boolean {
1625
+ private deleteCharacter(
1626
+ backwards: boolean,
1627
+ indent: { tabSize: number; unit: string },
1628
+ pairing: { brackets: boolean; quotes: boolean },
1629
+ language: string
1630
+ ): boolean {
1535
1631
  return this.deleteRanges(
1536
1632
  this.selections.map((selection) => {
1537
1633
  let { from, to } = rangeOf(selection);
1538
1634
  if (from === to) {
1539
1635
  if (!backwards) {
1540
1636
  to = deletionStep(this.rope, to, 1);
1637
+ } else if (this.arrows.has(from)) {
1638
+ from -= 2;
1541
1639
  } else if ((isQuote(this.rope.charAt(from - 1)) ? pairing.quotes : pairing.brackets) && this.isEmptyPair(from, language)) {
1542
1640
  from--;
1543
1641
  to++;
1544
1642
  } else {
1545
- from = this.backspaceStart(from, tabSize);
1643
+ return this.unindent(from, indent, language) ?? { from: this.backspaceStart(from, indent.tabSize), to, text: '' };
1546
1644
  }
1547
1645
  }
1548
1646
  return { from, to, text: '' };
@@ -1550,6 +1648,40 @@ export class DocumentModel {
1550
1648
  );
1551
1649
  }
1552
1650
 
1651
+ /*
1652
+ * Backspace with only whitespace before the caret, after the platform's smart backspace: a line indented past
1653
+ * where its brackets put it goes back to there, and one at or before it joins the line above, or takes the place
1654
+ * of that line when it is blank. Null where the brackets say nothing.
1655
+ */
1656
+ private unindent(offset: number, indent: { tabSize: number; unit: string }, language: string): TextEdit | null {
1657
+ const index = this.rope.lineAt(offset);
1658
+ const line = this.rope.lineBounds(index);
1659
+ if (!/^[\t ]*$/.test(this.slice(line.start, offset))) {
1660
+ return null;
1661
+ }
1662
+ const proper = properIndent(this.editSource(language), index, { language, unit: indent.unit });
1663
+ if (proper === null) {
1664
+ return null;
1665
+ }
1666
+ let end = offset;
1667
+ while (end < line.end && /[\t ]/.test(this.rope.charAt(end))) {
1668
+ end++;
1669
+ }
1670
+ if (indentationColumn(this.slice(line.start, end), indent.tabSize) > indentationColumn(proper, indent.tabSize)) {
1671
+ return { from: line.start, to: end, text: proper };
1672
+ }
1673
+ if (index === 0) {
1674
+ return { from: 0, to: end, text: '' };
1675
+ }
1676
+ const previous = this.getLine(index - 1);
1677
+ const start = previous.end - trailingBlankLength(previous.text);
1678
+ if (start !== previous.start || start === 0) {
1679
+ const before = this.slice(previous.start, start);
1680
+ return { from: start, to: end, text: before === '' ? '' : spacingBetween(before, this.slice(end, line.end).trimEnd()) };
1681
+ }
1682
+ return { from: start, to: end, text: proper };
1683
+ }
1684
+
1553
1685
  /* Whether the caret sits between an opener and its own closer, which backspace removes together. */
1554
1686
  private isEmptyPair(offset: number, language: string): boolean {
1555
1687
  const opener = this.rope.charAt(offset - 1);
@@ -1989,6 +2121,7 @@ export class DocumentModel {
1989
2121
  this.structureCache = undefined;
1990
2122
  this.selectionHistory = [];
1991
2123
  this.statementClosers.clear();
2124
+ this.arrows.clear();
1992
2125
  }
1993
2126
 
1994
2127
  private restore(state: State, changes: readonly (readonly DocumentChange[])[]): void {
@@ -14,8 +14,8 @@ export interface EditSource {
14
14
  newline(index: number): string;
15
15
  context(offset: number): TypingContext;
16
16
  region(line: number): VueRegion | null;
17
- /* Whether a `{` at this offset has no closer anywhere in the document. */
18
- unmatchedBrace(offset: number): boolean;
17
+ /* Whether a `{` at this offset has no closer of its own, so Enter after it adds one. */
18
+ unclosedBrace(offset: number): boolean;
19
19
  }
20
20
 
21
21
  /* Edits to apply together and the selections that stand after them. */
package/src/enter.ts CHANGED
@@ -159,7 +159,7 @@ class Enter {
159
159
  return { from, to: afterWhitespace, text: newline + inner + newline + start, caret: newline.length + inner.length };
160
160
  }
161
161
  const closing =
162
- last === '{' && source.unmatchedBrace(context.bracket!.at) ? this.closeBrace(from, afterWhitespace, line, rest, newline, start, inner) : null;
162
+ last === '{' && source.unclosedBrace(context.bracket!.at) ? this.closeBrace(from, afterWhitespace, line, rest, newline, start, inner) : null;
163
163
  return closing ?? plain(inner);
164
164
  }
165
165
 
package/src/fold-roles.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { isImportLine } from './lexical-folds.ts';
2
2
  import type { DocumentLine } from './rope.ts';
3
- import type { FoldHints, FoldRole, FoldingRange } from './structure.ts';
3
+ import type { FoldHints, FoldRangeHint, FoldRole, FoldingRange } from './structure.ts';
4
4
 
5
5
  const SCRIPT_LANGUAGES = /^(typescript|javascript|typescriptreact|javascriptreact|tsx|jsx|ts|js|mjs|cjs|mts|cts)$/i;
6
6
  /* How far back from a bracket the text before it is read. */
@@ -116,6 +116,12 @@ export function applyFoldHints(
116
116
  for (const hint of hints.ranges ?? []) {
117
117
  const startLine = lineAt(hint.from);
118
118
  const endLine = lineAt(hint.to);
119
+ const same = result.find((range) => range.startLine === startLine && range.endLine === endLine);
120
+ // The text's own fold of the same lines keeps its kind and role, and takes where the server hides from and shows again from.
121
+ if (same !== undefined) {
122
+ Object.assign(same, edgesOf(hint));
123
+ continue;
124
+ }
119
125
  if (endLine - startLine < minimum || starts.has(startLine)) {
120
126
  continue;
121
127
  }
@@ -129,12 +135,17 @@ export function applyFoldHints(
129
135
  from: first.start + indent,
130
136
  to: getLine(endLine).end,
131
137
  kind: 'server',
132
- ...(role === undefined ? {} : { role })
138
+ ...(role === undefined ? {} : { role }),
139
+ ...edgesOf(hint)
133
140
  });
134
141
  }
135
142
  return result.sort((left, right) => left.startLine - right.startLine || right.endLine - left.endLine);
136
143
  }
137
144
 
145
+ function edgesOf(hint: FoldRangeHint): Pick<FoldingRange, 'head' | 'tail'> {
146
+ return { ...(hint.head === undefined ? {} : { head: hint.head }), ...(hint.tail === undefined ? {} : { tail: hint.tail }) };
147
+ }
148
+
138
149
  /* The fold that is the body of a symbol, and what kind of body it is. */
139
150
  function bodyOf(
140
151
  ranges: FoldingRange[],
@@ -43,7 +43,7 @@ function skipWhitespace(source: EditSource, from: number): number {
43
43
  }
44
44
 
45
45
  /* What goes where two lines meet: nothing before a closer, a comma or a dot and after an opener, else a space. */
46
- function spacingBetween(previous: string, next: string): string {
46
+ export function spacingBetween(previous: string, next: string): string {
47
47
  if (next === '' || /^[)\],;]/.test(next) || /^\.(?![.\d])/.test(next) || /[([]$/.test(previous)) {
48
48
  return '';
49
49
  }
@@ -279,6 +279,15 @@ export function planAutoIndent(source: EditSource, lines: readonly number[], opt
279
279
  return edits.length === 0 ? null : { edits, selections: [] };
280
280
  }
281
281
 
282
+ /* The indentation the brackets give a line, or null where they say nothing: outside code, in markup and in a language that is not bracketed. */
283
+ export function properIndent(source: EditSource, line: number, options: LineCommandOptions): string | null {
284
+ const context = source.context(source.line(line).start);
285
+ if (!indentsByBrackets(options.language) || context.mode !== 'code' || markupGuard(source, options.language)(line)) {
286
+ return null;
287
+ }
288
+ return targetIndent(source, options, line, context, (index) => whitespaceOf(source.line(index).text));
289
+ }
290
+
282
291
  function targetIndent(source: EditSource, options: LineCommandOptions, line: number, context: TypingContext, indentOf: (line: number) => string): string {
283
292
  const text = source.line(line).text.trimStart();
284
293
  const open = frames(context);
package/src/structure.ts CHANGED
@@ -44,6 +44,9 @@ export interface FoldingRange {
44
44
  kind: 'bracket' | 'comment' | 'indentation' | 'imports' | 'line-comments' | 'region' | 'block' | 'section' | 'server';
45
45
  /* What the range is, where the text or a language server says; most ranges have none. */
46
46
  role?: FoldRole;
47
+ /* Where a collapsed fold hides from on its first line and shows again from on its last, when a server says; else whole lines. */
48
+ head?: number;
49
+ tail?: number;
47
50
  }
48
51
 
49
52
  export interface FoldingOptions {
@@ -79,6 +82,9 @@ export interface FoldRangeHint {
79
82
  to: number;
80
83
  /* The LSP kind: `comment`, `imports` or `region`. */
81
84
  kind?: string;
85
+ /* Where it hides from on its first line and shows again from on its last, from the LSP `startCharacter` and `endCharacter`. */
86
+ head?: number;
87
+ tail?: number;
82
88
  }
83
89
 
84
90
  export interface FoldHints {
package/src/types.ts CHANGED
@@ -123,6 +123,8 @@ export interface CommandOptions {
123
123
  /* A language id such as `typescript` or `php`; it decides what counts as a comment or a string. */
124
124
  language?: string;
125
125
  smartSemicolon?: boolean;
126
+ /* In PHP a `-` typed after a variable, a member or a `)` or `]` becomes `->`. On unless false. */
127
+ smartArrow?: boolean;
126
128
  /* Tab steps over a closer the editor inserted. On unless false. */
127
129
  tabOutOfClosers?: boolean;
128
130
  /* Enter computes the indentation of the new line, continues comments and closes braces. On unless false. */