@adecore/editor-core 0.22.1 → 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/comments.js CHANGED
@@ -1,9 +1,11 @@
1
+ import { editRegions, phpLiteralAt, phpTagAt } from './edit-regions.js';
2
+ import { hasJsx } from './markup-regions.js';
1
3
  import { commentSyntax } from './languages.js';
2
4
  import { indentText, isBlank, rangeOf, whitespaceOf } from './line-text.js';
3
5
  import { mapOffset } from './offsets.js';
4
6
  import { indentationColumn } from './structure.js';
5
- function syntaxOf(source, options, line) {
6
- const syntax = commentSyntax(options.language, source.region(line));
7
+ function syntaxOf(regions, options, line, offset) {
8
+ const syntax = commentSyntax(options.language, regions(line, offset));
7
9
  return options.lineToken === undefined ? syntax : { ...syntax, line: options.lineToken };
8
10
  }
9
11
  /* The runs of adjacent lines the selections touch. A selection that ends at the start of a line does not touch it. */
@@ -62,19 +64,42 @@ function geometryOf(source, edits, index) {
62
64
  * The markers go in at the smallest indentation of the lines, and a lone caret moves to the next line.
63
65
  */
64
66
  export function planLineComments(source, selections, options) {
67
+ const regions = editRegions(source, options.language);
65
68
  const blocks = blocksOf(source, selections, options.lineSpan);
66
- const syntaxes = blocks.map((block) => syntaxOf(source, options, block.first));
67
- if (syntaxes.some((syntax) => syntax.line === null && syntax.block === null)) {
68
- return null;
69
+ const lineBased = new Map();
70
+ for (const block of blocks) {
71
+ let marker;
72
+ let mixed = false;
73
+ const firstSyntax = hasJsx(options.language) && block.first !== block.last ? syntaxOf(regions, options, block.first) : undefined;
74
+ for (let line = block.first; line <= block.last; line++) {
75
+ const syntax = firstSyntax ?? syntaxOf(regions, options, line);
76
+ const effective = syntax.line === null ? syntax : { ...syntax, block: null };
77
+ lineBased.set(line, effective);
78
+ if (effective.line !== null || effective.block !== null) {
79
+ mixed ||=
80
+ marker !== undefined &&
81
+ (marker.line !== effective.line || marker.block?.open !== effective.block?.open || marker.block?.close !== effective.block?.close);
82
+ marker ??= effective;
83
+ }
84
+ }
85
+ // Commenting a region opener would change the markers needed for the next toggle.
86
+ if (mixed && /^(php|vue)$/i.test(options.language)) {
87
+ for (let line = block.first; line <= block.last; line++) {
88
+ lineBased.set(line, { line: null, block: null });
89
+ }
90
+ }
69
91
  }
70
- const lineBased = syntaxes.map((syntax) => (syntax.line === null ? syntax : { ...syntax, block: null }));
71
92
  let allCommented = true;
72
93
  let anyCommented = false;
73
- for (const [position, block] of blocks.entries()) {
94
+ for (const block of blocks) {
74
95
  const single = block.first === block.last;
75
96
  for (let line = block.first; line <= block.last; line++) {
76
97
  const text = source.line(line).text;
77
- const commented = isCommented(text, lineBased[position], !single);
98
+ const syntax = lineBased.get(line);
99
+ if (syntax.line === null && syntax.block === null) {
100
+ continue;
101
+ }
102
+ const commented = isCommented(text, syntax, !single);
78
103
  if (commented && !isBlank(text)) {
79
104
  anyCommented = true;
80
105
  }
@@ -88,8 +113,8 @@ export function planLineComments(source, selections, options) {
88
113
  }
89
114
  const edits = [];
90
115
  const placed = new Set();
91
- for (const [position, block] of blocks.entries()) {
92
- const syntax = lineBased[position];
116
+ for (const block of blocks) {
117
+ const syntax = lineBased.get(block.first);
93
118
  const multiline = block.first !== block.last;
94
119
  const startingNewLine = !multiline &&
95
120
  block.carets.length === 1 &&
@@ -98,6 +123,10 @@ export function planLineComments(source, selections, options) {
98
123
  const minimum = allCommented ? 0 : minimumIndent(source, block, syntax, options);
99
124
  for (let line = block.first; line <= block.last; line++) {
100
125
  const bounds = source.line(line);
126
+ const syntax = lineBased.get(line);
127
+ if (syntax.line === null && syntax.block === null) {
128
+ continue;
129
+ }
101
130
  const edit = allCommented
102
131
  ? uncommentLine(bounds.start, bounds.text, syntax)
103
132
  : commentLine(bounds.start, bounds.text, syntax, minimum, multiline, startingNewLine, options);
@@ -201,13 +230,17 @@ function finalSelections(source, selections, blocks, edits, allCommented, placed
201
230
  if (!moveCarets) {
202
231
  return result;
203
232
  }
204
- for (const [position, block] of blocks.entries()) {
233
+ for (const block of blocks) {
205
234
  for (const index of block.carets) {
206
235
  const selection = selections[index];
207
236
  const { from, to } = rangeOf(selection);
208
237
  const line = source.lineAt(from);
209
238
  if (from === to) {
210
- result[index] = caretAfter(source, sorted, line, span?.(line).last ?? line, selection.head, allCommented, placed, syntaxes[position]);
239
+ const syntax = syntaxes.get(line);
240
+ if (syntax.line === null && syntax.block === null) {
241
+ continue;
242
+ }
243
+ result[index] = caretAfter(source, sorted, line, span?.(line).last ?? line, selection.head, allCommented, placed, syntax);
211
244
  continue;
212
245
  }
213
246
  const lastLine = source.lineAt(to - 1);
@@ -241,7 +274,7 @@ function commentAround(source, offset, block) {
241
274
  if ((context.mode !== 'block-comment' && context.mode !== 'html-comment') || context.commentStart === undefined) {
242
275
  return null;
243
276
  }
244
- const from = context.commentStart;
277
+ const from = block.open === '{/*' && source.charAt(context.commentStart - 1) === '{' ? context.commentStart - 1 : context.commentStart;
245
278
  if (source.slice(from, from + block.open.length) !== block.open) {
246
279
  return null;
247
280
  }
@@ -264,12 +297,32 @@ function commentOf(source, from, to, block) {
264
297
  * With nothing selected it opens an empty comment around the caret.
265
298
  */
266
299
  export function planBlockComment(source, selections, options) {
300
+ const regions = editRegions(source, options.language);
267
301
  const edits = [];
268
302
  const result = [];
269
303
  let offsetDelta = 0;
270
304
  for (const selection of selections) {
271
305
  const { from, to } = rangeOf(selection);
272
- const block = syntaxOf(source, options, source.lineAt(from)).block;
306
+ if (/^php$/i.test(options.language)) {
307
+ const context = source.context(from);
308
+ const { mode } = context;
309
+ const endContext = source.context(to);
310
+ const endMode = endContext.mode;
311
+ const endsLineComment = mode === 'code' && endMode === 'line-comment' && source.slice(to, source.line(source.lineAt(to)).end).trim() === '';
312
+ const text = source.slice(from, to);
313
+ // Wrapping a PHP tag can move a closing marker into a different language.
314
+ if (phpLiteralAt(source, from) ||
315
+ phpLiteralAt(source, to) ||
316
+ phpTagAt(source, from) ||
317
+ phpTagAt(source, to) ||
318
+ (mode !== endMode && !endsLineComment) ||
319
+ context.embedStart !== endContext.embedStart ||
320
+ /<\?(?:php\b|=)/.test(text) ||
321
+ (mode !== 'html' && context.embedded === undefined && text.includes('?>'))) {
322
+ return null;
323
+ }
324
+ }
325
+ const block = syntaxOf(regions, options, source.lineAt(from), from).block;
273
326
  if (!block) {
274
327
  return null;
275
328
  }
@@ -0,0 +1,10 @@
1
+ export interface DocComment {
2
+ from: number;
3
+ to: number;
4
+ startLine: number;
5
+ endLine: number;
6
+ indent: string;
7
+ body: string;
8
+ }
9
+ export declare const MAX_DOC_COMMENTS = 500;
10
+ export declare function findDocComments(text: string, language: string, limit?: number): DocComment[];
@@ -0,0 +1,85 @@
1
+ import { commentRole } from './fold-roles.js';
2
+ import { commentSyntax, vueRegionAt } from './languages.js';
3
+ import { scanStructure } from './structure.js';
4
+ /* Past this many comments in one document the rest are left as text. */
5
+ export const MAX_DOC_COMMENTS = 500;
6
+ function bodyOf(inner) {
7
+ const lines = inner.split(/\r\n|\r|\n/).map((line, index) => (index === 0 ? line.replace(/^[ \t]+/, '') : line.replace(/^[ \t]*\*?[ \t]?/, '')));
8
+ const last = lines.length - 1;
9
+ lines[last] = lines[last].replace(/[ \t*]+$/, '');
10
+ while (lines.length > 0 && lines[0].trim() === '') {
11
+ lines.shift();
12
+ }
13
+ while (lines.length > 0 && lines.at(-1).trim() === '') {
14
+ lines.pop();
15
+ }
16
+ return lines.map((line) => line.replace(/[ \t]+$/, '')).join('\n');
17
+ }
18
+ const HEREDOC_OPENER = /<<<[ \t]*(?:'(\w+)'|"(\w+)"|(\w+))\r?\n/g;
19
+ /* The bodies of PHP heredocs and nowdocs, which the structure scan reads as code. Offsets of the first character after the opener and of the closing label. */
20
+ function heredocBodies(text) {
21
+ const result = [];
22
+ for (const opener of text.matchAll(HEREDOC_OPENER)) {
23
+ const label = opener[1] ?? opener[2] ?? opener[3];
24
+ const from = opener.index + opener[0].length;
25
+ const closer = new RegExp(`^[ \\t]*${label}\\b`, 'm').exec(text.slice(from));
26
+ result.push([from, closer === null ? text.length : from + closer.index]);
27
+ }
28
+ return result;
29
+ }
30
+ /*
31
+ * The block comments that open with two stars and have their lines to themselves: nothing but
32
+ * whitespace before the opener and after the closer. Only languages that write block comments as
33
+ * a slash and a star have any, and a Vue file has them in its script. A comment that shares a line
34
+ * with code is left out, since a row could not take its lines without hiding the code. A banner of
35
+ * three stars or more is no documentation, and nor is the text of a PHP heredoc or nowdoc. A heredoc
36
+ * body is still read as code by the scan, so a quote in it can hide the comments that follow.
37
+ */
38
+ export function findDocComments(text, language, limit = MAX_DOC_COMMENTS) {
39
+ const vue = language.toLowerCase() === 'vue';
40
+ const syntax = commentSyntax(language, vue ? 'script' : null);
41
+ if (syntax.block?.open !== '/*' || syntax.line !== '//' || !text.includes('/**')) {
42
+ return [];
43
+ }
44
+ const result = [];
45
+ const bodies = language.toLowerCase() === 'php' && text.includes('<<<') ? heredocBodies(text) : [];
46
+ const lines = vue ? text.split(/\r\n|\r|\n/) : null;
47
+ let counted = 0;
48
+ let line = 0;
49
+ for (const range of scanStructure(text)) {
50
+ if (result.length >= limit) {
51
+ break;
52
+ }
53
+ if (range.kind !== 'comment' || commentRole((from, to) => text.slice(from, to), range.from, range.to) === undefined) {
54
+ continue;
55
+ }
56
+ // Three stars or more is a banner, not a comment about the code under it.
57
+ if (text[range.from + 3] === '*' || bodies.some(([from, to]) => range.from >= from && range.from < to)) {
58
+ continue;
59
+ }
60
+ const lineStart = Math.max(text.lastIndexOf('\n', range.from - 1), text.lastIndexOf('\r', range.from - 1)) + 1;
61
+ const newline = text.slice(range.to).search(/[\r\n]/);
62
+ const lineEnd = newline === -1 ? text.length : range.to + newline;
63
+ const before = text.slice(lineStart, range.from);
64
+ if (!/^[ \t]*$/.test(before) || !/^[ \t]*$/.test(text.slice(range.to, lineEnd))) {
65
+ continue;
66
+ }
67
+ for (; counted < range.from; counted++) {
68
+ if (text[counted] === '\n' || (text[counted] === '\r' && text[counted + 1] !== '\n')) {
69
+ line++;
70
+ }
71
+ }
72
+ const startLine = line;
73
+ if (lines !== null && vueRegionAt((index) => lines[index] ?? '', startLine) !== 'script') {
74
+ continue;
75
+ }
76
+ let endLine = startLine;
77
+ for (let at = range.from; at < range.to; at++) {
78
+ if (text[at] === '\n' || (text[at] === '\r' && text[at + 1] !== '\n')) {
79
+ endLine++;
80
+ }
81
+ }
82
+ result.push({ from: range.from, to: range.to, startLine, endLine, indent: before, body: bodyOf(text.slice(range.innerFrom + 1, range.innerTo)) });
83
+ }
84
+ return result;
85
+ }
@@ -8,6 +8,10 @@ export interface OffsetRange {
8
8
  readonly to: number;
9
9
  }
10
10
  export interface DocumentEditOptions extends EditOptions {
11
+ selectionPositions?: readonly {
12
+ readonly anchor: Position;
13
+ readonly head: Position;
14
+ }[];
11
15
  expectedRevision?: number;
12
16
  }
13
17
  export interface TypeTextOptions extends CommandOptions {
@@ -43,6 +47,7 @@ export declare class DocumentModel {
43
47
  occurrencesExhausted: boolean;
44
48
  private closers;
45
49
  private bracketCache?;
50
+ private scriptCache?;
46
51
  constructor(text?: string);
47
52
  getText(): string;
48
53
  getLength(): number;
@@ -66,6 +71,7 @@ export declare class DocumentModel {
66
71
  findNext(query: string, from?: number, options?: FindNextOptions): FindMatch | null;
67
72
  replace(match: FindMatch, replacement: string, options?: ReplaceOptions): boolean;
68
73
  replaceAll(query: string, replacement: string, options?: ReplaceAllOptions): number;
74
+ replaceMatches(matches: readonly FindMatch[], replacement: string, options?: ReplaceOptions): number;
69
75
  getFoldingRanges(options?: FoldingOptions): FoldingRange[];
70
76
  typeText(text: string, options?: TypeTextOptions): boolean;
71
77
  private takeBackArrows;
@@ -112,6 +118,8 @@ export declare class DocumentModel {
112
118
  private editLines;
113
119
  private editSource;
114
120
  private isUnclosedBrace;
121
+ private scriptSource;
122
+ private scriptBracketIndex;
115
123
  private bracketIndex;
116
124
  private deleteCharacter;
117
125
  private unindent;
package/dist/document.js CHANGED
@@ -1,8 +1,9 @@
1
+ import { scriptSource } from './script-source.js';
1
2
  import { CloserTracker } from './closer-tracker.js';
2
3
  import { planBlockComment, planLineComments } from './comments.js';
3
4
  import { planEnter } from './enter.js';
4
5
  import { planAutoIndent, planJoinLines, planToggleCase, properIndent, spacingBetween } from './line-commands.js';
5
- import { mapOffset } from './offsets.js';
6
+ import { mapOffset, offsetMapper } from './offsets.js';
6
7
  import { planPaste } from './paste.js';
7
8
  import { isPlainText, vueRegionAt } from './languages.js';
8
9
  import { blockFolds } from './block-folds.js';
@@ -12,8 +13,8 @@ import { rangeOf, whitespaceOf } from './line-text.js';
12
13
  import { isQuote, replacesComparison, surround, swapQuotes } from './typing-handlers.js';
13
14
  import { scanBrackets } from './brackets.js';
14
15
  import { hasSmartSemicolon, isPhp } from './lexical.js';
15
- import { clampInteger, TextRope } from './rope.js';
16
- import { findMatches, replacementText, splitsSurrogate } from './search.js';
16
+ import { clampInteger, TextRope, ROPE_CHUNK_SIZE } from './rope.js';
17
+ import { findMatches, findMatchFrom, replacementText, splitsSurrogate } from './search.js';
17
18
  import { dedupeFoldingRanges, deriveFoldingRanges, indentationColumn, scanStructure, tabWidth } from './structure.js';
18
19
  import { TypingContexts } from './typing-context.js';
19
20
  import { isHumpBoundary, isWordBoundary } from './words.js';
@@ -238,6 +239,7 @@ export class DocumentModel {
238
239
  occurrencesExhausted = false;
239
240
  closers = new CloserTracker();
240
241
  bracketCache;
242
+ scriptCache;
241
243
  constructor(text = '') {
242
244
  this.rope = TextRope.from(text);
243
245
  this.textCache = { text };
@@ -326,21 +328,31 @@ export class DocumentModel {
326
328
  }
327
329
  const unique = this.validateEdits(edits);
328
330
  const changed = unique.filter((edit) => changesText(this.rope, edit));
329
- let next = this.rope;
330
- for (let index = changed.length - 1; index >= 0; index--) {
331
- const edit = changed[index];
332
- next = next.replace(edit.from, edit.to, edit.text);
331
+ // Sparse edits keep structural sharing; dense edits avoid rescanning a leaf for each replacement.
332
+ const rebuild = changed.length >= 256 && changed.length * ROPE_CHUNK_SIZE >= this.rope.length;
333
+ let next = rebuild ? this.rope.rebuild(changed) : this.rope;
334
+ if (!rebuild) {
335
+ for (let index = changed.length - 1; index >= 0; index--) {
336
+ const edit = changed[index];
337
+ next = next.replace(edit.from, edit.to, edit.text);
338
+ }
333
339
  }
334
- const nextSelections = options.selections ??
335
- this.selections.map((selection) => ({ anchor: mapOffset(selection.anchor, unique), head: mapOffset(selection.head, unique) }));
340
+ const map = offsetMapper(unique);
341
+ const at = (position) => {
342
+ const line = next.lineBounds(position.line);
343
+ return offsetIn(next, line.start + clampInteger(position.column, line.end - line.start));
344
+ };
336
345
  // Several edits can cancel each other out, like moving a character and moving it back.
337
346
  const unchanged = next === this.rope || (changed.length > 1 && next.length === this.rope.length && next.equalsRange(this.rope, changed[0].from, changed.at(-1).to));
338
347
  if (unchanged) {
339
348
  if (options.selections) {
340
- this.setSelections(nextSelections);
349
+ this.setSelections(options.selections);
341
350
  }
342
351
  return false;
343
352
  }
353
+ const nextSelections = options.selections ??
354
+ options.selectionPositions?.map((selection) => ({ anchor: at(selection.anchor), head: at(selection.head) })) ??
355
+ this.selections.map((selection) => ({ anchor: map(selection.anchor), head: map(selection.head) }));
344
356
  const before = this.state();
345
357
  const firstLine = this.rope.lineAt(changed[0].from);
346
358
  const previous = this.rope;
@@ -354,11 +366,13 @@ export class DocumentModel {
354
366
  const after = this.state();
355
367
  const changes = changed.map((edit) => ({ from: edit.from, to: edit.to, insertedLength: edit.text.length }));
356
368
  const last = this.undoStack.at(-1);
357
- if (options.historyGroup &&
369
+ const joinsGroup = options.historyGroup &&
358
370
  this.groupOpen &&
359
371
  last?.group === options.historyGroup &&
360
372
  last.after.rope === before.rope &&
361
- sameSelections(last.after.selections, before.selections)) {
373
+ sameSelections(last.after.selections, before.selections);
374
+ const joinsPrevious = options.joinPrevious === true && last !== undefined && last.after.rope === before.rope;
375
+ if (last !== undefined && (joinsGroup || joinsPrevious)) {
362
376
  last.after = after;
363
377
  last.changes.push(changes);
364
378
  }
@@ -368,9 +382,9 @@ export class DocumentModel {
368
382
  if (this.undoStack.length > historyLimit) {
369
383
  this.undoStack.shift();
370
384
  }
371
- this.groupOpen = Boolean(options.historyGroup);
385
+ this.groupOpen = Boolean(options.historyGroup) && !joinsPrevious;
372
386
  this.redoStack = [];
373
- this.emit([changes], options.source ?? 'external', () => editsOf(previous, changed));
387
+ this.emit([changes], options.source ?? 'external', () => (rebuild ? stretchEdits(previous, next, [changes]) : editsOf(previous, changed)), options.typed);
374
388
  return true;
375
389
  }
376
390
  /* Replaces everything as one undoable external edit. */
@@ -408,11 +422,7 @@ export class DocumentModel {
408
422
  }
409
423
  /* Includes a match that starts at `from`, or ends there when searching backwards. */
410
424
  findNext(query, from = this.selections.at(-1).head, options = {}) {
411
- const matches = this.find(query, options);
412
- const offset = clampInteger(from, this.rope.length);
413
- const ordered = options.backwards ? matches.reverse() : matches;
414
- const found = ordered.find((match) => (options.backwards ? match.to <= offset : match.from >= offset));
415
- return found ?? (options.wrap === false ? null : (ordered[0] ?? null));
425
+ return findMatchFrom(this.getText(), query, this.revision, from, options);
416
426
  }
417
427
  /* Refuses a match from an earlier revision, or one whose text no longer sits there. */
418
428
  replace(match, replacement, options = {}) {
@@ -433,14 +443,31 @@ export class DocumentModel {
433
443
  if (options.expectedRevision !== undefined && options.expectedRevision !== this.revision) {
434
444
  return 0;
435
445
  }
436
- const matches = this.find(query, options);
446
+ return this.replaceMatches(this.find(query, options), replacement, options);
447
+ }
448
+ /* Commits retained search results as one undo step; one stale result refuses the entire batch. */
449
+ replaceMatches(matches, replacement, options = {}) {
450
+ const revision = this.revision;
451
+ if (matches.length === 0 ||
452
+ (options.expectedRevision !== undefined && options.expectedRevision !== revision) ||
453
+ matches.some((match) => match.revision !== revision || this.slice(match.from, match.to) !== match.text)) {
454
+ return 0;
455
+ }
456
+ const ranges = new Set();
457
+ for (const match of matches) {
458
+ const key = `${match.from}:${match.to}`;
459
+ if (ranges.has(key)) {
460
+ throw new RangeError('Search matches must have unique ranges.');
461
+ }
462
+ ranges.add(key);
463
+ }
437
464
  const text = this.getText();
438
465
  const edits = matches.map((match) => ({
439
466
  from: match.from,
440
467
  to: match.to,
441
468
  text: replacementText(text, match, replacement, options.literal, options.preserveCase)
442
469
  }));
443
- return this.applyEdits(edits, { source: 'command', ...options }) ? matches.length : 0;
470
+ return this.applyEdits(edits, { source: 'command', ...options, expectedRevision: revision }) ? matches.length : 0;
444
471
  }
445
472
  getFoldingRanges(options = {}) {
446
473
  const ranges = options.brackets === false && options.comments === false ? [] : this.structure();
@@ -661,10 +688,12 @@ export class DocumentModel {
661
688
  const arrow = text === '-' && input.smartArrow && from === to;
662
689
  const needsContext = text.length === 1 && (/[()[\]{}'"`<]/.test(text) || (text === ';' && smartSemicolon) || arrow);
663
690
  const context = needsContext ? this.typingContext(from, language) : undefined;
664
- if (arrow && context?.mode === 'code' && context.word !== 'control-close' && this.endsOperand(from)) {
691
+ if (arrow && context?.mode === 'code' && context.embedded === undefined && context.word !== 'control-close' && this.endsOperand(from)) {
665
692
  return { from, to, text, anchor: 1, head: 1, arrow: true };
666
693
  }
667
- const insideQuote = context && (context.mode === 'quote' || context.mode === 'template') && context.quote === text && !context.escaped;
694
+ const insideQuote = context &&
695
+ (((context.mode === 'quote' || context.mode === 'template') && context.quote === text && !context.escaped) ||
696
+ (context.mode === 'html' && context.markup === 'value' && context.markupQuote === text));
668
697
  if (pairing && from === to && after === text && (quote ? insideQuote : context?.mode === 'code' && context.bracket?.close === text)) {
669
698
  return { from, to, text: '', anchor: 1, head: 1, skip: true };
670
699
  }
@@ -736,9 +765,13 @@ export class DocumentModel {
736
765
  return { from: line.start, to: offset, text: indent + closer, anchor: indent.length + 1, head: indent.length + 1 };
737
766
  }
738
767
  shouldClosePair(offset, opener, after, context, input) {
768
+ if (context?.mode === 'html') {
769
+ return context.markup === 'equals' && (opener === '"' || opener === "'") && (!after || /[\s>/]/u.test(after));
770
+ }
739
771
  const interpolation = this.isInterpolation(offset, opener, context);
740
772
  const quote = isQuote(opener);
741
- const followedByTerminator = quote ? !identifier.test(after) && after !== opener : !after || /[\s)\]}>,;:]/u.test(after);
773
+ const scriptEnd = context?.embedded !== undefined && /^<\/script(?=[\s/>]|$)/i.test(this.slice(offset, offset + 9));
774
+ const followedByTerminator = quote ? !identifier.test(after) && after !== opener : !after || scriptEnd || /[\s)\]}>,;:]/u.test(after);
742
775
  if (context?.mode !== 'code' && !interpolation) {
743
776
  return false;
744
777
  }
@@ -751,14 +784,22 @@ export class DocumentModel {
751
784
  !this.escaped(offset) &&
752
785
  !this.completesTripleQuote(offset, opener, input.language));
753
786
  }
754
- return !this.hasMatch(offset, opener, input.language);
787
+ return !this.hasMatch(offset, opener, input.language, context);
755
788
  }
756
789
  /* The third quote of a Python docstring opener is typed as it is, since the pair that came before it is the empty string. */
757
790
  completesTripleQuote(offset, quote, language) {
758
791
  return /^(python|py)$/i.test(language) && quote !== '`' && this.slice(offset - 2, offset) === quote + quote;
759
792
  }
760
793
  /* Whether the bracket just typed at `offset` would find its closer in the text that already follows it. */
761
- hasMatch(offset, opener, language) {
794
+ hasMatch(offset, opener, language, context) {
795
+ if (context?.embedded === 'javascript' && context.embedStart !== undefined) {
796
+ const source = this.scriptSource(context.embedStart, matchScanLimit).text;
797
+ if (source === undefined) {
798
+ return false;
799
+ }
800
+ const at = offset - context.embedStart;
801
+ return scanBrackets(source.slice(0, at) + opener + source.slice(at), 'javascript').pairs.has(at);
802
+ }
762
803
  if (this.rope.length > matchScanLimit) {
763
804
  return false;
764
805
  }
@@ -1277,7 +1318,7 @@ export class DocumentModel {
1277
1318
  if (sorted.some((current, index) => index > 0 && current.from < sorted[index - 1].to)) {
1278
1319
  plans = plan(false);
1279
1320
  }
1280
- return this.applySelectionEdits(plans.map((entry) => ({ from: entry.from, to: entry.to, text: entry.text, anchor: entry.caret, head: entry.caret })), { source: 'command' });
1321
+ return this.applySelectionEdits(plans.map((entry) => ({ from: entry.from, to: entry.to, text: entry.text, anchor: entry.caret, head: entry.caret })), { source: 'command', typed: mode === 'enter' });
1281
1322
  }
1282
1323
  /* A line of its own above each caret's line, at the indentation of its text. */
1283
1324
  startNewLineBefore(unit) {
@@ -1329,7 +1370,8 @@ export class DocumentModel {
1329
1370
  * it, which it took from that block. Such a closer sits shallower than the brace's line and leaves an opener before it unmatched.
1330
1371
  */
1331
1372
  isUnclosedBrace(offset, language) {
1332
- const index = this.bracketIndex(language);
1373
+ const context = isPhp(language) ? this.typingContext(offset, language) : undefined;
1374
+ const index = context?.embedded === 'javascript' && context.embedStart !== undefined ? this.scriptBracketIndex(context.embedStart) : this.bracketIndex(language);
1333
1375
  if (index === undefined) {
1334
1376
  return false;
1335
1377
  }
@@ -1343,6 +1385,39 @@ export class DocumentModel {
1343
1385
  }
1344
1386
  return [...index.unmatched].some((at) => at < offset && this.rope.charAt(at) === '{');
1345
1387
  }
1388
+ scriptSource(start, limit) {
1389
+ if (this.scriptCache?.revision !== this.revision) {
1390
+ this.scriptCache = { revision: this.revision, sources: new Map() };
1391
+ }
1392
+ const cached = this.scriptCache.sources.get(start);
1393
+ if (cached !== undefined && (cached.text !== undefined || cached.limit >= limit)) {
1394
+ return cached.text !== undefined && cached.text.length > limit ? { limit } : cached;
1395
+ }
1396
+ const end = Math.min(this.rope.length, start + limit + 11);
1397
+ let work = bracketScanLimit;
1398
+ const prepared = scriptSource(this.slice(start, end), start, end === this.rope.length, (at) => {
1399
+ const position = this.positionAt(at);
1400
+ work -= position.column + 1;
1401
+ return work < 0 ? null : this.typingContext(at, 'php');
1402
+ });
1403
+ const result = { limit, text: prepared !== undefined && prepared.length <= limit ? prepared : undefined };
1404
+ this.scriptCache.sources.set(start, result);
1405
+ return result;
1406
+ }
1407
+ scriptBracketIndex(start) {
1408
+ const source = this.scriptSource(start, bracketScanLimit);
1409
+ if (source.text === undefined) {
1410
+ return undefined;
1411
+ }
1412
+ if (source.index === undefined) {
1413
+ const index = scanBrackets(source.text, 'javascript');
1414
+ source.index = {
1415
+ pairs: new Map([...index.pairs].map(([from, to]) => [from + start, to + start])),
1416
+ unmatched: new Set([...index.unmatched].map((at) => at + start))
1417
+ };
1418
+ }
1419
+ return source.index;
1420
+ }
1346
1421
  /* Undefined for a document too large to scan on every Enter. */
1347
1422
  bracketIndex(language) {
1348
1423
  if (this.rope.length > bracketScanLimit) {
@@ -1418,6 +1493,10 @@ export class DocumentModel {
1418
1493
  return false;
1419
1494
  }
1420
1495
  const context = this.typingContext(offset - 1, language);
1496
+ if (context.mode === 'html' && context.markup === 'equals' && (opener === '"' || opener === "'")) {
1497
+ const inside = this.typingContext(offset, language);
1498
+ return inside.mode === 'html' && inside.markup === 'value' && inside.markupQuote === opener;
1499
+ }
1421
1500
  return context.mode === 'code' || this.isInterpolation(offset - 1, opener, context);
1422
1501
  }
1423
1502
  /* Backspace inside the indentation of a line goes back to the previous tab stop, and at the start of a line joins it with the one above. */
@@ -1819,11 +1898,14 @@ export class DocumentModel {
1819
1898
  this.emit(changes, 'command', () => stretchEdits(previous, state.rope, changes));
1820
1899
  }
1821
1900
  /* `contentEdits` is worked out when a listener asks, since most listeners never do and a restore of a large text would otherwise read all of it. */
1822
- emit(changes, source, contentEdits) {
1901
+ emit(changes, source, contentEdits, typed) {
1823
1902
  const snapshot = this.getSnapshot();
1824
1903
  if (changes) {
1825
1904
  snapshot.changes = changes.map((transaction) => transaction.map((change) => ({ ...change })));
1826
1905
  snapshot.source = source;
1906
+ if (typed) {
1907
+ snapshot.typed = true;
1908
+ }
1827
1909
  if (contentEdits) {
1828
1910
  let edits;
1829
1911
  Object.defineProperty(snapshot, 'contentEdits', { get: () => (edits ??= contentEdits()), enumerable: true });
@@ -0,0 +1,6 @@
1
+ import type { EditSource } from './edit-source.ts';
2
+ import { type EditRegion } from './languages.ts';
3
+ export type EditRegions = (line: number, offset?: number) => EditRegion | null;
4
+ export declare function phpLiteralAt(source: EditSource, offset: number): boolean;
5
+ export declare function phpTagAt(source: EditSource, offset: number): boolean;
6
+ export declare function editRegions(source: EditSource, language: string): EditRegions;
@@ -0,0 +1,87 @@
1
+ import { vueRegionAt } from './languages.js';
2
+ import { hasJsx, jsxChildren } from './markup-regions.js';
3
+ const SCAN_LIMIT = 2_000_000;
4
+ export function phpLiteralAt(source, offset) {
5
+ const context = source.context(offset);
6
+ return context.mode === 'heredoc' || context.mode === 'quote' || (context.embedded !== undefined && context.mode === 'template');
7
+ }
8
+ export function phpTagAt(source, offset) {
9
+ const context = source.context(offset);
10
+ return context.mode === 'html' && (context.markup === 'tag' || context.markup === 'equals' || context.markup === 'value');
11
+ }
12
+ function firstContent(source, line) {
13
+ const bounds = source.line(line);
14
+ return bounds.start + bounds.text.length - bounds.text.trimStart().length;
15
+ }
16
+ function mixedPhpLine(source, line) {
17
+ const bounds = source.line(line);
18
+ let work = SCAN_LIMIT;
19
+ for (const match of bounds.text.matchAll(/<\?(?:php\b|=)|\?>/g)) {
20
+ work -= bounds.text.length + 1;
21
+ if (work < 0) {
22
+ return true;
23
+ }
24
+ const at = bounds.start + match.index;
25
+ const context = source.context(at);
26
+ const { mode, embedded } = context;
27
+ if (match[0] === '?>') {
28
+ if (mode !== 'html' && embedded === undefined) {
29
+ return true;
30
+ }
31
+ }
32
+ else if (mode === 'html' || embedded !== undefined || (mode === 'code' && at === firstContent(source, line))) {
33
+ return true;
34
+ }
35
+ }
36
+ return false;
37
+ }
38
+ export function editRegions(source, language) {
39
+ const id = language.toLowerCase();
40
+ const end = source.line(source.lineCount - 1).end;
41
+ if (id === 'vue') {
42
+ return (line) => (/^<\/?(?:template|script|style)\b/.test(source.line(line).text) ? 'mixed' : vueRegionAt((index) => source.line(index).text, line));
43
+ }
44
+ if (hasJsx(id)) {
45
+ let children;
46
+ return (line, offset) => {
47
+ if (end > SCAN_LIMIT) {
48
+ return null;
49
+ }
50
+ children ??= jsxChildren(source.slice(0, end));
51
+ return children(offset ?? firstContent(source, line)) ? 'jsx' : null;
52
+ };
53
+ }
54
+ if (id === 'php') {
55
+ return (line, offset) => {
56
+ const bounds = source.line(line);
57
+ if (offset === undefined &&
58
+ (phpLiteralAt(source, bounds.start) ||
59
+ phpLiteralAt(source, bounds.end) ||
60
+ phpTagAt(source, bounds.start) ||
61
+ phpTagAt(source, bounds.end) ||
62
+ mixedPhpLine(source, line))) {
63
+ return 'mixed';
64
+ }
65
+ const at = offset ?? firstContent(source, line);
66
+ const context = source.context(at);
67
+ if (phpTagAt(source, at)) {
68
+ return 'mixed';
69
+ }
70
+ if (context.mode !== 'html' && context.embedded === undefined) {
71
+ return null;
72
+ }
73
+ if (context.markup === 'comment' || (offset === undefined && source.context(bounds.end).markup === 'comment')) {
74
+ return 'mixed';
75
+ }
76
+ if (context.markup === 'raw') {
77
+ const closing = /^<\/(script|style|textarea|title)(?=[\s/>]|$)/i.exec(source.slice(at, at + 11));
78
+ // The closing tag belongs to markup, while the preceding raw text keeps its syntax.
79
+ if (closing?.[1]?.toLowerCase() !== context.element) {
80
+ return context.element === 'script' || context.element === 'style' ? context.element : 'mixed';
81
+ }
82
+ }
83
+ return 'template';
84
+ };
85
+ }
86
+ return () => null;
87
+ }