@adecore/editor-core 0.17.0-beta.2 → 0.17.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;
@@ -108,6 +112,7 @@ export declare class DocumentModel {
108
112
  private editSource;
109
113
  private bracketIndex;
110
114
  private deleteCharacter;
115
+ private unindent;
111
116
  private isEmptyPair;
112
117
  private backspaceStart;
113
118
  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()) {
@@ -1275,24 +1333,60 @@ export class DocumentModel {
1275
1333
  this.bracketCache = { revision: this.revision, language, index };
1276
1334
  return index;
1277
1335
  }
1278
- deleteCharacter(backwards, tabSize, pairing, language) {
1336
+ deleteCharacter(backwards, indent, pairing, language) {
1279
1337
  return this.deleteRanges(this.selections.map((selection) => {
1280
1338
  let { from, to } = rangeOf(selection);
1281
1339
  if (from === to) {
1282
1340
  if (!backwards) {
1283
1341
  to = deletionStep(this.rope, to, 1);
1284
1342
  }
1343
+ else if (this.arrows.has(from)) {
1344
+ from -= 2;
1345
+ }
1285
1346
  else if ((isQuote(this.rope.charAt(from - 1)) ? pairing.quotes : pairing.brackets) && this.isEmptyPair(from, language)) {
1286
1347
  from--;
1287
1348
  to++;
1288
1349
  }
1289
1350
  else {
1290
- from = this.backspaceStart(from, tabSize);
1351
+ return this.unindent(from, indent, language) ?? { from: this.backspaceStart(from, indent.tabSize), to, text: '' };
1291
1352
  }
1292
1353
  }
1293
1354
  return { from, to, text: '' };
1294
1355
  }));
1295
1356
  }
1357
+ /*
1358
+ * Backspace with only whitespace before the caret, after the platform's smart backspace: a line indented past
1359
+ * where its brackets put it goes back to there, and one at or before it joins the line above, or takes the place
1360
+ * of that line when it is blank. Null where the brackets say nothing.
1361
+ */
1362
+ unindent(offset, indent, language) {
1363
+ const index = this.rope.lineAt(offset);
1364
+ const line = this.rope.lineBounds(index);
1365
+ if (!/^[\t ]*$/.test(this.slice(line.start, offset))) {
1366
+ return null;
1367
+ }
1368
+ const proper = properIndent(this.editSource(language), index, { language, unit: indent.unit });
1369
+ if (proper === null) {
1370
+ return null;
1371
+ }
1372
+ let end = offset;
1373
+ while (end < line.end && /[\t ]/.test(this.rope.charAt(end))) {
1374
+ end++;
1375
+ }
1376
+ if (indentationColumn(this.slice(line.start, end), indent.tabSize) > indentationColumn(proper, indent.tabSize)) {
1377
+ return { from: line.start, to: end, text: proper };
1378
+ }
1379
+ if (index === 0) {
1380
+ return { from: 0, to: end, text: '' };
1381
+ }
1382
+ const previous = this.getLine(index - 1);
1383
+ const start = previous.end - trailingBlankLength(previous.text);
1384
+ if (start !== previous.start || start === 0) {
1385
+ const before = this.slice(previous.start, start);
1386
+ return { from: start, to: end, text: before === '' ? '' : spacingBetween(before, this.slice(end, line.end).trimEnd()) };
1387
+ }
1388
+ return { from: start, to: end, text: proper };
1389
+ }
1296
1390
  /* Whether the caret sits between an opener and its own closer, which backspace removes together. */
1297
1391
  isEmptyPair(offset, language) {
1298
1392
  const opener = this.rope.charAt(offset - 1);
@@ -1699,6 +1793,7 @@ export class DocumentModel {
1699
1793
  this.structureCache = undefined;
1700
1794
  this.selectionHistory = [];
1701
1795
  this.statementClosers.clear();
1796
+ this.arrows.clear();
1702
1797
  }
1703
1798
  restore(state, changes) {
1704
1799
  const previous = this.rope;
@@ -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.17.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()) {
@@ -1531,18 +1602,25 @@ export class DocumentModel {
1531
1602
  return index;
1532
1603
  }
1533
1604
 
1534
- private deleteCharacter(backwards: boolean, tabSize: number, pairing: { brackets: boolean; quotes: boolean }, language: string): boolean {
1605
+ private deleteCharacter(
1606
+ backwards: boolean,
1607
+ indent: { tabSize: number; unit: string },
1608
+ pairing: { brackets: boolean; quotes: boolean },
1609
+ language: string
1610
+ ): boolean {
1535
1611
  return this.deleteRanges(
1536
1612
  this.selections.map((selection) => {
1537
1613
  let { from, to } = rangeOf(selection);
1538
1614
  if (from === to) {
1539
1615
  if (!backwards) {
1540
1616
  to = deletionStep(this.rope, to, 1);
1617
+ } else if (this.arrows.has(from)) {
1618
+ from -= 2;
1541
1619
  } else if ((isQuote(this.rope.charAt(from - 1)) ? pairing.quotes : pairing.brackets) && this.isEmptyPair(from, language)) {
1542
1620
  from--;
1543
1621
  to++;
1544
1622
  } else {
1545
- from = this.backspaceStart(from, tabSize);
1623
+ return this.unindent(from, indent, language) ?? { from: this.backspaceStart(from, indent.tabSize), to, text: '' };
1546
1624
  }
1547
1625
  }
1548
1626
  return { from, to, text: '' };
@@ -1550,6 +1628,40 @@ export class DocumentModel {
1550
1628
  );
1551
1629
  }
1552
1630
 
1631
+ /*
1632
+ * Backspace with only whitespace before the caret, after the platform's smart backspace: a line indented past
1633
+ * where its brackets put it goes back to there, and one at or before it joins the line above, or takes the place
1634
+ * of that line when it is blank. Null where the brackets say nothing.
1635
+ */
1636
+ private unindent(offset: number, indent: { tabSize: number; unit: string }, language: string): TextEdit | null {
1637
+ const index = this.rope.lineAt(offset);
1638
+ const line = this.rope.lineBounds(index);
1639
+ if (!/^[\t ]*$/.test(this.slice(line.start, offset))) {
1640
+ return null;
1641
+ }
1642
+ const proper = properIndent(this.editSource(language), index, { language, unit: indent.unit });
1643
+ if (proper === null) {
1644
+ return null;
1645
+ }
1646
+ let end = offset;
1647
+ while (end < line.end && /[\t ]/.test(this.rope.charAt(end))) {
1648
+ end++;
1649
+ }
1650
+ if (indentationColumn(this.slice(line.start, end), indent.tabSize) > indentationColumn(proper, indent.tabSize)) {
1651
+ return { from: line.start, to: end, text: proper };
1652
+ }
1653
+ if (index === 0) {
1654
+ return { from: 0, to: end, text: '' };
1655
+ }
1656
+ const previous = this.getLine(index - 1);
1657
+ const start = previous.end - trailingBlankLength(previous.text);
1658
+ if (start !== previous.start || start === 0) {
1659
+ const before = this.slice(previous.start, start);
1660
+ return { from: start, to: end, text: before === '' ? '' : spacingBetween(before, this.slice(end, line.end).trimEnd()) };
1661
+ }
1662
+ return { from: start, to: end, text: proper };
1663
+ }
1664
+
1553
1665
  /* Whether the caret sits between an opener and its own closer, which backspace removes together. */
1554
1666
  private isEmptyPair(offset: number, language: string): boolean {
1555
1667
  const opener = this.rope.charAt(offset - 1);
@@ -1989,6 +2101,7 @@ export class DocumentModel {
1989
2101
  this.structureCache = undefined;
1990
2102
  this.selectionHistory = [];
1991
2103
  this.statementClosers.clear();
2104
+ this.arrows.clear();
1992
2105
  }
1993
2106
 
1994
2107
  private restore(state: State, changes: readonly (readonly DocumentChange[])[]): void {
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. */