@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/src/document.ts CHANGED
@@ -1,8 +1,9 @@
1
+ import { scriptSource } from './script-source.ts';
1
2
  import { CloserTracker } from './closer-tracker.ts';
2
3
  import { planBlockComment, planLineComments } from './comments.ts';
3
4
  import { planEnter } from './enter.ts';
4
5
  import { planAutoIndent, planJoinLines, planToggleCase, properIndent, spacingBetween } from './line-commands.ts';
5
- import { mapOffset } from './offsets.ts';
6
+ import { mapOffset, offsetMapper } from './offsets.ts';
6
7
  import { planPaste } from './paste.ts';
7
8
  import type { EnterOptions, EnterPlan } from './enter.ts';
8
9
  import type { EditPlan, EditSource } from './edit-source.ts';
@@ -15,9 +16,9 @@ import { isQuote, replacesComparison, surround, swapQuotes } from './typing-hand
15
16
  import { scanBrackets } from './brackets.ts';
16
17
  import type { BracketIndex } from './brackets.ts';
17
18
  import { hasSmartSemicolon, isPhp } from './lexical.ts';
18
- import { clampInteger, TextRope } from './rope.ts';
19
+ import { clampInteger, TextRope, ROPE_CHUNK_SIZE } from './rope.ts';
19
20
  import type { DocumentLine } from './rope.ts';
20
- import { findMatches, replacementText, splitsSurrogate } from './search.ts';
21
+ import { findMatches, findMatchFrom, replacementText, splitsSurrogate } from './search.ts';
21
22
  import type { FindMatch, FindNextOptions, FindOptions } from './search.ts';
22
23
  import { dedupeFoldingRanges, deriveFoldingRanges, indentationColumn, scanStructure, tabWidth } from './structure.ts';
23
24
  import type { FoldingOptions, FoldingRange, StructureRange } from './structure.ts';
@@ -45,6 +46,8 @@ export interface OffsetRange {
45
46
  }
46
47
 
47
48
  export interface DocumentEditOptions extends EditOptions {
49
+ /* Positions in the resulting text, committed with the edit and its undo state. */
50
+ selectionPositions?: readonly { readonly anchor: Position; readonly head: Position }[];
48
51
  /* The edit is refused, with nothing changed, unless the document is still at this revision. */
49
52
  expectedRevision?: number;
50
53
  }
@@ -379,6 +382,7 @@ export class DocumentModel {
379
382
  occurrencesExhausted = false;
380
383
  private closers = new CloserTracker();
381
384
  private bracketCache?: { revision: number; language: string; index: BracketIndex };
385
+ private scriptCache?: { revision: number; sources: Map<number, { limit: number; text?: string; index?: BracketIndex }> };
382
386
 
383
387
  constructor(text = '') {
384
388
  this.rope = TextRope.from(text);
@@ -481,23 +485,33 @@ export class DocumentModel {
481
485
  }
482
486
  const unique = this.validateEdits(edits);
483
487
  const changed = unique.filter((edit) => changesText(this.rope, edit));
484
- let next = this.rope;
485
- for (let index = changed.length - 1; index >= 0; index--) {
486
- const edit = changed[index]!;
487
- next = next.replace(edit.from, edit.to, edit.text);
488
+ // Sparse edits keep structural sharing; dense edits avoid rescanning a leaf for each replacement.
489
+ const rebuild = changed.length >= 256 && changed.length * ROPE_CHUNK_SIZE >= this.rope.length;
490
+ let next = rebuild ? this.rope.rebuild(changed) : this.rope;
491
+ if (!rebuild) {
492
+ for (let index = changed.length - 1; index >= 0; index--) {
493
+ const edit = changed[index]!;
494
+ next = next.replace(edit.from, edit.to, edit.text);
495
+ }
488
496
  }
489
- const nextSelections =
490
- options.selections ??
491
- this.selections.map((selection) => ({ anchor: mapOffset(selection.anchor, unique), head: mapOffset(selection.head, unique) }));
497
+ const map = offsetMapper(unique);
498
+ const at = (position: Position): number => {
499
+ const line = next.lineBounds(position.line);
500
+ return offsetIn(next, line.start + clampInteger(position.column, line.end - line.start));
501
+ };
492
502
  // Several edits can cancel each other out, like moving a character and moving it back.
493
503
  const unchanged =
494
504
  next === this.rope || (changed.length > 1 && next.length === this.rope.length && next.equalsRange(this.rope, changed[0]!.from, changed.at(-1)!.to));
495
505
  if (unchanged) {
496
506
  if (options.selections) {
497
- this.setSelections(nextSelections);
507
+ this.setSelections(options.selections);
498
508
  }
499
509
  return false;
500
510
  }
511
+ const nextSelections =
512
+ options.selections ??
513
+ options.selectionPositions?.map((selection) => ({ anchor: at(selection.anchor), head: at(selection.head) })) ??
514
+ this.selections.map((selection) => ({ anchor: map(selection.anchor), head: map(selection.head) }));
501
515
 
502
516
  const before = this.state();
503
517
  const firstLine = this.rope.lineAt(changed[0]!.from);
@@ -512,13 +526,14 @@ export class DocumentModel {
512
526
  const after = this.state();
513
527
  const changes = changed.map((edit) => ({ from: edit.from, to: edit.to, insertedLength: edit.text.length }));
514
528
  const last = this.undoStack.at(-1);
515
- if (
529
+ const joinsGroup =
516
530
  options.historyGroup &&
517
531
  this.groupOpen &&
518
532
  last?.group === options.historyGroup &&
519
533
  last.after.rope === before.rope &&
520
- sameSelections(last.after.selections, before.selections)
521
- ) {
534
+ sameSelections(last.after.selections, before.selections);
535
+ const joinsPrevious = options.joinPrevious === true && last !== undefined && last.after.rope === before.rope;
536
+ if (last !== undefined && (joinsGroup || joinsPrevious)) {
522
537
  last.after = after;
523
538
  last.changes.push(changes);
524
539
  } else {
@@ -527,9 +542,14 @@ export class DocumentModel {
527
542
  if (this.undoStack.length > historyLimit) {
528
543
  this.undoStack.shift();
529
544
  }
530
- this.groupOpen = Boolean(options.historyGroup);
545
+ this.groupOpen = Boolean(options.historyGroup) && !joinsPrevious;
531
546
  this.redoStack = [];
532
- this.emit([changes], options.source ?? 'external', () => editsOf(previous, changed));
547
+ this.emit(
548
+ [changes],
549
+ options.source ?? 'external',
550
+ () => (rebuild ? stretchEdits(previous, next, [changes]) : editsOf(previous, changed)),
551
+ options.typed
552
+ );
533
553
  return true;
534
554
  }
535
555
 
@@ -574,11 +594,7 @@ export class DocumentModel {
574
594
 
575
595
  /* Includes a match that starts at `from`, or ends there when searching backwards. */
576
596
  findNext(query: string, from = this.selections.at(-1)!.head, options: FindNextOptions = {}): FindMatch | null {
577
- const matches = this.find(query, options);
578
- const offset = clampInteger(from, this.rope.length);
579
- const ordered = options.backwards ? matches.reverse() : matches;
580
- const found = ordered.find((match) => (options.backwards ? match.to <= offset : match.from >= offset));
581
- return found ?? (options.wrap === false ? null : (ordered[0] ?? null));
597
+ return findMatchFrom(this.getText(), query, this.revision, from, options);
582
598
  }
583
599
 
584
600
  /* Refuses a match from an earlier revision, or one whose text no longer sits there. */
@@ -604,14 +620,34 @@ export class DocumentModel {
604
620
  if (options.expectedRevision !== undefined && options.expectedRevision !== this.revision) {
605
621
  return 0;
606
622
  }
607
- const matches = this.find(query, options);
623
+ return this.replaceMatches(this.find(query, options), replacement, options);
624
+ }
625
+
626
+ /* Commits retained search results as one undo step; one stale result refuses the entire batch. */
627
+ replaceMatches(matches: readonly FindMatch[], replacement: string, options: ReplaceOptions = {}): number {
628
+ const revision = this.revision;
629
+ if (
630
+ matches.length === 0 ||
631
+ (options.expectedRevision !== undefined && options.expectedRevision !== revision) ||
632
+ matches.some((match) => match.revision !== revision || this.slice(match.from, match.to) !== match.text)
633
+ ) {
634
+ return 0;
635
+ }
636
+ const ranges = new Set<string>();
637
+ for (const match of matches) {
638
+ const key = `${match.from}:${match.to}`;
639
+ if (ranges.has(key)) {
640
+ throw new RangeError('Search matches must have unique ranges.');
641
+ }
642
+ ranges.add(key);
643
+ }
608
644
  const text = this.getText();
609
645
  const edits = matches.map((match) => ({
610
646
  from: match.from,
611
647
  to: match.to,
612
648
  text: replacementText(text, match, replacement, options.literal, options.preserveCase)
613
649
  }));
614
- return this.applyEdits(edits, { source: 'command', ...options }) ? matches.length : 0;
650
+ return this.applyEdits(edits, { source: 'command', ...options, expectedRevision: revision }) ? matches.length : 0;
615
651
  }
616
652
 
617
653
  getFoldingRanges(options: FoldingOptions = {}): FoldingRange[] {
@@ -855,10 +891,13 @@ export class DocumentModel {
855
891
  const arrow = text === '-' && input.smartArrow && from === to;
856
892
  const needsContext = text.length === 1 && (/[()[\]{}'"`<]/.test(text) || (text === ';' && smartSemicolon) || arrow);
857
893
  const context = needsContext ? this.typingContext(from, language) : undefined;
858
- if (arrow && context?.mode === 'code' && context.word !== 'control-close' && this.endsOperand(from)) {
894
+ if (arrow && context?.mode === 'code' && context.embedded === undefined && context.word !== 'control-close' && this.endsOperand(from)) {
859
895
  return { from, to, text, anchor: 1, head: 1, arrow: true };
860
896
  }
861
- const insideQuote = context && (context.mode === 'quote' || context.mode === 'template') && context.quote === text && !context.escaped;
897
+ const insideQuote =
898
+ context &&
899
+ (((context.mode === 'quote' || context.mode === 'template') && context.quote === text && !context.escaped) ||
900
+ (context.mode === 'html' && context.markup === 'value' && context.markupQuote === text));
862
901
  if (pairing && from === to && after === text && (quote ? insideQuote : context?.mode === 'code' && context.bracket?.close === text)) {
863
902
  return { from, to, text: '', anchor: 1, head: 1, skip: true };
864
903
  }
@@ -937,9 +976,13 @@ export class DocumentModel {
937
976
  }
938
977
 
939
978
  private shouldClosePair(offset: number, opener: string, after: string, context: TypingContext | undefined, input: TypingInput): boolean {
979
+ if (context?.mode === 'html') {
980
+ return context.markup === 'equals' && (opener === '"' || opener === "'") && (!after || /[\s>/]/u.test(after));
981
+ }
940
982
  const interpolation = this.isInterpolation(offset, opener, context);
941
983
  const quote = isQuote(opener);
942
- const followedByTerminator = quote ? !identifier.test(after) && after !== opener : !after || /[\s)\]}>,;:]/u.test(after);
984
+ const scriptEnd = context?.embedded !== undefined && /^<\/script(?=[\s/>]|$)/i.test(this.slice(offset, offset + 9));
985
+ const followedByTerminator = quote ? !identifier.test(after) && after !== opener : !after || scriptEnd || /[\s)\]}>,;:]/u.test(after);
943
986
  if (context?.mode !== 'code' && !interpolation) {
944
987
  return false;
945
988
  }
@@ -954,7 +997,7 @@ export class DocumentModel {
954
997
  !this.completesTripleQuote(offset, opener, input.language)
955
998
  );
956
999
  }
957
- return !this.hasMatch(offset, opener, input.language);
1000
+ return !this.hasMatch(offset, opener, input.language, context);
958
1001
  }
959
1002
 
960
1003
  /* The third quote of a Python docstring opener is typed as it is, since the pair that came before it is the empty string. */
@@ -963,7 +1006,15 @@ export class DocumentModel {
963
1006
  }
964
1007
 
965
1008
  /* Whether the bracket just typed at `offset` would find its closer in the text that already follows it. */
966
- private hasMatch(offset: number, opener: string, language: string): boolean {
1009
+ private hasMatch(offset: number, opener: string, language: string, context?: TypingContext): boolean {
1010
+ if (context?.embedded === 'javascript' && context.embedStart !== undefined) {
1011
+ const source = this.scriptSource(context.embedStart, matchScanLimit).text;
1012
+ if (source === undefined) {
1013
+ return false;
1014
+ }
1015
+ const at = offset - context.embedStart;
1016
+ return scanBrackets(source.slice(0, at) + opener + source.slice(at), 'javascript').pairs.has(at);
1017
+ }
967
1018
  if (this.rope.length > matchScanLimit) {
968
1019
  return false;
969
1020
  }
@@ -1533,7 +1584,7 @@ export class DocumentModel {
1533
1584
  }
1534
1585
  return this.applySelectionEdits(
1535
1586
  plans.map((entry) => ({ from: entry.from, to: entry.to, text: entry.text, anchor: entry.caret, head: entry.caret })),
1536
- { source: 'command' }
1587
+ { source: 'command', typed: mode === 'enter' }
1537
1588
  );
1538
1589
  }
1539
1590
 
@@ -1595,7 +1646,9 @@ export class DocumentModel {
1595
1646
  * it, which it took from that block. Such a closer sits shallower than the brace's line and leaves an opener before it unmatched.
1596
1647
  */
1597
1648
  private isUnclosedBrace(offset: number, language: string): boolean {
1598
- const index = this.bracketIndex(language);
1649
+ const context = isPhp(language) ? this.typingContext(offset, language) : undefined;
1650
+ const index =
1651
+ context?.embedded === 'javascript' && context.embedStart !== undefined ? this.scriptBracketIndex(context.embedStart) : this.bracketIndex(language);
1599
1652
  if (index === undefined) {
1600
1653
  return false;
1601
1654
  }
@@ -1610,6 +1663,41 @@ export class DocumentModel {
1610
1663
  return [...index.unmatched].some((at) => at < offset && this.rope.charAt(at) === '{');
1611
1664
  }
1612
1665
 
1666
+ private scriptSource(start: number, limit: number): { limit: number; text?: string; index?: BracketIndex } {
1667
+ if (this.scriptCache?.revision !== this.revision) {
1668
+ this.scriptCache = { revision: this.revision, sources: new Map() };
1669
+ }
1670
+ const cached = this.scriptCache.sources.get(start);
1671
+ if (cached !== undefined && (cached.text !== undefined || cached.limit >= limit)) {
1672
+ return cached.text !== undefined && cached.text.length > limit ? { limit } : cached;
1673
+ }
1674
+ const end = Math.min(this.rope.length, start + limit + 11);
1675
+ let work = bracketScanLimit;
1676
+ const prepared = scriptSource(this.slice(start, end), start, end === this.rope.length, (at) => {
1677
+ const position = this.positionAt(at);
1678
+ work -= position.column + 1;
1679
+ return work < 0 ? null : this.typingContext(at, 'php');
1680
+ });
1681
+ const result = { limit, text: prepared !== undefined && prepared.length <= limit ? prepared : undefined };
1682
+ this.scriptCache.sources.set(start, result);
1683
+ return result;
1684
+ }
1685
+
1686
+ private scriptBracketIndex(start: number): BracketIndex | undefined {
1687
+ const source = this.scriptSource(start, bracketScanLimit);
1688
+ if (source.text === undefined) {
1689
+ return undefined;
1690
+ }
1691
+ if (source.index === undefined) {
1692
+ const index = scanBrackets(source.text, 'javascript');
1693
+ source.index = {
1694
+ pairs: new Map([...index.pairs].map(([from, to]) => [from + start, to + start])),
1695
+ unmatched: new Set([...index.unmatched].map((at) => at + start))
1696
+ };
1697
+ }
1698
+ return source.index;
1699
+ }
1700
+
1613
1701
  /* Undefined for a document too large to scan on every Enter. */
1614
1702
  private bracketIndex(language: string): BracketIndex | undefined {
1615
1703
  if (this.rope.length > bracketScanLimit) {
@@ -1692,6 +1780,10 @@ export class DocumentModel {
1692
1780
  return false;
1693
1781
  }
1694
1782
  const context = this.typingContext(offset - 1, language);
1783
+ if (context.mode === 'html' && context.markup === 'equals' && (opener === '"' || opener === "'")) {
1784
+ const inside = this.typingContext(offset, language);
1785
+ return inside.mode === 'html' && inside.markup === 'value' && inside.markupQuote === opener;
1786
+ }
1695
1787
  return context.mode === 'code' || this.isInterpolation(offset - 1, opener, context);
1696
1788
  }
1697
1789
 
@@ -2126,11 +2218,19 @@ export class DocumentModel {
2126
2218
  }
2127
2219
 
2128
2220
  /* `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. */
2129
- private emit(changes?: readonly (readonly DocumentChange[])[], source?: EditorSnapshot['source'], contentEdits?: () => ContentEdit[]): void {
2221
+ private emit(
2222
+ changes?: readonly (readonly DocumentChange[])[],
2223
+ source?: EditorSnapshot['source'],
2224
+ contentEdits?: () => ContentEdit[],
2225
+ typed?: boolean
2226
+ ): void {
2130
2227
  const snapshot = this.getSnapshot();
2131
2228
  if (changes) {
2132
2229
  snapshot.changes = changes.map((transaction) => transaction.map((change) => ({ ...change })));
2133
2230
  snapshot.source = source;
2231
+ if (typed) {
2232
+ snapshot.typed = true;
2233
+ }
2134
2234
  if (contentEdits) {
2135
2235
  let edits: ContentEdit[] | undefined;
2136
2236
  Object.defineProperty(snapshot, 'contentEdits', { get: () => (edits ??= contentEdits()), enumerable: true });
@@ -0,0 +1,96 @@
1
+ import type { EditSource } from './edit-source.ts';
2
+ import { type EditRegion, vueRegionAt } from './languages.ts';
3
+ import { hasJsx, jsxChildren } from './markup-regions.ts';
4
+
5
+ export type EditRegions = (line: number, offset?: number) => EditRegion | null;
6
+
7
+ const SCAN_LIMIT = 2_000_000;
8
+ export function phpLiteralAt(source: EditSource, offset: number): boolean {
9
+ const context = source.context(offset);
10
+ return context.mode === 'heredoc' || context.mode === 'quote' || (context.embedded !== undefined && context.mode === 'template');
11
+ }
12
+
13
+ export function phpTagAt(source: EditSource, offset: number): boolean {
14
+ const context = source.context(offset);
15
+ return context.mode === 'html' && (context.markup === 'tag' || context.markup === 'equals' || context.markup === 'value');
16
+ }
17
+
18
+ function firstContent(source: EditSource, line: number): number {
19
+ const bounds = source.line(line);
20
+ return bounds.start + bounds.text.length - bounds.text.trimStart().length;
21
+ }
22
+
23
+ function mixedPhpLine(source: EditSource, line: number): boolean {
24
+ const bounds = source.line(line);
25
+ let work = SCAN_LIMIT;
26
+ for (const match of bounds.text.matchAll(/<\?(?:php\b|=)|\?>/g)) {
27
+ work -= bounds.text.length + 1;
28
+ if (work < 0) {
29
+ return true;
30
+ }
31
+ const at = bounds.start + match.index;
32
+ const context = source.context(at);
33
+ const { mode, embedded } = context;
34
+ if (match[0] === '?>') {
35
+ if (mode !== 'html' && embedded === undefined) {
36
+ return true;
37
+ }
38
+ } else if (mode === 'html' || embedded !== undefined || (mode === 'code' && at === firstContent(source, line))) {
39
+ return true;
40
+ }
41
+ }
42
+ return false;
43
+ }
44
+
45
+ export function editRegions(source: EditSource, language: string): EditRegions {
46
+ const id = language.toLowerCase();
47
+ const end = source.line(source.lineCount - 1).end;
48
+ if (id === 'vue') {
49
+ return (line) => (/^<\/?(?:template|script|style)\b/.test(source.line(line).text) ? 'mixed' : vueRegionAt((index) => source.line(index).text, line));
50
+ }
51
+ if (hasJsx(id)) {
52
+ let children: ((offset: number) => boolean) | undefined;
53
+ return (line, offset) => {
54
+ if (end > SCAN_LIMIT) {
55
+ return null;
56
+ }
57
+ children ??= jsxChildren(source.slice(0, end));
58
+ return children(offset ?? firstContent(source, line)) ? 'jsx' : null;
59
+ };
60
+ }
61
+ if (id === 'php') {
62
+ return (line, offset) => {
63
+ const bounds = source.line(line);
64
+ if (
65
+ offset === undefined &&
66
+ (phpLiteralAt(source, bounds.start) ||
67
+ phpLiteralAt(source, bounds.end) ||
68
+ phpTagAt(source, bounds.start) ||
69
+ phpTagAt(source, bounds.end) ||
70
+ mixedPhpLine(source, line))
71
+ ) {
72
+ return 'mixed';
73
+ }
74
+ const at = offset ?? firstContent(source, line);
75
+ const context = source.context(at);
76
+ if (phpTagAt(source, at)) {
77
+ return 'mixed';
78
+ }
79
+ if (context.mode !== 'html' && context.embedded === undefined) {
80
+ return null;
81
+ }
82
+ if (context.markup === 'comment' || (offset === undefined && source.context(bounds.end).markup === 'comment')) {
83
+ return 'mixed';
84
+ }
85
+ if (context.markup === 'raw') {
86
+ const closing = /^<\/(script|style|textarea|title)(?=[\s/>]|$)/i.exec(source.slice(at, at + 11));
87
+ // The closing tag belongs to markup, while the preceding raw text keeps its syntax.
88
+ if (closing?.[1]?.toLowerCase() !== context.element) {
89
+ return context.element === 'script' || context.element === 'style' ? context.element : 'mixed';
90
+ }
91
+ }
92
+ return 'template';
93
+ };
94
+ }
95
+ return () => null;
96
+ }
package/src/enter.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { EditSource } from './edit-source.ts';
2
+ import { editRegions } from './edit-regions.ts';
2
3
  import { commentSyntax } from './languages.ts';
3
4
  import { hasHashComments, isBraced, isPhp, openers } from './lexical.ts';
4
5
  import { whitespaceOf } from './line-text.ts';
@@ -54,13 +55,14 @@ function escapeForPattern(text: string): string {
54
55
  * `if` or `for`, after `else`, `=>` or a binary operator. Languages with braces only.
55
56
  */
56
57
  export function continues(text: string, context: TypingContext, language: string): boolean {
57
- if (!isBraced(language) || context.mode !== 'code') {
58
+ const effectiveLanguage = context.embedded ?? language;
59
+ if (!isBraced(effectiveLanguage) || context.mode !== 'code') {
58
60
  return false;
59
61
  }
60
62
  if (context.word === 'control-close' || context.word === 'else' || context.word === 'do') {
61
63
  return true;
62
64
  }
63
- if (isPhp(language) && /(?<!\.)\.$/.test(text)) {
65
+ if (isPhp(effectiveLanguage) && /(?<!\.)\.$/.test(text)) {
64
66
  return true;
65
67
  }
66
68
  return trailingOperator.test(text);
@@ -149,6 +151,17 @@ class Enter {
149
151
  const start = leading.length < before.length ? base : leading;
150
152
  const plain = (indent: string): EnterPlan => ({ from, to: afterWhitespace, text: newline + indent, caret: newline.length + indent.length });
151
153
 
154
+ if (context.mode === 'code' || (isPhp(language) && context.mode === 'html')) {
155
+ const tag = this.openTag(trimmed, index);
156
+ if (tag) {
157
+ const inner = start + unit;
158
+ if (new RegExp(`^</${escapeForPattern(tag)}\\s*>`, 'i').test(source.slice(afterWhitespace, line.end))) {
159
+ return { from, to: afterWhitespace, text: newline + inner + newline + start, caret: newline.length + inner.length };
160
+ }
161
+ return plain(inner);
162
+ }
163
+ }
164
+
152
165
  if (context.mode !== 'code') {
153
166
  return plain(leading);
154
167
  }
@@ -164,15 +177,6 @@ class Enter {
164
177
  return closing ?? plain(inner);
165
178
  }
166
179
 
167
- const tag = this.openTag(trimmed, index);
168
- if (tag) {
169
- const inner = start + unit;
170
- if (new RegExp(`^</${escapeForPattern(tag)}\\s*>`, 'i').test(source.slice(afterWhitespace, line.end))) {
171
- return { from, to: afterWhitespace, text: newline + inner + newline + start, caret: newline.length + inner.length };
172
- }
173
- return plain(inner);
174
- }
175
-
176
180
  if (this.opensIndentedBlock(trimmed, context)) {
177
181
  return plain(this.blockIndent(line.text, start) + unit);
178
182
  }
@@ -210,7 +214,7 @@ class Enter {
210
214
  /* The name of the tag the text ends in, when it is an open tag with children to come. */
211
215
  private openTag(trimmed: string, index: number): string | null {
212
216
  const { language } = this.options;
213
- if (!markupLanguages.test(language)) {
217
+ if (!markupLanguages.test(language) && !isPhp(language)) {
214
218
  return null;
215
219
  }
216
220
  if (/^vue$/i.test(language) && this.source.region(index) === 'script') {
@@ -220,6 +224,14 @@ class Enter {
220
224
  if (!match || trimmed.endsWith('/>') || (voidElements.test(match[1]!) && !/^xml$/i.test(language))) {
221
225
  return null;
222
226
  }
227
+ if (isPhp(language)) {
228
+ const offset = this.source.line(index).start + trimmed.indexOf('<', match.index);
229
+ // Only earlier tags establish this opener's region; its content can be script or style.
230
+ const regions = editRegions(this.source, language);
231
+ if (regions(index, offset) !== 'template') {
232
+ return null;
233
+ }
234
+ }
223
235
  return match[1]!;
224
236
  }
225
237
 
@@ -288,7 +300,7 @@ class Enter {
288
300
 
289
301
  private inString(from: number, context: TypingContext, index: number): EnterPlan | null {
290
302
  const { source, options } = this;
291
- const operator = concatenation.get(options.language.toLowerCase());
303
+ const operator = concatenation.get(context.embedded ?? options.language.toLowerCase());
292
304
  const quote = context.quote;
293
305
  if (operator === undefined || (quote !== '"' && quote !== "'")) {
294
306
  return null;
package/src/index.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 {
package/src/languages.ts CHANGED
@@ -11,6 +11,7 @@ export interface CommentSyntax {
11
11
 
12
12
  /* The part of a Vue single file component a line sits in. */
13
13
  export type VueRegion = 'template' | 'script' | 'style';
14
+ export type EditRegion = VueRegion | 'jsx' | 'mixed';
14
15
 
15
16
  const slashStar: BlockComment = { open: '/*', close: '*/' };
16
17
  const html: BlockComment = { open: '<!--', close: '-->' };
@@ -45,9 +46,15 @@ register('haskell hs', { line: '--', block: { open: '{-', close: '-}' } });
45
46
  register('plaintext text txt log', none);
46
47
 
47
48
  /* An id nothing knows has no comment syntax, which toggling a comment answers by doing nothing. */
48
- export function commentSyntax(language: string, region?: VueRegion | null): CommentSyntax {
49
+ export function commentSyntax(language: string, region?: EditRegion | null): CommentSyntax {
49
50
  const id = language.toLowerCase();
50
- if (id === 'vue') {
51
+ if (region === 'mixed') {
52
+ return none;
53
+ }
54
+ if (region === 'jsx') {
55
+ return { line: null, block: { open: '{/*', close: '*/}' } };
56
+ }
57
+ if (id === 'vue' || (id === 'php' && region !== null && region !== undefined)) {
51
58
  if (region === 'script') {
52
59
  return cLike;
53
60
  }
@@ -15,14 +15,17 @@ const CLOSERS: Readonly<Record<string, string>> = { '{': '}', '(': ')', '[': ']'
15
15
  */
16
16
  class JsxScanner {
17
17
  readonly ranges: { from: number; to: number }[] = [];
18
+ readonly children: { from: number; to: number }[] = [];
19
+ private readonly collectChildren: boolean;
18
20
  /* The quoted values of attributes, quotes included. */
19
21
  readonly values: { from: number; to: number }[] = [];
20
22
  private readonly text: string;
21
23
  private depth = 0;
22
24
  private budget: number;
23
25
 
24
- constructor(text: string) {
26
+ constructor(text: string, collectChildren = false) {
25
27
  this.text = text;
28
+ this.collectChildren = collectChildren;
26
29
  this.budget = text.length * 12 + 1_000;
27
30
  }
28
31
 
@@ -178,15 +181,35 @@ class JsxScanner {
178
181
 
179
182
  /* An element from its `<`, recorded when no element holds it, and the offset after its closing tag; -1 when it is not one. */
180
183
  private element(from: number, outermost: boolean): number {
184
+ const children = this.children.length;
185
+ const previousEnd = this.children.at(-1)?.to;
181
186
  this.depth++;
182
187
  const end = this.parseElement(from);
183
188
  this.depth--;
189
+ if (end < 0) {
190
+ this.children.length = children;
191
+ if (previousEnd !== undefined) {
192
+ this.children[children - 1]!.to = previousEnd;
193
+ }
194
+ }
184
195
  if (end >= 0 && outermost && this.depth === 0) {
185
196
  this.ranges.push({ from, to: end });
186
197
  }
187
198
  return end;
188
199
  }
189
200
 
201
+ private child(from: number, to: number): void {
202
+ if (!this.collectChildren || from >= to) {
203
+ return;
204
+ }
205
+ const previous = this.children.at(-1);
206
+ if (previous !== undefined && previous.to === from) {
207
+ previous.to = to;
208
+ } else {
209
+ this.children.push({ from, to });
210
+ }
211
+ }
212
+
190
213
  private skipSpace(from: number): number {
191
214
  let at = from;
192
215
  while (at < this.text.length && /\s/.test(this.text[at]!)) {
@@ -208,6 +231,22 @@ class JsxScanner {
208
231
  if (char === undefined || --this.budget < 0) {
209
232
  return -1;
210
233
  }
234
+ if (char === '/' && text[at + 1] === '/') {
235
+ const end = text.indexOf('\n', at + 2);
236
+ if (end < 0) {
237
+ return -1;
238
+ }
239
+ at = end + 1;
240
+ continue;
241
+ }
242
+ if (char === '/' && text[at + 1] === '*') {
243
+ const end = text.indexOf('*/', at + 2);
244
+ if (end < 0) {
245
+ return -1;
246
+ }
247
+ at = end + 2;
248
+ continue;
249
+ }
211
250
  if (char === '/' && text[at + 1] === '>') {
212
251
  return at + 2;
213
252
  }
@@ -253,14 +292,21 @@ class JsxScanner {
253
292
  }
254
293
  }
255
294
  for (;;) {
295
+ const start = at;
256
296
  while (at < text.length && text[at] !== '<' && text[at] !== '{') {
257
297
  at++;
258
298
  }
299
+ this.child(start, text[at] === '<' ? at + 1 : at);
259
300
  if (at >= text.length || --this.budget < 0) {
260
301
  return -1;
261
302
  }
262
303
  if (text[at] === '{') {
304
+ this.child(at, at + 1);
305
+ const from = at;
263
306
  at = this.code(at + 1, '}');
307
+ if (at >= 0 && /^\{\s*\/\*/.test(text.slice(from, from + 20))) {
308
+ this.child(from + 1, at);
309
+ }
264
310
  } else if (text[at + 1] === '/') {
265
311
  const closing = /^<\/\s*([\w$.:-]*)\s*>/.exec(text.slice(at, at + 200));
266
312
  return closing !== null && closing[1] === name ? at + closing[0].length : -1;
@@ -301,6 +347,13 @@ function insideAny(ranges: readonly { from: number; to: number }[], offset: numb
301
347
  return false;
302
348
  }
303
349
 
350
+ /* Child text and nested tags use JSX comments; attributes and embedded expressions stay code. */
351
+ export function jsxChildren(text: string): (offset: number) => boolean {
352
+ const scanner = new JsxScanner(text, true);
353
+ scanner.scan();
354
+ return (offset) => insideAny(scanner.children, offset);
355
+ }
356
+
304
357
  /* Whether a language is one whose scripts can hold JSX. */
305
358
  export function hasJsx(language: string): boolean {
306
359
  return JSX_LANGUAGES.test(language);