@adecore/editor-core 0.22.0 → 0.23.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/dist/enter.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { editRegions } from './edit-regions.js';
1
2
  import { commentSyntax } from './languages.js';
2
3
  import { hasHashComments, isBraced, isPhp, openers } from './lexical.js';
3
4
  import { whitespaceOf } from './line-text.js';
@@ -27,13 +28,14 @@ function escapeForPattern(text) {
27
28
  * `if` or `for`, after `else`, `=>` or a binary operator. Languages with braces only.
28
29
  */
29
30
  export function continues(text, context, language) {
30
- if (!isBraced(language) || context.mode !== 'code') {
31
+ const effectiveLanguage = context.embedded ?? language;
32
+ if (!isBraced(effectiveLanguage) || context.mode !== 'code') {
31
33
  return false;
32
34
  }
33
35
  if (context.word === 'control-close' || context.word === 'else' || context.word === 'do') {
34
36
  return true;
35
37
  }
36
- if (isPhp(language) && /(?<!\.)\.$/.test(text)) {
38
+ if (isPhp(effectiveLanguage) && /(?<!\.)\.$/.test(text)) {
37
39
  return true;
38
40
  }
39
41
  return trailingOperator.test(text);
@@ -113,6 +115,16 @@ class Enter {
113
115
  const base = context.mode === 'code' ? this.baseIndent(index) : leading;
114
116
  const start = leading.length < before.length ? base : leading;
115
117
  const plain = (indent) => ({ from, to: afterWhitespace, text: newline + indent, caret: newline.length + indent.length });
118
+ if (context.mode === 'code' || (isPhp(language) && context.mode === 'html')) {
119
+ const tag = this.openTag(trimmed, index);
120
+ if (tag) {
121
+ const inner = start + unit;
122
+ if (new RegExp(`^</${escapeForPattern(tag)}\\s*>`, 'i').test(source.slice(afterWhitespace, line.end))) {
123
+ return { from, to: afterWhitespace, text: newline + inner + newline + start, caret: newline.length + inner.length };
124
+ }
125
+ return plain(inner);
126
+ }
127
+ }
116
128
  if (context.mode !== 'code') {
117
129
  return plain(leading);
118
130
  }
@@ -125,14 +137,6 @@ class Enter {
125
137
  const closing = last === '{' && source.unclosedBrace(context.bracket.at) ? this.closeBrace(from, afterWhitespace, line, rest, newline, start, inner) : null;
126
138
  return closing ?? plain(inner);
127
139
  }
128
- const tag = this.openTag(trimmed, index);
129
- if (tag) {
130
- const inner = start + unit;
131
- if (new RegExp(`^</${escapeForPattern(tag)}\\s*>`, 'i').test(source.slice(afterWhitespace, line.end))) {
132
- return { from, to: afterWhitespace, text: newline + inner + newline + start, caret: newline.length + inner.length };
133
- }
134
- return plain(inner);
135
- }
136
140
  if (this.opensIndentedBlock(trimmed, context)) {
137
141
  return plain(this.blockIndent(line.text, start) + unit);
138
142
  }
@@ -159,7 +163,7 @@ class Enter {
159
163
  /* The name of the tag the text ends in, when it is an open tag with children to come. */
160
164
  openTag(trimmed, index) {
161
165
  const { language } = this.options;
162
- if (!markupLanguages.test(language)) {
166
+ if (!markupLanguages.test(language) && !isPhp(language)) {
163
167
  return null;
164
168
  }
165
169
  if (/^vue$/i.test(language) && this.source.region(index) === 'script') {
@@ -169,6 +173,14 @@ class Enter {
169
173
  if (!match || trimmed.endsWith('/>') || (voidElements.test(match[1]) && !/^xml$/i.test(language))) {
170
174
  return null;
171
175
  }
176
+ if (isPhp(language)) {
177
+ const offset = this.source.line(index).start + trimmed.indexOf('<', match.index);
178
+ // Only earlier tags establish this opener's region; its content can be script or style.
179
+ const regions = editRegions(this.source, language);
180
+ if (regions(index, offset) !== 'template') {
181
+ return null;
182
+ }
183
+ }
172
184
  return match[1];
173
185
  }
174
186
  /* Python's `def f():`, a YAML `key:` and a `case x:`. */
@@ -232,7 +244,7 @@ class Enter {
232
244
  }
233
245
  inString(from, context, index) {
234
246
  const { source, options } = this;
235
- const operator = concatenation.get(options.language.toLowerCase());
247
+ const operator = concatenation.get(context.embedded ?? options.language.toLowerCase());
236
248
  const quote = context.quote;
237
249
  if (operator === undefined || (quote !== '"' && quote !== "'")) {
238
250
  return null;
package/dist/index.d.ts CHANGED
@@ -2,8 +2,9 @@ export { DocumentModel } from './document.ts';
2
2
  export type { DocumentEditOptions, DocumentLine, OffsetRange, ReplaceAllOptions, ReplaceOptions, TypeTextOptions } from './document.ts';
3
3
  export { scanBrackets, type BracketIndex } from './brackets.ts';
4
4
  export { replaceWithCaseRespect } from './preserve-case.ts';
5
- export { findMatches, replacementText, type FindMatch, type FindNextOptions, type FindOptions } from './search.ts';
5
+ export { findMatches, iterateMatches, findMatchFrom, replacementText, type FindMatch, type FindNextOptions, type FindOptions } from './search.ts';
6
6
  export { indentationColumn } from './structure.ts';
7
+ export { findDocComments, MAX_DOC_COMMENTS, type DocComment } from './doc-comments.ts';
7
8
  export type { FoldHints, FoldRangeHint, FoldRole, FoldSymbolHint, FoldingOptions, FoldingRange } from './structure.ts';
8
9
  export { isHumpBoundary, isWordBoundary, wordBoundary, type WordText } from './words.ts';
9
10
  export type { ChangeSource, CommandOptions, ContentEdit, Disposable, DocumentChange, EditOptions, EditorCommand, EditorSnapshot, Position, Selection, TextEdit } from './types.ts';
package/dist/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  export { DocumentModel } from './document.js';
2
2
  export { scanBrackets } from './brackets.js';
3
3
  export { replaceWithCaseRespect } from './preserve-case.js';
4
- export { findMatches, replacementText } from './search.js';
4
+ export { findMatches, iterateMatches, findMatchFrom, replacementText } from './search.js';
5
5
  export { indentationColumn } from './structure.js';
6
+ export { findDocComments, MAX_DOC_COMMENTS } from './doc-comments.js';
6
7
  export { isHumpBoundary, isWordBoundary, wordBoundary } from './words.js';
7
8
  export { changedSpan, changedSpans } from './text-span.js';
@@ -7,6 +7,7 @@ export interface CommentSyntax {
7
7
  block: BlockComment | null;
8
8
  }
9
9
  export type VueRegion = 'template' | 'script' | 'style';
10
- export declare function commentSyntax(language: string, region?: VueRegion | null): CommentSyntax;
10
+ export type EditRegion = VueRegion | 'jsx' | 'mixed';
11
+ export declare function commentSyntax(language: string, region?: EditRegion | null): CommentSyntax;
11
12
  export declare function isPlainText(language: string): boolean;
12
13
  export declare function vueRegionAt(lineText: (line: number) => string, line: number): VueRegion | null;
package/dist/languages.js CHANGED
@@ -22,7 +22,13 @@ register('plaintext text txt log', none);
22
22
  /* An id nothing knows has no comment syntax, which toggling a comment answers by doing nothing. */
23
23
  export function commentSyntax(language, region) {
24
24
  const id = language.toLowerCase();
25
- if (id === 'vue') {
25
+ if (region === 'mixed') {
26
+ return none;
27
+ }
28
+ if (region === 'jsx') {
29
+ return { line: null, block: { open: '{/*', close: '*/}' } };
30
+ }
31
+ if (id === 'vue' || (id === 'php' && region !== null && region !== undefined)) {
26
32
  if (region === 'script') {
27
33
  return cLike;
28
34
  }
@@ -3,6 +3,7 @@ export declare function jsxRanges(text: string): {
3
3
  from: number;
4
4
  to: number;
5
5
  }[];
6
+ export declare function jsxChildren(text: string): (offset: number) => boolean;
6
7
  export declare function hasJsx(language: string): boolean;
7
8
  export declare function isJsxAttributeValue(text: string, offset: number): boolean;
8
9
  export declare function markupGuard(source: EditSource, language: string): (line: number) => boolean;
@@ -12,13 +12,16 @@ const CLOSERS = { '{': '}', '(': ')', '[': ']' };
12
12
  */
13
13
  class JsxScanner {
14
14
  ranges = [];
15
+ children = [];
16
+ collectChildren;
15
17
  /* The quoted values of attributes, quotes included. */
16
18
  values = [];
17
19
  text;
18
20
  depth = 0;
19
21
  budget;
20
- constructor(text) {
22
+ constructor(text, collectChildren = false) {
21
23
  this.text = text;
24
+ this.collectChildren = collectChildren;
22
25
  this.budget = text.length * 12 + 1_000;
23
26
  }
24
27
  scan() {
@@ -174,14 +177,34 @@ class JsxScanner {
174
177
  }
175
178
  /* An element from its `<`, recorded when no element holds it, and the offset after its closing tag; -1 when it is not one. */
176
179
  element(from, outermost) {
180
+ const children = this.children.length;
181
+ const previousEnd = this.children.at(-1)?.to;
177
182
  this.depth++;
178
183
  const end = this.parseElement(from);
179
184
  this.depth--;
185
+ if (end < 0) {
186
+ this.children.length = children;
187
+ if (previousEnd !== undefined) {
188
+ this.children[children - 1].to = previousEnd;
189
+ }
190
+ }
180
191
  if (end >= 0 && outermost && this.depth === 0) {
181
192
  this.ranges.push({ from, to: end });
182
193
  }
183
194
  return end;
184
195
  }
196
+ child(from, to) {
197
+ if (!this.collectChildren || from >= to) {
198
+ return;
199
+ }
200
+ const previous = this.children.at(-1);
201
+ if (previous !== undefined && previous.to === from) {
202
+ previous.to = to;
203
+ }
204
+ else {
205
+ this.children.push({ from, to });
206
+ }
207
+ }
185
208
  skipSpace(from) {
186
209
  let at = from;
187
210
  while (at < this.text.length && /\s/.test(this.text[at])) {
@@ -202,6 +225,22 @@ class JsxScanner {
202
225
  if (char === undefined || --this.budget < 0) {
203
226
  return -1;
204
227
  }
228
+ if (char === '/' && text[at + 1] === '/') {
229
+ const end = text.indexOf('\n', at + 2);
230
+ if (end < 0) {
231
+ return -1;
232
+ }
233
+ at = end + 1;
234
+ continue;
235
+ }
236
+ if (char === '/' && text[at + 1] === '*') {
237
+ const end = text.indexOf('*/', at + 2);
238
+ if (end < 0) {
239
+ return -1;
240
+ }
241
+ at = end + 2;
242
+ continue;
243
+ }
205
244
  if (char === '/' && text[at + 1] === '>') {
206
245
  return at + 2;
207
246
  }
@@ -250,14 +289,21 @@ class JsxScanner {
250
289
  }
251
290
  }
252
291
  for (;;) {
292
+ const start = at;
253
293
  while (at < text.length && text[at] !== '<' && text[at] !== '{') {
254
294
  at++;
255
295
  }
296
+ this.child(start, text[at] === '<' ? at + 1 : at);
256
297
  if (at >= text.length || --this.budget < 0) {
257
298
  return -1;
258
299
  }
259
300
  if (text[at] === '{') {
301
+ this.child(at, at + 1);
302
+ const from = at;
260
303
  at = this.code(at + 1, '}');
304
+ if (at >= 0 && /^\{\s*\/\*/.test(text.slice(from, from + 20))) {
305
+ this.child(from + 1, at);
306
+ }
261
307
  }
262
308
  else if (text[at + 1] === '/') {
263
309
  const closing = /^<\/\s*([\w$.:-]*)\s*>/.exec(text.slice(at, at + 200));
@@ -300,6 +346,12 @@ function insideAny(ranges, offset) {
300
346
  }
301
347
  return false;
302
348
  }
349
+ /* Child text and nested tags use JSX comments; attributes and embedded expressions stay code. */
350
+ export function jsxChildren(text) {
351
+ const scanner = new JsxScanner(text, true);
352
+ scanner.scan();
353
+ return (offset) => insideAny(scanner.children, offset);
354
+ }
303
355
  /* Whether a language is one whose scripts can hold JSX. */
304
356
  export function hasJsx(language) {
305
357
  return JSX_LANGUAGES.test(language);
package/dist/offsets.d.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  import type { TextEdit } from './types.ts';
2
2
  export declare function mapOffset(offset: number, edits: readonly TextEdit[], stayBefore?: boolean): number;
3
+ export declare function offsetMapper(edits: readonly TextEdit[]): (offset: number, stayBefore?: boolean) => number;
package/dist/offsets.js CHANGED
@@ -12,3 +12,26 @@ export function mapOffset(offset, edits, stayBefore = false) {
12
12
  }
13
13
  return offset + delta;
14
14
  }
15
+ /* Reuse this index across selections while retaining both endpoint affinities. */
16
+ export function offsetMapper(edits) {
17
+ const deltas = new Float64Array(edits.length + 1);
18
+ for (let index = 0; index < edits.length; index++) {
19
+ const edit = edits[index];
20
+ deltas[index + 1] = deltas[index] + edit.text.length - (edit.to - edit.from);
21
+ }
22
+ return (offset, stayBefore = false) => {
23
+ let low = 0, high = edits.length;
24
+ while (low < high) {
25
+ const middle = (low + high) >>> 1;
26
+ const edit = edits[middle];
27
+ if (edit.to < offset || (edit.to === offset && !(stayBefore && edit.from === edit.to))) {
28
+ low = middle + 1;
29
+ }
30
+ else {
31
+ high = middle;
32
+ }
33
+ }
34
+ const edit = edits[low];
35
+ return edit !== undefined && edit.from <= offset && offset < edit.to ? edit.from + deltas[low] + edit.text.length : offset + deltas[low];
36
+ };
37
+ }
@@ -0,0 +1,7 @@
1
+ import type { TypingContext } from './typing-context.ts';
2
+ interface ScriptLexer {
3
+ initial(): TypingContext;
4
+ step(state: TypingContext, text: string, at: number, end: number, offset: number): number;
5
+ }
6
+ export declare function scanPhpMarkup(state: TypingContext, text: string, from: number, end: number, offset: number, script: ScriptLexer): number;
7
+ export {};
@@ -0,0 +1,99 @@
1
+ const TAG = /<(\/?)([a-z][\w:-]*)(?=[\s/>]|$)/iy;
2
+ const PHP = /<\?(?:php\b|=)/y;
3
+ function finishTag(state, selfClosing, script, start) {
4
+ state.markup = !selfClosing && state.element !== undefined ? 'raw' : undefined;
5
+ state.markupQuote = undefined;
6
+ state.embed = state.markup === 'raw' && state.element === 'script' ? script.initial() : undefined;
7
+ if (state.embed !== undefined) {
8
+ state.embed.embedStart = start;
9
+ }
10
+ if (selfClosing) {
11
+ state.element = undefined;
12
+ }
13
+ }
14
+ export function scanPhpMarkup(state, text, from, end, offset, script) {
15
+ for (let at = from; at < end; at++) {
16
+ const char = text[at];
17
+ if (char === '<' && text[at + 1] === '?') {
18
+ PHP.lastIndex = at;
19
+ const open = PHP.exec(text);
20
+ if (open !== null) {
21
+ if (state.markup === 'equals') {
22
+ state.markup = 'value';
23
+ }
24
+ state.mode = 'code';
25
+ state.expression = true;
26
+ state.word = '';
27
+ return at + open[0].length - 1;
28
+ }
29
+ }
30
+ if (state.markup === 'comment') {
31
+ if (text.startsWith('-->', at) && at + 3 <= end) {
32
+ state.markup = undefined;
33
+ at += 2;
34
+ }
35
+ continue;
36
+ }
37
+ if (state.markup === 'value' && state.markupQuote !== undefined) {
38
+ if (char === state.markupQuote) {
39
+ state.markup = 'tag';
40
+ state.markupQuote = undefined;
41
+ }
42
+ continue;
43
+ }
44
+ if (char === '<') {
45
+ if (state.markup !== 'raw' && text.startsWith('<!--', at)) {
46
+ // HTML also closes these two comments immediately, without a later -->.
47
+ const empty = text.startsWith('<!-->', at) ? 5 : text.startsWith('<!--->', at) ? 6 : 0;
48
+ state.markup = empty > 0 && at + empty <= end ? undefined : 'comment';
49
+ state.element = undefined;
50
+ at += (empty || 4) - 1;
51
+ continue;
52
+ }
53
+ TAG.lastIndex = at;
54
+ const tag = TAG.exec(text);
55
+ const name = tag?.[2]?.toLowerCase();
56
+ if (state.markup === 'raw' && (tag?.[1] !== '/' || name !== state.element)) {
57
+ if (state.embed !== undefined) {
58
+ at = script.step(state.embed, text, at, end, offset);
59
+ }
60
+ continue;
61
+ }
62
+ state.embed = undefined;
63
+ state.element = tag?.[1] !== '/' && (name === 'script' || name === 'style' || name === 'textarea' || name === 'title') ? name : undefined;
64
+ state.markup = tag === null ? undefined : 'tag';
65
+ state.markupQuote = undefined;
66
+ if (tag !== null) {
67
+ at += tag[0].length - 1;
68
+ }
69
+ continue;
70
+ }
71
+ if (state.embed !== undefined) {
72
+ at = script.step(state.embed, text, at, end, offset);
73
+ continue;
74
+ }
75
+ if (state.markup === undefined || state.markup === 'raw') {
76
+ continue;
77
+ }
78
+ if (char === '>') {
79
+ finishTag(state, false, script, offset + at + 1);
80
+ }
81
+ else if (char === '/' && text[at + 1] === '>' && at + 2 <= end) {
82
+ finishTag(state, true, script, offset + at + 2);
83
+ at++;
84
+ }
85
+ else if (/\s/u.test(char)) {
86
+ if (state.markup === 'value') {
87
+ state.markup = 'tag';
88
+ }
89
+ }
90
+ else if (state.markup === 'equals') {
91
+ state.markup = 'value';
92
+ state.markupQuote = char === '"' || char === "'" ? char : undefined;
93
+ }
94
+ else if (state.markup === 'tag' && char === '=') {
95
+ state.markup = 'equals';
96
+ }
97
+ }
98
+ return end - 1;
99
+ }
package/dist/rope.d.ts CHANGED
@@ -4,6 +4,7 @@ export interface DocumentLine {
4
4
  next: number;
5
5
  text: string;
6
6
  }
7
+ export declare const ROPE_CHUNK_SIZE = 2048;
7
8
  export declare function clampInteger(value: number, maximum: number): number;
8
9
  export declare class TextRope {
9
10
  private readonly root;
@@ -14,6 +15,11 @@ export declare class TextRope {
14
15
  charAt(offset: number): string;
15
16
  slice(from?: number, to?: number): string;
16
17
  replace(from: number, to: number, text: string): TextRope;
18
+ rebuild(edits: readonly {
19
+ from: number;
20
+ to: number;
21
+ text: string;
22
+ }[]): TextRope;
17
23
  equalsRange(other: TextRope, from: number, to: number): boolean;
18
24
  lineAt(offset: number): number;
19
25
  lineBounds(index: number): Omit<DocumentLine, 'text'>;
package/dist/rope.js CHANGED
@@ -1,4 +1,4 @@
1
- const chunkSize = 2048;
1
+ export const ROPE_CHUNK_SIZE = 2048;
2
2
  function leaf(text) {
3
3
  if (!text.length) {
4
4
  return null;
@@ -36,7 +36,7 @@ function join(left, right) {
36
36
  if (!right) {
37
37
  return left;
38
38
  }
39
- if ('text' in left && 'text' in right && left.length + right.length <= chunkSize) {
39
+ if ('text' in left && 'text' in right && left.length + right.length <= ROPE_CHUNK_SIZE) {
40
40
  return leaf(left.text + right.text);
41
41
  }
42
42
  if (left.height > right.height + 1 && 'left' in left) {
@@ -66,8 +66,8 @@ function split(node, offset) {
66
66
  }
67
67
  function build(text) {
68
68
  const leaves = [];
69
- for (let offset = 0; offset < text.length; offset += chunkSize) {
70
- leaves.push(leaf(text.slice(offset, offset + chunkSize)));
69
+ for (let offset = 0; offset < text.length; offset += ROPE_CHUNK_SIZE) {
70
+ leaves.push(leaf(text.slice(offset, offset + ROPE_CHUNK_SIZE)));
71
71
  }
72
72
  const tree = (from, to) => {
73
73
  if (from === to) {
@@ -156,6 +156,35 @@ export class TextRope {
156
156
  const [, after] = split(rest, to - from);
157
157
  return new TextRope(join(join(before, build(text)), after));
158
158
  }
159
+ /* Dense validated batches rebuild once, rather than repeatedly splitting and rescanning leaves. */
160
+ rebuild(edits) {
161
+ const chunks = chunksOf(this.root, 0, this.length);
162
+ let chunk = chunks.next();
163
+ let column = 0;
164
+ let offset = 0;
165
+ const pieces = [];
166
+ const advance = (end, keep) => {
167
+ while (offset < end && !chunk.done) {
168
+ const count = Math.min(end - offset, chunk.value.length - column);
169
+ if (keep) {
170
+ pieces.push(chunk.value.slice(column, column + count));
171
+ }
172
+ offset += count;
173
+ column += count;
174
+ if (column === chunk.value.length) {
175
+ chunk = chunks.next();
176
+ column = 0;
177
+ }
178
+ }
179
+ };
180
+ for (const edit of edits) {
181
+ advance(edit.from, true);
182
+ pieces.push(edit.text);
183
+ advance(edit.to, false);
184
+ }
185
+ advance(this.length, true);
186
+ return TextRope.from(pieces.join(''));
187
+ }
159
188
  /* Whether both ropes hold the same text in `[from, to)`, which has to be the same range of both. */
160
189
  equalsRange(other, from, to) {
161
190
  if (this.root === other.root) {
@@ -0,0 +1,2 @@
1
+ import type { TypingContext } from './typing-context.ts';
2
+ export declare function scriptSource(text: string, start: number, complete: boolean, context: (offset: number) => TypingContext | null): string | undefined;
@@ -0,0 +1,44 @@
1
+ export function scriptSource(text, start, complete, context) {
2
+ const events = /<\?(?:php\b|=)|\?>|<\/script(?=[\s/>]|$)/gi;
3
+ const chunks = [];
4
+ let cursor = 0;
5
+ let island;
6
+ const finish = (end) => {
7
+ chunks.push(text.slice(cursor, island ?? end));
8
+ if (island !== undefined) {
9
+ chunks.push(' '.repeat(end - island));
10
+ }
11
+ return chunks.join('');
12
+ };
13
+ for (const match of text.matchAll(events)) {
14
+ const state = context(start + match.index);
15
+ if (state === null) {
16
+ return undefined;
17
+ }
18
+ const inScript = state.embedded === 'javascript' && state.embedStart === start;
19
+ if (/^<\?(?:php\b|=)/.test(match[0])) {
20
+ if (inScript) {
21
+ island = match.index;
22
+ }
23
+ }
24
+ else if (match[0] === '?>') {
25
+ if (island !== undefined && state.embed?.embedStart === start && (state.mode === 'code' || state.mode === 'line-comment')) {
26
+ let end = match.index + 2;
27
+ if (text.startsWith('\r\n', end)) {
28
+ end += 2;
29
+ }
30
+ else if (text[end] === '\n') {
31
+ end++;
32
+ }
33
+ // Spaces preserve document offsets without treating PHP source newlines as script text.
34
+ chunks.push(text.slice(cursor, island), ' '.repeat(end - island));
35
+ cursor = end;
36
+ island = undefined;
37
+ }
38
+ }
39
+ else if (match[0].startsWith('</') && inScript) {
40
+ return finish(match.index);
41
+ }
42
+ }
43
+ return complete ? finish(text.length) : undefined;
44
+ }
package/dist/search.d.ts CHANGED
@@ -23,4 +23,6 @@ export declare function splitsSurrogate(text: {
23
23
  charAt(offset: number): string;
24
24
  }, offset: number): boolean;
25
25
  export declare function findMatches(text: string, query: string, revision: number, options?: FindOptions): FindMatch[];
26
- export declare function replacementText(text: string, match: FindMatch, replacement: string, literal?: boolean, preserveCase?: boolean): string;
26
+ export declare function iterateMatches(text: string, query: string, revision: number, options?: FindOptions): Generator<FindMatch, void, unknown>;
27
+ export declare function findMatchFrom(text: string, query: string, revision: number, offset: number, options?: FindNextOptions): FindMatch | null;
28
+ export declare function replacementText(text: string, match: FindMatch, replacement: string, literal?: boolean, preserveCase?: boolean, maxCharacters?: number): string;
package/dist/search.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { clampInteger } from './rope.js';
1
2
  import { replaceWithCaseRespect } from './preserve-case.js';
2
3
  const wordCharacter = /[\p{L}\p{N}\p{M}\p{Pc}$]/u;
3
4
  /* Whether `offset` sits between the two halves of a surrogate pair. */
@@ -9,8 +10,13 @@ function characterBefore(text, offset) {
9
10
  }
10
11
  /* Invalid bounds throw `RangeError`, an invalid expression `SyntaxError`. A zero-width match advances by one code point. */
11
12
  export function findMatches(text, query, revision, options = {}) {
13
+ return Array.from(iterateMatches(text, query, revision, options));
14
+ }
15
+ /* Validation and regex execution start when the iterator advances. */
16
+ export function* iterateMatches(text, query, revision, options = {}) {
17
+ options = { ...options };
12
18
  if (!query && !options.regex) {
13
- return [];
19
+ return;
14
20
  }
15
21
  const from = options.from ?? 0;
16
22
  const to = options.to ?? text.length;
@@ -24,8 +30,8 @@ export function findMatches(text, query, revision, options = {}) {
24
30
  const source = options.regex ? query : query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
25
31
  const expression = new RegExp(source, `gmu${options.caseSensitive ? '' : 'i'}`);
26
32
  expression.lastIndex = from;
27
- const matches = [];
28
- while (matches.length < maximum) {
33
+ let count = 0;
34
+ while (count < maximum) {
29
35
  const match = expression.exec(text);
30
36
  if (!match || match.index > to) {
31
37
  break;
@@ -34,7 +40,8 @@ export function findMatches(text, query, revision, options = {}) {
34
40
  const wholeWord = !options.wholeWord ||
35
41
  (!wordCharacter.test(characterBefore(text, match.index)) && !wordCharacter.test(String.fromCodePoint(text.codePointAt(end) ?? 0)));
36
42
  if (match.index >= from && end <= to && !splitsSurrogate(text, match.index) && !splitsSurrogate(text, end) && wholeWord) {
37
- matches.push({
43
+ count++;
44
+ yield {
38
45
  from: match.index,
39
46
  to: end,
40
47
  text: match[0],
@@ -42,21 +49,62 @@ export function findMatches(text, query, revision, options = {}) {
42
49
  groups: match.groups ? { ...match.groups } : undefined,
43
50
  regex: options.regex === true,
44
51
  revision
45
- });
52
+ };
46
53
  }
47
54
  if (!match[0].length) {
48
55
  expression.lastIndex = match.index + ((text.codePointAt(match.index) ?? 0) > 0xffff ? 2 : 1);
49
56
  }
50
57
  }
51
- return matches;
58
+ }
59
+ /* Keeps the original match grid; starting RegExp at the caret would create overlapping matches. */
60
+ export function findMatchFrom(text, query, revision, offset, options = {}) {
61
+ const position = clampInteger(offset, text.length);
62
+ let first = null;
63
+ let last = null;
64
+ let previous = null;
65
+ for (const match of iterateMatches(text, query, revision, options)) {
66
+ first ??= match;
67
+ last = match;
68
+ if (!options.backwards && match.from >= position) {
69
+ return match;
70
+ }
71
+ if (options.backwards) {
72
+ if (match.to <= position) {
73
+ previous = match;
74
+ }
75
+ else if (previous !== null || options.wrap === false) {
76
+ return previous;
77
+ }
78
+ }
79
+ }
80
+ return options.backwards ? (previous ?? (options.wrap === false ? null : last)) : options.wrap === false ? null : first;
52
81
  }
53
82
  /* Expands the JavaScript `$` patterns of a regex replacement, and gives it the case of the match when asked. Literal replacements are returned as they are. */
54
- export function replacementText(text, match, replacement, literal = false, preserveCase = false) {
55
- const expanded = literal || !match.regex ? replacement : expandPatterns(text, match, replacement);
56
- return preserveCase ? replaceWithCaseRespect(expanded, match.text) : expanded;
83
+ export function replacementText(text, match, replacement, literal = false, preserveCase = false, maxCharacters = Infinity) {
84
+ if (maxCharacters !== Infinity && (!Number.isSafeInteger(maxCharacters) || maxCharacters < 0)) {
85
+ throw new RangeError('The replacement character limit must be nonnegative.');
86
+ }
87
+ const expanded = literal || !match.regex ? replacement : expandPatterns(text, match, replacement, maxCharacters);
88
+ if (expanded.length > maxCharacters) {
89
+ throw new RangeError('Replacement output exceeds its character limit.');
90
+ }
91
+ const result = preserveCase ? replaceWithCaseRespect(expanded, match.text) : expanded;
92
+ if (result.length > maxCharacters) {
93
+ throw new RangeError('Replacement output exceeds its character limit.');
94
+ }
95
+ return result;
57
96
  }
58
- function expandPatterns(text, match, replacement) {
59
- return replacement.replace(/\$(\$|&|`|'|\d{1,2}|<[^>]*>)/g, (token, group) => {
97
+ function expandPatterns(text, match, replacement, maxCharacters) {
98
+ const pieces = [];
99
+ let characters = 0, cursor = 0;
100
+ const append = (value) => {
101
+ characters += value.length;
102
+ if (characters > maxCharacters) {
103
+ throw new RangeError('Replacement output exceeds its character limit.');
104
+ }
105
+ pieces.push(value);
106
+ };
107
+ const expand = (token, group) => {
60
108
  if (group === '$') {
61
109
  return '$';
62
110
  }
@@ -81,5 +129,12 @@ function expandPatterns(text, match, replacement) {
81
129
  return (match.captures[first - 1] ?? '') + group[1];
82
130
  }
83
131
  return token;
84
- });
132
+ };
133
+ for (const found of replacement.matchAll(/\$(\$|&|`|'|\d{1,2}|<[^>]*>)/g)) {
134
+ append(replacement.slice(cursor, found.index));
135
+ append(expand(found[0], found[1]));
136
+ cursor = found.index + found[0].length;
137
+ }
138
+ append(replacement.slice(cursor));
139
+ return pieces.join('');
85
140
  }