@adecore/editor-core 0.17.0-beta.1 → 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.
package/LICENSE CHANGED
@@ -6,7 +6,7 @@ FSL-1.1-MIT
6
6
 
7
7
  ## Notice
8
8
 
9
- Copyright 2026 Bas Milius
9
+ Copyright 2026 - present Bas Milius
10
10
 
11
11
  ## Terms and Conditions
12
12
 
package/README.md CHANGED
@@ -1,54 +1,40 @@
1
1
  # @adecore/editor-core
2
2
 
3
- Read the [editor-core handbook](https://adecore.dev/editor-core/handbook/) for the complete setup, concepts, host integration and testing guides.
3
+ [![npm](https://img.shields.io/npm/v/@adecore/editor-core)](https://www.npmjs.com/package/@adecore/editor-core)
4
+ [![Docs](https://img.shields.io/badge/docs-adecore.dev-blue)](https://adecore.dev/editor-core/)
4
5
 
5
- The document model and editing logic of the smart editor, with no DOM and no dependencies. It runs in the browser, Bun and Node. The view that draws it and the language client that feeds it are separate packages (`@adecore/editor`, `@adecore/lsp`).
6
+ The document model of a code editor, with no DOM and no dependencies. `DocumentModel` holds the text, the selections, an undo history and the editing commands: typing with bracket pairing, smart Enter, comment toggles, line moves, multiple carets, search and folding. It runs in a browser, Bun and Node. [`@adecore/editor`](https://adecore.dev/editor/) draws it on screen.
6
7
 
7
- `DocumentModel` holds the text as a persistent rope, the selections, a bounded undo history and the editing commands. Offsets are UTF-16 code units, lines and columns zero-based. A batch of edits is simultaneous and in the coordinates of the text before it.
8
+ ## Install
8
9
 
9
- ## API
10
+ ```sh
11
+ bun add @adecore/editor-core
12
+ ```
10
13
 
11
- Everything comes from the package root.
14
+ ## Use
12
15
 
13
- - `DocumentModel`: `getText`, `getLine`, `slice`, `getSelections`, `getPrimary`, `getSnapshot`, `subscribe`, `applyEdits`, `setText`, `undo`, `redo`, `positionAt`, `offsetAt`, `find`, `findNext`, `replace`, `replaceAll`, `getFoldingRanges`, `typeText`, `paste`, `execute`, and `wordSelectionAt`, `wordStartBefore` and `wordEndAfter` for the mouse.
14
- - The selections are in the order they were added, and the last is the primary caret; a merge that takes it in keeps it last.
15
- - `applyEdits` takes `expectedRevision` and refuses the batch if the text moved on. A revision only goes up.
16
- - `typeText` is one keystroke on every caret. It pairs brackets and quotes (each with its own option, and not in plain text), pairs a bracket only when the text after it has no closer for it, pairs a quote only when no identifier character follows, types over a closer, wraps a selection in a bracket, `<` or quote and swaps the quotes of a string when one of them is selected, sends a closing bracket alone on its line back to the indentation of its opener, leaves a Python docstring opener at three quotes and moves a `;` to the end of a call. It reads the language id (`typescript`, `php`, `python`, and so on) to tell code from comments and strings.
17
- - `paste` writes the document's own line breaks, takes one line for each caret when the counts match, puts text copied from a bare caret (`wholeLines`) above the line of each bare caret, and moves a pasted block to the indentation of the line it lands on, only when it lands in code and not in a string, a template literal, a comment or a heredoc.
18
- - `execute` runs an `EditorCommand`: word and camel-hump movement, smart Home and End, backspace, Enter and its variants (start a line below or above, split a line), Tab, join lines, toggle case, auto-indent, line duplicate, delete and move, line and block comment toggles, indent, multiple carets (add above and below, which takes them back when pressed the other way, and one per selected line), selection expansion and the occurrence commands (next, unselect and all).
19
- - A word move stops at the end of a word, then at the start of the next line, and on an empty line, going back at the start of a word and then the end of the line above; deleting by word stops at both edges of a word. Camel humps are off unless `camelCase` is true. Home and End take `visualLine`, so a wrapped row is an edge of its own, and Home inside the indentation goes to the start of the line.
20
- - `lineSpan` in the command options names the lines that stand on one row of the screen, which a host with folds fills in: a collapsed fold is then one line to delete, duplicate, comment and move (it swaps with the whole row it passes, and `onLinesMoved` tells the host where each line went), one word stop that never lands inside it, and its lines are skipped by add caret above and below.
21
- - Select next occurrence selects the word at the caret, then whole words with the same case; from a selection it finds the text inside other words too. The end of the text is said (`occurrencesExhausted`) and the press after it starts again.
22
- - `getFoldingRanges` also reads, with a language, runs of import lines, runs of line comments and `region` markers (`lexicalFolds`), and the whole-line blocks of Markdown (front matter, code fences, tables, the section under a heading) and PHP (heredocs and nowdocs, `<?php ... ?>` blocks between markup) in `blockFolds`.
23
- - A range may carry a `role`, which is what the settings fold by when a file opens: the file header, a documentation comment, imports, a region, an object or array literal in a script and a PHP attribute list are read off the text (`fold-roles.ts`). The body of a function, a method or a class, and the tag a server's range starts with, come from `hints` in the options, which a host fills from a language server's symbols and folding ranges as offsets. A symbol names the bracket pair that closes where it ends, or the indented block that does; a range the text has no fold for becomes a `server` fold, one per line it starts on.
24
- - `replace` and `replaceAll` can give the replacement the case of the match (`preserveCase`).
25
- - Enter works from the lexer's state at the caret. It continues a line comment when text follows the caret (with the marker the comment starts with, so a PHP `#` and a Python `##` go on as they are), closes and continues `/*` and `/**` comments with the stars lined up under the opener's first star (a `/** text */` that closes on its line gives the closer a line of its own), splits a string literal with the language's concatenation (not in the value of a JSX attribute or the path of an import, `require` or `export ... from`, where it breaks the line as it is), closes a brace nothing closes, indents after an opener, `def f():` (the code before a trailing comment decides, as if the comment were not there), a YAML `key:`, a `case` label, an open tag and a statement left unfinished, and replaces the whitespace after the caret by the indentation it computes.
26
- - Comment toggles use a table of markers per language (`commentSyntax`), with the region of a Vue file picked by the caret's line. Line comments go in at the smallest indentation of the lines, and a lone caret moves down a line. A language with block comments only wraps each line.
27
- - Backspace at the start of a line also takes the whitespace that trails the line above. Tab inserts up to the next tab stop, which for several carets on a line counts what the carets before it added, or steps over a closer the editor added.
28
- - `scanBrackets` pairs brackets lexically, for code that does not parse yet.
29
- - `changedSpan` is the one stretch that differs between two texts and `changedSpans` the stretches of a bounded line diff (Myers on interned lines, then trimmed to the characters), which `applyEdits` takes as one batch. A diff past 4 million steps of work, 1,000 edited lines or 16 MiB between the first and last change is the one span. On 100,000 lines (6 MB) with three changes it takes about 20 ms.
30
- - `findMatches` and `replacementText` are the search the model uses.
31
- - `isWordBoundary`, `isHumpBoundary` and `wordBoundary` are the word predicates and the navigation on top of them.
32
- - Types for selections, edits, changes, snapshots, commands and options.
16
+ ```ts
17
+ import { DocumentModel } from '@adecore/editor-core';
33
18
 
34
- ## Port scope and provenance
19
+ const document = new DocumentModel('const greeting = "hello";\n');
35
20
 
36
- This package comes from a proof of concept editor that ports IntelliJ's editing behavior. Only one thing is ported code: the character-level word-boundary predicates in `src/words.ts` (`isWordBoundary`, `isHumpBound`, `isLowerCaseOrDigit` and `isPunctuation`). Their upstream is `platform/platform-impl/src/com/intellij/openapi/editor/actions/EditorActionUtil.java` in JetBrains/intellij-community at `7184b03af6c5370e79a01ded0df68cf56d7669da`, lines 934 through 982, under Apache 2.0. The file keeps the copyright header, and `NOTICE` and `LICENSE.apache-2.0.txt` come with the package.
21
+ document.setSelections([{ anchor: 26, head: 26 }]);
22
+ document.typeText('(', { language: 'typescript' }); // the closer comes along
23
+ document.undo();
24
+ ```
37
25
 
38
- The branch order, the start and end asymmetry, and the underscore, dollar and acronym rules are kept. Java's character tests are written as Unicode properties plus Java's whitespace and control ranges. Like the upstream char overloads, they look at UTF-16 code units, and a newer Unicode version in the runtime can classify a new character differently than Java does.
26
+ Offsets are UTF-16 code units, lines and columns zero-based. A batch of edits is simultaneous, in the coordinates of the text before it.
39
27
 
40
- The rest is written here: the rope, transactions and history, the commands, search, folding and the lexical scanners. Bracket matching and delimiter typing were checked against IntelliJ's documented behavior and its `TypedParenImpl` and `TypedQuoteImpl` handlers, which depend on PSI and were not copied. Enter, comment toggling, join lines, paste and the handlers for typing over a selection follow the behavior of the same editor's handlers, with the lexer standing in for the syntax tree and no formatter; none of their code is used. Navigation avoids surrogate pairs and CRLF. IntelliJ's token filters, quoted-token policies and visual-line rules are not part of this.
28
+ ## Documentation
41
29
 
42
- ## Known limits
30
+ | Page | What it covers |
31
+ |---|---|
32
+ | [Overview](https://adecore.dev/editor-core/) | Coordinates, limits and provenance |
33
+ | [Documents and edits](https://adecore.dev/editor-core/documents) | Text, selections, edit batches, revisions, history and change events |
34
+ | [Typing and commands](https://adecore.dev/editor-core/commands) | `typeText`, `paste`, `execute` and their options |
35
+ | [Search and folding](https://adecore.dev/editor-core/search-folding) | Find and replace, folding ranges and roles |
36
+ | [API reference](https://adecore.dev/editor-core/api) | The helpers and types next to the model |
43
37
 
44
- - The scanners are lexical, not parsers. A `/` after an ambiguous construct can read as a regex or a division, and JSX text, Python triple quotes and HTML or XML tag pairs are not modeled. PHP is: the markup around its tags and heredoc and nowdoc bodies are lexer modes, so nothing in them pairs, indents or reads as code. `scanStructure`, behind folding and selection expansion, does not know regex literals or template interpolation.
45
- - Language is a string id (the Shiki ones) with positive lists for the lexer: which languages have `//`, `#` (anywhere, or only at the start of a word in shell, YAML and Dockerfile), `--`, `/* */` and `<!-- -->` comments, which have braces, and a table of comment markers. An unknown id has no comments and no braces, so nothing in it reads as a comment. Indentation is worked out from brackets, keywords and the end of the previous line, not by a formatter, so Auto-indent Lines leaves languages without braces alone. It also leaves markup alone: only lines the lexer calls code are re-indented, never the lines of JSX elements (a scanner finds them, `markup-regions.ts`), a Vue template or anything outside its script and style blocks, the markup and heredocs of a PHP file, and the bodies of template literals, multi-line strings and comments.
46
- - Smart semicolons only cross a trailing chain of `)` and `]`, and only in TypeScript, JavaScript and PHP.
47
- - Search, folding and selection expansion read the whole document on request, synchronously. A regex has no timeout, so run untrusted patterns in a worker.
48
- - Word navigation reads what it crosses: a single 4 MiB word takes well over half a second. History keeps the last 200 steps. The rope has no size cap and was measured up to 25 MiB (`bun run benchmark`).
49
- - Backspace and delete by grapheme need `Intl.Segmenter`.
50
- - Columns are UTF-16, not graphemes or visual columns, and tab stops only matter for indentation commands and indentation folds.
38
+ ## License
51
39
 
52
- ## Packaging
53
-
54
- This unpublished package is private at version `0.0.0`. Its sources retain FSL-1.1-MIT except the explicitly attributed Apache-2.0 word predicates; `LICENSE`, `NOTICE` and `LICENSE.apache-2.0.txt` are included in the package. Source consumers enable `source` in their bundler and TypeScript `customConditions`. Default exports use compiled JavaScript and declarations. Run `bun run build`, `bun run typecheck`, `bun run test`, or the separate `bun run benchmark` from the package after workspace installation.
40
+ FSL-1.1-MIT, except the word-boundary predicates in `src/words.ts`, which are adapted from the IntelliJ Platform (`EditorActionUtil.java` of JetBrains/intellij-community at `7184b03af6c5370e79a01ded0df68cf56d7669da`, lines 934 through 982) under Apache 2.0. `NOTICE` and `LICENSE.apache-2.0.txt` ship with the package.
@@ -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.1",
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. */