@adecore/editor-core 0.41.1 → 0.43.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/src/document.ts CHANGED
@@ -2,6 +2,10 @@ import { scriptSource } from './script-source.ts';
2
2
  import { CloserTracker } from './closer-tracker.ts';
3
3
  import { planBlockComment, planLineComments } from './comments.ts';
4
4
  import { planEnter } from './enter.ts';
5
+ import { planCodeBlock } from './code-blocks.ts';
6
+ import { planCompleteStatement } from './complete-statement.ts';
7
+ import { planMoveStatement, statementMoveMode } from './statement-move.ts';
8
+ import { planHippie, type HippieCycle, type HippieOptions } from './hippie.ts';
5
9
  import { planAutoIndent, planJoinLines, planToggleCase, properIndent, spacingBetween } from './line-commands.ts';
6
10
  import { mapOffset, offsetMapper } from './offsets.ts';
7
11
  import { planPaste } from './paste.ts';
@@ -37,10 +41,17 @@ import type {
37
41
  } from './types.ts';
38
42
  import { TypingContexts } from './typing-context.ts';
39
43
  import type { TypingContext } from './typing-context.ts';
44
+ import { todosInLines, type TodoMatch, type TodoOptions } from './todo.ts';
45
+ import { type LineOrder, planConvertIndents, planLineOrder } from './line-order.ts';
46
+ import { ModifiedStretches, planWhitespaceCleanup, type WhitespaceCleanupOptions } from './whitespace-cleanup.ts';
40
47
  import { isHumpBoundary, isWordBoundary } from './words.ts';
41
48
 
42
49
  export type { DocumentLine, FindMatch, FindNextOptions, FindOptions, FoldingOptions, FoldingRange };
43
50
 
51
+ const LINE_ORDERS: Partial<Record<EditorCommand, LineOrder>> = { sortLines: 'sort', reverseLines: 'reverse', uniqueLines: 'unique' };
52
+ /* Where trailing whitespace is part of a string's value, which a cleanup must not change. */
53
+ const STRING_MODES = new Set<TypingContext['mode']>(['quote', 'template', 'heredoc']);
54
+
44
55
  export interface OffsetRange {
45
56
  readonly from: number;
46
57
  readonly to: number;
@@ -156,6 +167,7 @@ const identifier = /[\p{L}\p{N}\p{M}\p{Pc}$]/u;
156
167
  /* What can follow `->`: the first letter of a member name, or the `{` of a dynamic one. */
157
168
  const arrowContinues = /^[\p{L}_{]/u;
158
169
  const wordCommand = /^(select|delete)?(word|camel)(Left|Right)$/i;
170
+ const codeBlockCommand = /^codeBlock(Start|End)(WithSelection)?$/;
159
171
 
160
172
  let graphemes: Intl.Segmenter | undefined;
161
173
 
@@ -456,6 +468,7 @@ export class DocumentModel {
456
468
  private structureCache?: StructureRange[];
457
469
  private selectionHistory: Selection[][] = [];
458
470
  private typingContexts = new TypingContexts((line) => this.getLine(line));
471
+ private readonly modified = new ModifiedStretches();
459
472
  private statementClosers = new Map<number, { text: string; language: string }>();
460
473
  /* The carets that stand right after a `->` the editor completed, where the next keystroke decides whether it stays. */
461
474
  private arrows = new Set<number>();
@@ -463,6 +476,9 @@ export class DocumentModel {
463
476
  private occurrenceSession: { wholeWord: boolean; notFound: boolean; signature: string } | undefined;
464
477
  /* The last select next occurrence found no more, which a host may say. */
465
478
  occurrencesExhausted = false;
479
+ /* Where the word the last Hippie completion took from this document stands, which a view reads right after the command to show it. */
480
+ hippieSource: { from: number; to: number } | null = null;
481
+ private hippieState: { cycle: HippieCycle; revision: number } | null = null;
466
482
  private closers = new CloserTracker();
467
483
  private bracketCache?: { revision: number; language: string; index: BracketIndex };
468
484
  private bracketTracker = new BracketTracker(() => this.rope);
@@ -814,6 +830,52 @@ export class DocumentModel {
814
830
  return options.hints === undefined ? unique : applyFoldHints(unique, options.hints, lineAt, getLine, slice, minimum);
815
831
  }
816
832
 
833
+ /* The TODO comments of the lines from `first` through `last`. It reads the lexer state the editing commands keep, so after an edit only the lines from the edit on are lexed again. */
834
+ findTodos(language: string, options: TodoOptions = {}, first = 0, last = this.getLineCount() - 1): TodoMatch[] {
835
+ const source = {
836
+ lineCount: this.getLineCount(),
837
+ line: (index: number) => this.getLine(index),
838
+ context: (line: number, column: number) => this.typingContexts.at(line, column, language)
839
+ };
840
+
841
+ return todosInLines(source, options, first, last);
842
+ }
843
+
844
+ /* Forgets which lines changed, as when the text was just written to disk. */
845
+ markSaved(): void {
846
+ this.modified.clear();
847
+ }
848
+
849
+ /* The zero-based lines changed since `markSaved`, or since the document was made, undo and redo included. */
850
+ getModifiedLines(): number[] {
851
+ return this.modified.lines(
852
+ (offset) => this.rope.lineAt(offset),
853
+ (line) => this.rope.lineBounds(line).start
854
+ );
855
+ }
856
+
857
+ /*
858
+ * Cleans up the whitespace before a save, as one undo step: the trailing whitespace of the modified or
859
+ * of all lines, except on the lines with a caret and inside a multiline string of the language, the
860
+ * blank lines at the end, and a final line break. Returns whether the text changed.
861
+ */
862
+ cleanUpWhitespace(options: WhitespaceCleanupOptions & { language?: string } = {}): boolean {
863
+ const language = options.language ?? 'plaintext';
864
+ const modified = new Set(options.trailingWhitespace === 'modified' ? this.getModifiedLines() : []);
865
+
866
+ const source = {
867
+ lineCount: this.getLineCount(),
868
+ line: (index: number) => this.getLine(index),
869
+ newline: (index: number) => this.newlineAt(index),
870
+ keeps: (line: number, column: number) => STRING_MODES.has(this.typingContexts.at(line, column, language).mode)
871
+ };
872
+
873
+ const carets = new Set(this.selections.map((selection) => this.rope.lineAt(selection.head)));
874
+ const edits = planWhitespaceCleanup(source, options, { modified: (line) => modified.has(line), carets });
875
+
876
+ return edits.length > 0 && this.applyEdits(edits, { source: 'command' });
877
+ }
878
+
817
879
  /*
818
880
  * Types `text` at every caret as one keystroke would: it wraps a selection in a bracket or quote,
819
881
  * closes an opener, types over a closer that is already there and, in TypeScript, JavaScript and
@@ -992,6 +1054,32 @@ export class DocumentModel {
992
1054
  );
993
1055
  }
994
1056
 
1057
+ if (command === 'moveStatementUp' || command === 'moveStatementDown') {
1058
+ return this.moveStatements(command === 'moveStatementUp' ? -1 : 1, { language, unit, tabSize }, options);
1059
+ }
1060
+
1061
+ if (command === 'hippieCompletion' || command === 'hippieBackwardCompletion') {
1062
+ return this.completeFromWords(command === 'hippieCompletion' ? 1 : -1, { language, wordSources: options.wordSources });
1063
+ }
1064
+
1065
+ if (command === 'completeCurrentStatement') {
1066
+ const plan = planCompleteStatement(this.editSource(language), this.selections, { language, unit, tabSize });
1067
+
1068
+ return this.applyEdits(plan.edits, { source: 'command', selections: plan.selections });
1069
+ }
1070
+
1071
+ const blockMatch = codeBlockCommand.exec(command);
1072
+
1073
+ if (blockMatch) {
1074
+ const plan = planCodeBlock(this.editSource(language), this.selections, blockMatch[1] === 'Start' ? 'start' : 'end', blockMatch[2] !== undefined, {
1075
+ tabSize,
1076
+ guides: options.indentGuides === true,
1077
+ mate: (offset) => this.matchBracket(offset, language)?.mate
1078
+ });
1079
+
1080
+ return this.changeSelections(plan);
1081
+ }
1082
+
995
1083
  const enter = { language, unit, tabSize, smart: options.smartEnter !== false, reach: true };
996
1084
 
997
1085
  if (command === 'insertNewline' || command === 'startNewLine' || command === 'splitLine') {
@@ -1006,6 +1094,20 @@ export class DocumentModel {
1006
1094
  return this.editLines(command, { language, unit });
1007
1095
  }
1008
1096
 
1097
+ const order = LINE_ORDERS[command];
1098
+
1099
+ if (order !== undefined) {
1100
+ const plan = planLineOrder(this.editSource(language), this.selections, order);
1101
+
1102
+ return plan !== null && this.applyEdits(plan.edits, { source: 'command', selections: plan.selections });
1103
+ }
1104
+
1105
+ if (command === 'convertIndentsToSpaces' || command === 'convertIndentsToTabs') {
1106
+ const edits = planConvertIndents(this.editSource(language), this.selections, tabSize, command === 'convertIndentsToSpaces');
1107
+
1108
+ return edits.length > 0 && this.applyEdits(edits, { source: 'command' });
1109
+ }
1110
+
1009
1111
  if (command === 'insertTab') {
1010
1112
  return this.insertTab(tabSize, options);
1011
1113
  }
@@ -1839,6 +1941,61 @@ export class DocumentModel {
1839
1941
  return this.applyEdits(edits, { source: 'command', selections });
1840
1942
  }
1841
1943
 
1944
+ /* Moves the statement at each caret past its neighbor; a language without brackets or indentation to go by moves lines. */
1945
+ private moveStatements(direction: -1 | 1, settings: { language: string; unit: string; tabSize: number }, options: CommandOptions): boolean {
1946
+ if (statementMoveMode(settings.language) === null) {
1947
+ return this.moveLines(this.selectedLines(options.lineSpan), direction, options);
1948
+ }
1949
+
1950
+ const structure = options.structure?.() ?? this.getFoldingRanges({ language: settings.language, tabSize: settings.tabSize });
1951
+ const plan = planMoveStatement(this.editSource(settings.language), this.selections, direction, { ...settings, structure, lineSpan: options.lineSpan });
1952
+
1953
+ if (plan === null) {
1954
+ return false;
1955
+ }
1956
+
1957
+ const changed = this.applyEdits(plan.edits, { source: 'command', selections: plan.selections });
1958
+
1959
+ if (changed) {
1960
+ options.onLinesMoved?.(plan.moves);
1961
+ }
1962
+
1963
+ return changed;
1964
+ }
1965
+
1966
+ /* Hippie completion: the word before every caret becomes the next longer word that starts the same, from this document and then from the others. */
1967
+ private completeFromWords(direction: -1 | 1, options: HippieOptions): boolean {
1968
+ const carets = this.selections.map((selection) => selection.head).sort((a, b) => a - b);
1969
+ const primary = this.selections.at(-1)!.head;
1970
+ const held = this.hippieState;
1971
+ const continues =
1972
+ held !== null &&
1973
+ held.revision === this.revision &&
1974
+ held.cycle.carets.length === carets.length &&
1975
+ held.cycle.carets.every((caret, i) => caret === carets[i]);
1976
+ const plan = planHippie(this.getText(), carets, primary, continues ? held.cycle : null, direction, options);
1977
+
1978
+ this.hippieState = null;
1979
+ this.hippieSource = null;
1980
+
1981
+ if (plan === null) {
1982
+ return false;
1983
+ }
1984
+
1985
+ const primaryIndex = carets.indexOf(primary);
1986
+ const landed = plan.carets.map((caret) => ({ anchor: caret, head: caret }));
1987
+ const [primaryCaret] = landed.splice(primaryIndex, 1);
1988
+
1989
+ this.applyEdits(plan.edits, { source: 'command', selections: [...landed, primaryCaret!] });
1990
+
1991
+ if (plan.cycle !== null) {
1992
+ this.hippieState = { cycle: plan.cycle, revision: this.revision };
1993
+ this.hippieSource = plan.source;
1994
+ }
1995
+
1996
+ return true;
1997
+ }
1998
+
1842
1999
  private moveLines(indices: readonly number[], direction: -1 | 1, options: CommandOptions): boolean {
1843
2000
  const span: LineSpanOf = options.lineSpan ?? ((line) => ({ first: line, last: line }));
1844
2001
  const groups = blocks(indices).filter((block) => (direction === -1 ? block.first > 0 : block.last < this.getLineCount() - 1));
@@ -2738,6 +2895,10 @@ export class DocumentModel {
2738
2895
  const snapshot = this.getSnapshot();
2739
2896
 
2740
2897
  if (changes) {
2898
+ for (const transaction of changes) {
2899
+ this.modified.record(transaction);
2900
+ }
2901
+
2741
2902
  snapshot.changes = changes.map((transaction) => transaction.map((change) => ({ ...change })));
2742
2903
  snapshot.source = source;
2743
2904
 
package/src/hippie.ts ADDED
@@ -0,0 +1,290 @@
1
+ import { isBraced, isWordCharacter } from './lexical.ts';
2
+ import type { TextEdit } from './types.ts';
3
+
4
+ /* Texts of other documents longer than this are not read for words. */
5
+ const MAX_SOURCE_LENGTH = 2_000_000;
6
+
7
+ const LETTER_OR_DIGIT = /[\p{L}\p{N}]/u;
8
+ const LETTER = /\p{L}/u;
9
+
10
+ export interface HippieOptions {
11
+ language: string;
12
+ /* The texts of the other open documents, in the order their words are offered. */
13
+ wordSources?: () => readonly string[];
14
+ }
15
+
16
+ interface Word {
17
+ text: string;
18
+ start: number;
19
+ end: number;
20
+ }
21
+
22
+ /* A word the cycle can offer. `side` tells which side of the caret a word of this document stands on. */
23
+ interface Variant extends Word {
24
+ source: 'here' | 'other';
25
+ side: 'before' | 'after';
26
+ }
27
+
28
+ /* What the last press left, so the next one goes on from it. */
29
+ export interface HippieCycle {
30
+ /* What stood at the caret before the first press, which every press of the cycle completes. */
31
+ prefix: string;
32
+ /* The length of the text each caret holds from the last press, which the next press replaces. */
33
+ length: number;
34
+ /* The carets the last press left. */
35
+ carets: readonly number[];
36
+ proposal: Pick<Variant, 'text' | 'source' | 'side'>;
37
+ }
38
+
39
+ export interface HippiePlan {
40
+ edits: TextEdit[];
41
+ carets: number[];
42
+ /* Null when the cycle is over and the next press starts again. */
43
+ cycle: HippieCycle | null;
44
+ /* Where the proposed word stands in this document after the edit, when it came from here. */
45
+ source: { from: number; to: number } | null;
46
+ }
47
+
48
+ interface Position {
49
+ source: 'here' | 'other';
50
+ index: number;
51
+ }
52
+
53
+ function isWordPart(character: string, hyphenated: boolean): boolean {
54
+ return isWordCharacter(character) || (hyphenated && (character === '-' || character === '*'));
55
+ }
56
+
57
+ /* The words of a text in order. A word has a letter or a digit in it. */
58
+ export function hippieWords(text: string, hyphenated: boolean): Word[] {
59
+ const words: Word[] = [];
60
+ let start = -1;
61
+
62
+ for (let i = 0; i <= text.length; i++) {
63
+ const inside = i < text.length && isWordPart(text[i]!, hyphenated);
64
+
65
+ if (inside && start < 0) {
66
+ start = i;
67
+ } else if (!inside && start >= 0) {
68
+ const word = text.slice(start, i);
69
+
70
+ if (LETTER_OR_DIGIT.test(word)) {
71
+ words.push({ text: word, start, end: i });
72
+ }
73
+
74
+ start = -1;
75
+ }
76
+ }
77
+
78
+ return words;
79
+ }
80
+
81
+ /* Where the word before the caret starts, which is the caret itself when it is not in a word. */
82
+ export function hippiePrefixStart(text: string, caret: number, hyphenated: boolean): number {
83
+ let start = caret;
84
+
85
+ while (start > 0 && isWordPart(text[start - 1]!, hyphenated)) {
86
+ start--;
87
+ }
88
+
89
+ return LETTER_OR_DIGIT.test(text.slice(start, caret)) ? start : caret;
90
+ }
91
+
92
+ /* A letter that starts a camel hump (an uppercase after a lowercase) or follows a `_`, a `-` or a digit. */
93
+ function isHump(word: string, index: number): boolean {
94
+ const character = word[index]!;
95
+ const before = word[index - 1]!;
96
+
97
+ if (!LETTER.test(character)) {
98
+ return false;
99
+ }
100
+
101
+ const startsHump = character !== character.toLowerCase() && before !== before.toUpperCase();
102
+
103
+ return startsHump || /[\d_-]/.test(before);
104
+ }
105
+
106
+ function continues(word: string, prefix: string, wordIndex: number, prefixIndex: number): boolean {
107
+ if (prefixIndex === prefix.length) {
108
+ return true;
109
+ }
110
+
111
+ for (let i = wordIndex; i < word.length; i++) {
112
+ // The character right after the last match continues it; any other has to start a hump.
113
+ if (i > wordIndex && !isHump(word, i)) {
114
+ continue;
115
+ }
116
+
117
+ if (word[i]!.toLowerCase() === prefix[prefixIndex]!.toLowerCase() && continues(word, prefix, i + 1, prefixIndex + 1)) {
118
+ return true;
119
+ }
120
+ }
121
+
122
+ return false;
123
+ }
124
+
125
+ /* Whether a word is longer than the prefix and starts with it: the first character as typed, the rest by letters of any case or camel humps. */
126
+ export function hippieMatches(word: string, prefix: string): boolean {
127
+ if (word.length <= prefix.length) {
128
+ return false;
129
+ }
130
+
131
+ return prefix === '' || (word[0] === prefix[0] && continues(word, prefix, 1, 1));
132
+ }
133
+
134
+ /* The words of this document that complete the prefix: those before the primary caret ascending, then those after it. */
135
+ function variantsHere(text: string, carets: readonly number[], primary: number, prefix: string, hyphenated: boolean): Variant[] {
136
+ const before = new Map<string, Variant>();
137
+ const after = new Map<string, Variant>();
138
+
139
+ const words = hippieWords(text, hyphenated).filter(
140
+ (word) => !carets.some((caret) => word.start <= caret && caret <= word.end) && hippieMatches(word.text, prefix)
141
+ );
142
+
143
+ // The nearest occurrence of a repeated word stands: walk back from the caret before it, forward from it after.
144
+ for (const word of words.filter((entry) => entry.end <= primary).reverse()) {
145
+ if (!before.has(word.text)) {
146
+ before.set(word.text, { ...word, source: 'here', side: 'before' });
147
+ }
148
+ }
149
+
150
+ for (const word of words) {
151
+ if (word.end > primary && !after.has(word.text)) {
152
+ after.set(word.text, { ...word, source: 'here', side: 'after' });
153
+ }
154
+ }
155
+
156
+ return [...before.values()].reverse().concat([...after.values()]);
157
+ }
158
+
159
+ function variantsOther(sources: readonly string[], prefix: string, hyphenated: boolean): Variant[] {
160
+ const seen = new Map<string, Variant>();
161
+
162
+ for (const source of sources) {
163
+ if (source.length > MAX_SOURCE_LENGTH) {
164
+ continue;
165
+ }
166
+
167
+ for (const word of hippieWords(source, hyphenated)) {
168
+ if (!seen.has(word.text) && hippieMatches(word.text, prefix)) {
169
+ seen.set(word.text, { ...word, source: 'other', side: 'after' });
170
+ }
171
+ }
172
+ }
173
+
174
+ return [...seen.values()];
175
+ }
176
+
177
+ /* The first proposal going up is the nearest word above the caret, going down the nearest below it. */
178
+ function homeOf(here: readonly Variant[], direction: 1 | -1): number {
179
+ const firstAfter = here.findIndex((variant) => variant.side === 'after');
180
+
181
+ if (direction === 1) {
182
+ return Math.max((firstAfter < 0 ? here.length : firstAfter) - 1, 0);
183
+ }
184
+
185
+ return firstAfter < 0 ? 0 : firstAfter;
186
+ }
187
+
188
+ /* Where a source runs out the cycle goes to the other documents, and from there back home. */
189
+ function enter(here: readonly Variant[], other: readonly Variant[], direction: 1 | -1, from: 'here' | 'other'): Position | null {
190
+ const otherStart: Position = { source: 'other', index: direction === 1 ? other.length - 1 : 0 };
191
+
192
+ if (from === 'here' && other.length > 0) {
193
+ return otherStart;
194
+ }
195
+
196
+ if (here.length > 0) {
197
+ return { source: 'here', index: homeOf(here, direction) };
198
+ }
199
+
200
+ return other.length > 0 ? otherStart : null;
201
+ }
202
+
203
+ function step(here: readonly Variant[], other: readonly Variant[], current: Position | null, direction: 1 | -1): Position | null {
204
+ if (current === null) {
205
+ return enter(here, other, direction, 'other');
206
+ }
207
+
208
+ const list = current.source === 'here' ? here : other;
209
+ const index = current.index + (direction === 1 ? -1 : 1);
210
+
211
+ return index >= 0 && index < list.length ? { source: current.source, index } : enter(here, other, direction, current.source);
212
+ }
213
+
214
+ /* Where a word stands once the edits are made, or null when an edit reaches into it. */
215
+ function sourceAfter(word: Word, edits: readonly TextEdit[]): { from: number; to: number } | null {
216
+ let delta = 0;
217
+
218
+ for (const edit of edits) {
219
+ if (edit.to <= word.start) {
220
+ delta += edit.text.length - (edit.to - edit.from);
221
+ } else if (edit.from < word.end) {
222
+ return null;
223
+ }
224
+ }
225
+
226
+ return { from: word.start + delta, to: word.end + delta };
227
+ }
228
+
229
+ /*
230
+ * The next proposal of Hippie completion: the word before each caret is replaced by a longer word that starts the
231
+ * same, the nearest in this document first, and the following presses go through the others and then through the
232
+ * other open documents. `cycle` is what the last press left; pass it only while nothing has moved since.
233
+ * `carets` is ascending and `primary` is one of them.
234
+ */
235
+ export function planHippie(
236
+ text: string,
237
+ carets: readonly number[],
238
+ primary: number,
239
+ cycle: HippieCycle | null,
240
+ direction: 1 | -1,
241
+ options: HippieOptions
242
+ ): HippiePlan | null {
243
+ const hyphenated = !isBraced(options.language);
244
+ const prefix = cycle?.prefix ?? text.slice(hippiePrefixStart(text, primary, hyphenated), primary);
245
+ const here = variantsHere(text, carets, primary, prefix, hyphenated);
246
+ const other = variantsOther(options.wordSources?.() ?? [], prefix, hyphenated);
247
+ let current: Position | null = null;
248
+
249
+ if (cycle !== null) {
250
+ const index = (cycle.proposal.source === 'here' ? here : other).findIndex(
251
+ (variant) => variant.text === cycle.proposal.text && variant.side === cycle.proposal.side
252
+ );
253
+
254
+ current = index < 0 ? null : { source: cycle.proposal.source, index };
255
+ }
256
+
257
+ const next = step(here, other, current, direction);
258
+ const variant = next === null ? null : (next.source === 'here' ? here : other)[next.index]!;
259
+
260
+ if (variant === null && cycle === null) {
261
+ return null;
262
+ }
263
+
264
+ // With nothing left to offer, a cycle that is under way gives the prefix back.
265
+ const proposal = variant?.text ?? prefix;
266
+ const length = cycle?.length ?? prefix.length;
267
+ const edits: TextEdit[] = [];
268
+ const landed: number[] = [];
269
+ let end = 0;
270
+ let delta = 0;
271
+
272
+ for (const caret of carets) {
273
+ const from = Math.max(caret - length, end);
274
+
275
+ edits.push({ from, to: caret, text: proposal });
276
+ landed.push(from + delta + proposal.length);
277
+ delta += proposal.length - (caret - from);
278
+ end = caret;
279
+ }
280
+
281
+ return {
282
+ edits,
283
+ carets: landed,
284
+ cycle:
285
+ variant === null
286
+ ? null
287
+ : { prefix, length: proposal.length, carets: landed, proposal: { text: variant.text, source: variant.source, side: variant.side } },
288
+ source: variant?.source === 'here' ? sourceAfter(variant, edits) : null
289
+ };
290
+ }
package/src/index.ts CHANGED
@@ -4,6 +4,9 @@ export { scanBrackets, type BracketIndex, type BracketMatch } from './brackets.t
4
4
  export { findMatches, iterateMatches, findMatchFrom, replacementText, type FindMatch, type FindNextOptions, type FindOptions } from './search.ts';
5
5
  export { indentationColumn } from './structure.ts';
6
6
  export { findDocComments, MAX_DOC_COMMENTS, type DocComment } from './doc-comments.ts';
7
+ export { DEFAULT_TODO_WORDS, findTodos, type TodoMatch, type TodoOptions } from './todo.ts';
8
+ export { detectIndentation, type DetectedIndentation } from './line-order.ts';
9
+ export type { TrailingWhitespace, WhitespaceCleanupOptions } from './whitespace-cleanup.ts';
7
10
  export type { FoldHints, FoldRangeHint, FoldRole, FoldSymbolHint, FoldingOptions, FoldingRange } from './structure.ts';
8
11
 
9
12
  export type {
package/src/lexical.ts CHANGED
@@ -130,3 +130,12 @@ export function startsExpressionAt(text: string, from: number, to: number): bool
130
130
  export function startsExpressionAfter(character: string): boolean {
131
131
  return expressionPunctuation.has(character);
132
132
  }
133
+
134
+ /* The character that ends a statement in a language, or null where a line break does. */
135
+ export function statementTerminator(language: string): ';' | null {
136
+ return /^(typescript|javascript|typescriptreact|javascriptreact|tsx|jsx|ts|js|mjs|cjs|mts|cts|php|java|c|cpp|c\+\+|csharp|cs|rust|rs|dart|zig|glsl|hlsl|scala)$/i.test(
137
+ language
138
+ )
139
+ ? ';'
140
+ : null;
141
+ }