@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 +67 -14
- package/dist/doc-comments.d.ts +10 -0
- package/dist/doc-comments.js +85 -0
- package/dist/document.d.ts +8 -0
- package/dist/document.js +111 -29
- package/dist/edit-regions.d.ts +6 -0
- package/dist/edit-regions.js +87 -0
- package/dist/enter.js +24 -12
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/languages.d.ts +2 -1
- package/dist/languages.js +7 -1
- package/dist/markup-regions.d.ts +1 -0
- package/dist/markup-regions.js +53 -1
- package/dist/offsets.d.ts +1 -0
- package/dist/offsets.js +23 -0
- package/dist/php-markup.d.ts +7 -0
- package/dist/php-markup.js +99 -0
- package/dist/rope.d.ts +6 -0
- package/dist/rope.js +33 -4
- package/dist/script-source.d.ts +2 -0
- package/dist/script-source.js +44 -0
- package/dist/search.d.ts +3 -1
- package/dist/search.js +67 -12
- package/dist/types.d.ts +3 -0
- package/dist/typing-context.d.ts +8 -0
- package/dist/typing-context.js +70 -22
- package/package.json +1 -1
- package/src/comments.ts +70 -15
- package/src/doc-comments.ts +104 -0
- package/src/document.ts +131 -31
- package/src/edit-regions.ts +96 -0
- package/src/enter.ts +25 -13
- package/src/index.ts +2 -1
- package/src/languages.ts +9 -2
- package/src/markup-regions.ts +54 -1
- package/src/offsets.ts +24 -0
- package/src/php-markup.ts +104 -0
- package/src/rope.ts +35 -5
- package/src/script-source.ts +43 -0
- package/src/search.ts +69 -12
- package/src/types.ts +6 -0
- package/src/typing-context.ts +85 -22
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
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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
|
|
490
|
-
|
|
491
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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?:
|
|
49
|
+
export function commentSyntax(language: string, region?: EditRegion | null): CommentSyntax {
|
|
49
50
|
const id = language.toLowerCase();
|
|
50
|
-
if (
|
|
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
|
}
|
package/src/markup-regions.ts
CHANGED
|
@@ -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);
|