@adecore/editor-core 0.0.1 → 0.17.0-beta.1
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/LICENSE +110 -0
- package/LICENSE.apache-2.0.txt +201 -0
- package/NOTICE +11 -0
- package/README.md +54 -0
- package/dist/block-folds.d.ts +3 -0
- package/dist/block-folds.js +112 -0
- package/dist/brackets.d.ts +5 -0
- package/dist/brackets.js +136 -0
- package/dist/closer-tracker.d.ts +9 -0
- package/dist/closer-tracker.js +46 -0
- package/dist/comments.d.ts +14 -0
- package/dist/comments.js +344 -0
- package/dist/document.d.ts +148 -0
- package/dist/document.js +1729 -0
- package/dist/edit-source.d.ts +19 -0
- package/dist/edit-source.js +1 -0
- package/dist/enter.d.ts +17 -0
- package/dist/enter.js +387 -0
- package/dist/fold-roles.d.ts +6 -0
- package/dist/fold-roles.js +142 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +7 -0
- package/dist/languages.d.ts +12 -0
- package/dist/languages.js +51 -0
- package/dist/lexical-folds.d.ts +10 -0
- package/dist/lexical-folds.js +116 -0
- package/dist/lexical.d.ts +16 -0
- package/dist/lexical.js +74 -0
- package/dist/line-commands.d.ts +9 -0
- package/dist/line-commands.js +306 -0
- package/dist/markup-regions.d.ts +8 -0
- package/dist/markup-regions.js +331 -0
- package/dist/offsets.d.ts +2 -0
- package/dist/offsets.js +14 -0
- package/dist/paste.d.ts +9 -0
- package/dist/paste.js +108 -0
- package/dist/preserve-case.d.ts +1 -0
- package/dist/preserve-case.js +61 -0
- package/dist/rope.d.ts +22 -0
- package/dist/rope.js +246 -0
- package/dist/search.d.ts +26 -0
- package/dist/search.js +85 -0
- package/dist/structure.d.ts +50 -0
- package/dist/structure.js +124 -0
- package/dist/text-span.d.ts +7 -0
- package/dist/text-span.js +211 -0
- package/dist/types.d.ts +70 -0
- package/dist/types.js +1 -0
- package/dist/typing-context.d.ts +30 -0
- package/dist/typing-context.js +270 -0
- package/dist/typing-handlers.d.ts +15 -0
- package/dist/typing-handlers.js +125 -0
- package/dist/words.d.ts +7 -0
- package/dist/words.js +72 -0
- package/package.json +34 -3
- package/src/block-folds.ts +117 -0
- package/src/brackets.ts +159 -0
- package/src/closer-tracker.ts +60 -0
- package/src/comments.ts +411 -0
- package/src/document.ts +2021 -0
- package/src/edit-source.ts +25 -0
- package/src/enter.ts +452 -0
- package/src/fold-roles.ts +166 -0
- package/src/index.ts +22 -0
- package/src/languages.ts +77 -0
- package/src/lexical-folds.ts +138 -0
- package/src/lexical.ts +113 -0
- package/src/line-commands.ts +356 -0
- package/src/markup-regions.ts +333 -0
- package/src/offsets.ts +16 -0
- package/src/paste.ts +126 -0
- package/src/preserve-case.ts +62 -0
- package/src/rope.ts +278 -0
- package/src/search.ts +119 -0
- package/src/structure.ts +223 -0
- package/src/text-span.ts +230 -0
- package/src/types.ts +141 -0
- package/src/typing-context.ts +329 -0
- package/src/typing-handlers.ts +143 -0
- package/src/words.ts +83 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
const quotes = /["'`]/;
|
|
2
|
+
export function isQuote(character) {
|
|
3
|
+
return quotes.test(character);
|
|
4
|
+
}
|
|
5
|
+
/* Every quote of a body with whether a backslash escapes it. */
|
|
6
|
+
function quotesIn(body, selected, typed) {
|
|
7
|
+
const found = [];
|
|
8
|
+
let escaped = false;
|
|
9
|
+
for (let index = 0; index < body.length; index++) {
|
|
10
|
+
const character = body[index];
|
|
11
|
+
if (escaped) {
|
|
12
|
+
if (character === selected || character === typed) {
|
|
13
|
+
found.push({ index, escaped: true });
|
|
14
|
+
}
|
|
15
|
+
escaped = false;
|
|
16
|
+
}
|
|
17
|
+
else if (character === '\\') {
|
|
18
|
+
escaped = true;
|
|
19
|
+
}
|
|
20
|
+
else if (character === selected || character === typed) {
|
|
21
|
+
found.push({ index, escaped: false });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return found;
|
|
25
|
+
}
|
|
26
|
+
/* The string that has the quote at `offset` for its opening or its closing quote, on that line. */
|
|
27
|
+
function stringAround(source, offset, quote) {
|
|
28
|
+
const line = source.line(source.lineAt(offset));
|
|
29
|
+
const before = source.context(offset);
|
|
30
|
+
const after = source.context(offset + 1);
|
|
31
|
+
const inString = (mode) => mode === 'quote' || mode === 'template';
|
|
32
|
+
if (before.mode === 'code' && inString(after.mode) && after.quote === quote) {
|
|
33
|
+
const text = source.slice(offset + 1, line.end);
|
|
34
|
+
for (let at = 0; at < text.length; at++) {
|
|
35
|
+
if (text[at] === '\\') {
|
|
36
|
+
at++;
|
|
37
|
+
}
|
|
38
|
+
else if (text[at] === quote) {
|
|
39
|
+
return { start: offset, end: offset + 1 + at };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
if (inString(before.mode) && before.quote === quote && !before.escaped && after.mode === 'code') {
|
|
45
|
+
const text = source.slice(line.start, offset);
|
|
46
|
+
for (let at = text.length - 1; at >= 0; at--) {
|
|
47
|
+
let slashes = 0;
|
|
48
|
+
while (at - slashes - 1 >= 0 && text[at - slashes - 1] === '\\') {
|
|
49
|
+
slashes++;
|
|
50
|
+
}
|
|
51
|
+
if (text[at] === quote && slashes % 2 === 0) {
|
|
52
|
+
return { start: line.start + at, end: offset };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
/*
|
|
59
|
+
* A quote typed over a selected quote of a string turns that string's own quotes into the typed
|
|
60
|
+
* one, escaping the typed quote inside it and unescaping the old one.
|
|
61
|
+
*/
|
|
62
|
+
export function swapQuotes(source, offset, typed) {
|
|
63
|
+
const selected = source.charAt(offset);
|
|
64
|
+
if (selected === typed || !isQuote(selected) || !isQuote(typed)) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
const span = stringAround(source, offset, selected);
|
|
68
|
+
if (!span) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
const body = source.slice(span.start + 1, span.end);
|
|
72
|
+
const inside = quotesIn(body, selected, typed);
|
|
73
|
+
const interpolated = selected === '`' && body.includes('${');
|
|
74
|
+
if (interpolated && inside.length > 0) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
let rewritten = body;
|
|
78
|
+
if (!interpolated) {
|
|
79
|
+
let shift = 0;
|
|
80
|
+
for (const { index, escaped } of inside) {
|
|
81
|
+
const character = body[index];
|
|
82
|
+
if (character === typed && !escaped) {
|
|
83
|
+
rewritten = rewritten.slice(0, index + shift) + '\\' + rewritten.slice(index + shift);
|
|
84
|
+
shift++;
|
|
85
|
+
}
|
|
86
|
+
else if (character === selected && escaped) {
|
|
87
|
+
rewritten = rewritten.slice(0, index + shift - 1) + rewritten.slice(index + shift);
|
|
88
|
+
shift--;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const text = typed + rewritten + typed;
|
|
93
|
+
return { from: span.start, to: span.end + 1, text, caret: offset === span.start ? 1 : text.length };
|
|
94
|
+
}
|
|
95
|
+
const comparison = /^[<>=!]+$/;
|
|
96
|
+
/* Whether typing `<` or `>` over this selection replaces an operator, as `<` over `==` does. */
|
|
97
|
+
export function replacesComparison(typed, selected, inCommentOrString) {
|
|
98
|
+
return (typed === '<' || typed === '>') && selected.length <= 3 && comparison.test(selected) && !inCommentOrString;
|
|
99
|
+
}
|
|
100
|
+
const closerOf = { '(': ')', '[': ']', '{': '}', '<': '>', '"': '"', "'": "'", '`': '`' };
|
|
101
|
+
export function matchingDelimiter(character) {
|
|
102
|
+
return closerOf[character] ?? character;
|
|
103
|
+
}
|
|
104
|
+
function similarDelimiters(left, right) {
|
|
105
|
+
const bracket = (character) => /[([{<]/.test(character);
|
|
106
|
+
return (bracket(left) && bracket(right)) || (isQuote(left) && isQuote(right));
|
|
107
|
+
}
|
|
108
|
+
/*
|
|
109
|
+
* What a delimiter typed over a selection makes of it: the selection between the new delimiters. A
|
|
110
|
+
* selection that already has delimiters of the same kind swaps them for the typed ones.
|
|
111
|
+
*/
|
|
112
|
+
export function surround(selected, typed) {
|
|
113
|
+
let inner = selected;
|
|
114
|
+
if (selected.length > 1) {
|
|
115
|
+
const first = selected[0];
|
|
116
|
+
const last = selected.at(-1);
|
|
117
|
+
if (similarDelimiters(first, typed) &&
|
|
118
|
+
last === matchingDelimiter(first) &&
|
|
119
|
+
(isQuote(first) || first !== typed) &&
|
|
120
|
+
selected.indexOf(last, 1) === selected.length - 1) {
|
|
121
|
+
inner = selected.slice(1, -1);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return { text: typed + inner + matchingDelimiter(typed), inner };
|
|
125
|
+
}
|
package/dist/words.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export type WordText = string | {
|
|
2
|
+
readonly length: number;
|
|
3
|
+
charAt(offset: number): string;
|
|
4
|
+
};
|
|
5
|
+
export declare function isHumpBoundary(text: WordText, offset: number, isStart: boolean): boolean;
|
|
6
|
+
export declare function isWordBoundary(text: WordText, offset: number, camel: boolean, isStart: boolean): boolean;
|
|
7
|
+
export declare function wordBoundary(text: WordText, offset: number, direction: -1 | 1, camel?: boolean): number;
|
package/dist/words.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Copyright 2000-2023 JetBrains s.r.o. and contributors.
|
|
2
|
+
// Adapted to TypeScript under Apache-2.0; see NOTICE, README.md and LICENSE.apache-2.0.txt.
|
|
3
|
+
//
|
|
4
|
+
// isWordBoundary, isHumpBoundary and isPunctuation adapt `isWordBoundary`, `isHumpBound`,
|
|
5
|
+
// `isLowerCaseOrDigit` and `isPunctuation` from JetBrains/intellij-community at
|
|
6
|
+
// 7184b03af6c5370e79a01ded0df68cf56d7669da, platform/platform-impl/src/com/intellij/openapi/editor/actions/EditorActionUtil.java,
|
|
7
|
+
// lines 934-982.
|
|
8
|
+
// Java's Character.isJavaIdentifierPart, which counts the ignorable control characters as part of a word.
|
|
9
|
+
// oxlint-disable-next-line no-control-regex
|
|
10
|
+
const identifierPart = /[\p{L}\p{Nl}\p{Sc}\p{Pc}\p{Nd}\p{Mc}\p{Mn}\p{Cf}\u0000-\u0008\u000e-\u001b\u007f-\u009f]/u;
|
|
11
|
+
// Java's Character.isWhitespace: no no-break spaces.
|
|
12
|
+
// oxlint-disable-next-line no-control-regex
|
|
13
|
+
const whitespace = /[\t\n\v\f\r\u001c-\u0020\u1680\u2000-\u2006\u2008-\u200a\u2028\u2029\u205f\u3000]/u;
|
|
14
|
+
const lower = /\p{Lowercase}/u;
|
|
15
|
+
const upper = /\p{Uppercase}/u;
|
|
16
|
+
const digit = /\p{Nd}/u;
|
|
17
|
+
const letterOrDigit = /[\p{L}\p{Nd}]/u;
|
|
18
|
+
function characterAt(text, offset) {
|
|
19
|
+
return offset >= 0 && offset < text.length ? text.charAt(offset) : '\0';
|
|
20
|
+
}
|
|
21
|
+
function isPunctuation(character) {
|
|
22
|
+
return !(identifierPart.test(character) || whitespace.test(character));
|
|
23
|
+
}
|
|
24
|
+
/* Whether `offset` splits a camel hump, a digit run or an underscore from the word around it. */
|
|
25
|
+
export function isHumpBoundary(text, offset, isStart) {
|
|
26
|
+
if (offset <= 0 || offset >= text.length) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
const previous = characterAt(text, offset - 1);
|
|
30
|
+
const current = characterAt(text, offset);
|
|
31
|
+
const next = characterAt(text, offset + 1);
|
|
32
|
+
const hump = isStart ? current : previous;
|
|
33
|
+
const neighbor = isStart ? previous : current;
|
|
34
|
+
return (((lower.test(previous) || digit.test(previous)) && upper.test(current)) ||
|
|
35
|
+
(neighbor === '_' && hump !== '_') ||
|
|
36
|
+
(neighbor === '$' && letterOrDigit.test(hump)) ||
|
|
37
|
+
(upper.test(previous) && upper.test(current) && lower.test(next)));
|
|
38
|
+
}
|
|
39
|
+
/* Whether a word starts (`isStart`) or ends at `offset`. Punctuation runs count as words. */
|
|
40
|
+
export function isWordBoundary(text, offset, camel, isStart) {
|
|
41
|
+
if (offset < 0 || offset > text.length) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
const previous = characterAt(text, offset - 1);
|
|
45
|
+
const current = characterAt(text, offset);
|
|
46
|
+
const word = isStart ? current : previous;
|
|
47
|
+
const neighbor = isStart ? previous : current;
|
|
48
|
+
if (identifierPart.test(word)) {
|
|
49
|
+
if (!identifierPart.test(neighbor)) {
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
if (camel && isHumpBoundary(text, offset, isStart)) {
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return isPunctuation(word) && !isPunctuation(neighbor);
|
|
57
|
+
}
|
|
58
|
+
/* The next stop in `direction`, never inside a surrogate pair or between CR and LF. Written here, not ported. */
|
|
59
|
+
export function wordBoundary(text, offset, direction, camel = true) {
|
|
60
|
+
let position = offset + direction;
|
|
61
|
+
while (position > 0 && position < text.length) {
|
|
62
|
+
const before = text.charAt(position - 1);
|
|
63
|
+
const after = text.charAt(position);
|
|
64
|
+
const splitsPair = /[\ud800-\udbff]/.test(before) && /[\udc00-\udfff]/.test(after);
|
|
65
|
+
const splitsLineBreak = before === '\r' && after === '\n';
|
|
66
|
+
if (!splitsPair && !splitsLineBreak && isWordBoundary(text, position, camel, direction === -1)) {
|
|
67
|
+
return position;
|
|
68
|
+
}
|
|
69
|
+
position += direction;
|
|
70
|
+
}
|
|
71
|
+
return Math.max(0, Math.min(text.length, position));
|
|
72
|
+
}
|
package/package.json
CHANGED
|
@@ -1,11 +1,42 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adecore/editor-core",
|
|
3
|
-
"version": "0.0.1",
|
|
4
|
-
"
|
|
3
|
+
"version": "0.17.0-beta.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": {
|
|
7
|
+
"source": "./src/index.ts",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"default": "./dist/index.js"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"typecheck": "tsc --noEmit",
|
|
14
|
+
"benchmark": "bun --expose-gc scripts/benchmark.ts",
|
|
15
|
+
"build": "bun scripts/build.ts",
|
|
16
|
+
"test": "bun test --conditions=source"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@types/bun": "^1.4.2",
|
|
20
|
+
"typescript": "^7.0.2"
|
|
21
|
+
},
|
|
5
22
|
"license": "FSL-1.1-MIT",
|
|
23
|
+
"author": "Bas Milius",
|
|
6
24
|
"repository": {
|
|
7
25
|
"type": "git",
|
|
8
26
|
"url": "git+https://github.com/basmilius/adecore.git",
|
|
9
27
|
"directory": "packages/editor-core"
|
|
10
|
-
}
|
|
28
|
+
},
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/basmilius/adecore/issues"
|
|
31
|
+
},
|
|
32
|
+
"homepage": "https://adecore.dev/editor-core/",
|
|
33
|
+
"sideEffects": false,
|
|
34
|
+
"files": [
|
|
35
|
+
"dist",
|
|
36
|
+
"src",
|
|
37
|
+
"!src/**/*.test.ts",
|
|
38
|
+
"LICENSE",
|
|
39
|
+
"NOTICE",
|
|
40
|
+
"LICENSE.apache-2.0.txt"
|
|
41
|
+
]
|
|
11
42
|
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { rangeOf } from './lexical-folds.ts';
|
|
2
|
+
import type { DocumentLine } from './rope.ts';
|
|
3
|
+
import type { FoldingRange } from './structure.ts';
|
|
4
|
+
|
|
5
|
+
const MARKDOWN = /^(markdown|md|mdx)$/i;
|
|
6
|
+
const HEADING = /^(#{1,6})\s+\S/;
|
|
7
|
+
const FENCE = /^\s*(`{3,}|~{3,})/;
|
|
8
|
+
const TABLE_ROW = /^\s*\|/;
|
|
9
|
+
const HEREDOC = /<<<[ \t]*(["']?)([A-Za-z_]\w*)\1\s*$/;
|
|
10
|
+
|
|
11
|
+
/*
|
|
12
|
+
* Front matter, code fences, tables and the sections under headings. A fence closes on a line of the same
|
|
13
|
+
* character and at least as long, and what a fence holds is never read as a heading or a table.
|
|
14
|
+
*/
|
|
15
|
+
function markdownFolds(lineCount: number, getLine: (line: number) => DocumentLine): FoldingRange[] {
|
|
16
|
+
const result: FoldingRange[] = [];
|
|
17
|
+
const headings: { line: number; level: number }[] = [];
|
|
18
|
+
let first = 0;
|
|
19
|
+
if (lineCount > 0 && getLine(0).text.trim() === '---') {
|
|
20
|
+
for (let line = 1; line < lineCount; line++) {
|
|
21
|
+
const text = getLine(line).text.trim();
|
|
22
|
+
if (text === '---' || text === '...') {
|
|
23
|
+
result.push(rangeOf(0, line, getLine, 'block', 'front-matter'));
|
|
24
|
+
first = line + 1;
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
let tableStart = -1;
|
|
30
|
+
const flushTable = (end: number): void => {
|
|
31
|
+
if (tableStart !== -1 && end > tableStart) {
|
|
32
|
+
result.push(rangeOf(tableStart, end, getLine, 'block', 'table'));
|
|
33
|
+
}
|
|
34
|
+
tableStart = -1;
|
|
35
|
+
};
|
|
36
|
+
for (let line = first; line < lineCount; line++) {
|
|
37
|
+
const text = getLine(line).text;
|
|
38
|
+
const fence = FENCE.exec(text);
|
|
39
|
+
if (fence !== null) {
|
|
40
|
+
flushTable(line - 1);
|
|
41
|
+
const marker = fence[1]!;
|
|
42
|
+
let end = -1;
|
|
43
|
+
for (let next = line + 1; next < lineCount && end === -1; next++) {
|
|
44
|
+
const closer = FENCE.exec(getLine(next).text);
|
|
45
|
+
if (closer !== null && closer[1]![0] === marker[0] && closer[1]!.length >= marker.length && getLine(next).text.trim() === closer[1]) {
|
|
46
|
+
end = next;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (end !== -1) {
|
|
50
|
+
result.push(rangeOf(line, end, getLine, 'block', 'code-fence'));
|
|
51
|
+
line = end;
|
|
52
|
+
}
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (TABLE_ROW.test(text)) {
|
|
56
|
+
tableStart = tableStart === -1 ? line : tableStart;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
flushTable(line - 1);
|
|
60
|
+
const heading = HEADING.exec(text);
|
|
61
|
+
if (heading !== null) {
|
|
62
|
+
headings.push({ line, level: heading[1]!.length });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
flushTable(lineCount - 1);
|
|
66
|
+
for (const [index, heading] of headings.entries()) {
|
|
67
|
+
const next = headings.slice(index + 1).find((candidate) => candidate.level <= heading.level);
|
|
68
|
+
let end = (next?.line ?? lineCount) - 1;
|
|
69
|
+
while (end > heading.line && getLine(end).text.trim() === '') {
|
|
70
|
+
end--;
|
|
71
|
+
}
|
|
72
|
+
if (end > heading.line) {
|
|
73
|
+
result.push(rangeOf(heading.line, end, getLine, 'section'));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/* `<?php ... ?>` blocks that sit between markup, and heredocs and nowdocs. A block that opens the file is the file, not a fold. */
|
|
80
|
+
function phpFolds(lineCount: number, getLine: (line: number) => DocumentLine): FoldingRange[] {
|
|
81
|
+
const result: FoldingRange[] = [];
|
|
82
|
+
let open = -1;
|
|
83
|
+
for (let line = 0; line < lineCount; line++) {
|
|
84
|
+
const text = getLine(line).text;
|
|
85
|
+
const heredoc = HEREDOC.exec(text);
|
|
86
|
+
if (heredoc !== null) {
|
|
87
|
+
const closer = new RegExp(`^\\s*${heredoc[2]}\\b`);
|
|
88
|
+
for (let next = line + 1; next < lineCount; next++) {
|
|
89
|
+
if (closer.test(getLine(next).text)) {
|
|
90
|
+
result.push(rangeOf(line, next, getLine, 'block', 'heredoc'));
|
|
91
|
+
line = next;
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const opened = open === -1 ? text.search(/<\?(?:php\b|=)/) : -1;
|
|
98
|
+
const closed = text.indexOf('?>', Math.max(0, opened));
|
|
99
|
+
if (opened !== -1 && closed === -1) {
|
|
100
|
+
open = line === 0 ? -1 : line;
|
|
101
|
+
} else if (open !== -1 && closed !== -1) {
|
|
102
|
+
if (line > open) {
|
|
103
|
+
result.push(rangeOf(open, line, getLine, 'block', 'php-tag'));
|
|
104
|
+
}
|
|
105
|
+
open = -1;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/* The folds of whole lines that a language has beyond brackets and indentation. */
|
|
112
|
+
export function blockFolds(lineCount: number, getLine: (line: number) => DocumentLine, language: string): FoldingRange[] {
|
|
113
|
+
if (MARKDOWN.test(language)) {
|
|
114
|
+
return markdownFolds(lineCount, getLine);
|
|
115
|
+
}
|
|
116
|
+
return language.toLowerCase() === 'php' ? phpFolds(lineCount, getLine) : [];
|
|
117
|
+
}
|
package/src/brackets.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import {
|
|
2
|
+
hasBlockComments,
|
|
3
|
+
hasDashComments,
|
|
4
|
+
hasHashComments,
|
|
5
|
+
hashNeedsWordStart,
|
|
6
|
+
hasMarkupComments,
|
|
7
|
+
hasSlashComments,
|
|
8
|
+
isPhp,
|
|
9
|
+
isScript,
|
|
10
|
+
isWordCharacter,
|
|
11
|
+
openers,
|
|
12
|
+
startsExpression,
|
|
13
|
+
startsExpressionAfter,
|
|
14
|
+
startsWord
|
|
15
|
+
} from './lexical.ts';
|
|
16
|
+
|
|
17
|
+
export interface BracketIndex {
|
|
18
|
+
/* Both directions: an opener maps to its closer and the closer back to the opener. */
|
|
19
|
+
pairs: ReadonlyMap<number, number>;
|
|
20
|
+
unmatched: ReadonlySet<number>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface OpenBracket {
|
|
24
|
+
at: number;
|
|
25
|
+
close: string;
|
|
26
|
+
/* An `${` whose closer resumes the template text. */
|
|
27
|
+
template?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function skipLine(text: string, at: number): number {
|
|
31
|
+
const end = text.indexOf('\n', at + 1);
|
|
32
|
+
return end < 0 ? text.length : end;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function skipComment(text: string, from: number, terminator: string): number {
|
|
36
|
+
const end = text.indexOf(terminator, from);
|
|
37
|
+
return end < 0 ? text.length : end + terminator.length - 1;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function skipString(text: string, at: number, quote: string, endsAtLineBreak: boolean): number {
|
|
41
|
+
let cursor = at + 1;
|
|
42
|
+
for (; cursor < text.length; cursor++) {
|
|
43
|
+
if (text[cursor] === '\\') {
|
|
44
|
+
cursor++;
|
|
45
|
+
} else if (text[cursor] === quote || (endsAtLineBreak && /[\r\n]/.test(text[cursor]))) {
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return cursor;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function skipRegex(text: string, at: number): number {
|
|
53
|
+
let inClass = false;
|
|
54
|
+
let cursor = at + 1;
|
|
55
|
+
for (; cursor < text.length; cursor++) {
|
|
56
|
+
const character = text[cursor];
|
|
57
|
+
if (character === '\\') {
|
|
58
|
+
cursor++;
|
|
59
|
+
} else if (character === '[') {
|
|
60
|
+
inClass = true;
|
|
61
|
+
} else if (character === ']') {
|
|
62
|
+
inClass = false;
|
|
63
|
+
} else if ((character === '/' && !inClass) || /[\r\n]/.test(character)) {
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return cursor;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/* Lexical pairing for incomplete code; malformed nesting never produces crossing pairs. */
|
|
71
|
+
export function scanBrackets(text: string, language = 'typescript'): BracketIndex {
|
|
72
|
+
const pairs = new Map<number, number>();
|
|
73
|
+
const unmatched = new Set<number>();
|
|
74
|
+
const stack: OpenBracket[] = [];
|
|
75
|
+
const script = isScript(language);
|
|
76
|
+
const hashComments = hasHashComments(language);
|
|
77
|
+
const hashAtWordStart = hashNeedsWordStart(language);
|
|
78
|
+
const dashComments = hasDashComments(language);
|
|
79
|
+
const phpAttributes = isPhp(language);
|
|
80
|
+
const slashComments = hasSlashComments(language);
|
|
81
|
+
const blockComments = hasBlockComments(language);
|
|
82
|
+
const markupComments = hasMarkupComments(language);
|
|
83
|
+
let inTemplate = false;
|
|
84
|
+
let expectsExpression = true;
|
|
85
|
+
|
|
86
|
+
for (let at = 0; at < text.length; at++) {
|
|
87
|
+
const char = text[at];
|
|
88
|
+
const next = text[at + 1];
|
|
89
|
+
|
|
90
|
+
if (inTemplate) {
|
|
91
|
+
if (char === '\\') {
|
|
92
|
+
at++;
|
|
93
|
+
} else if (char === '`') {
|
|
94
|
+
inTemplate = false;
|
|
95
|
+
expectsExpression = false;
|
|
96
|
+
} else if (char === '$' && next === '{') {
|
|
97
|
+
stack.push({ at: ++at, close: '}', template: true });
|
|
98
|
+
inTemplate = false;
|
|
99
|
+
expectsExpression = true;
|
|
100
|
+
}
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (/\s/.test(char)) {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (markupComments && text.startsWith('<!--', at)) {
|
|
109
|
+
at = skipComment(text, at + 4, '-->');
|
|
110
|
+
} else if (
|
|
111
|
+
(slashComments && char === '/' && next === '/') ||
|
|
112
|
+
(dashComments && char === '-' && next === '-') ||
|
|
113
|
+
(hashComments && char === '#' && !(phpAttributes && next === '[') && (!hashAtWordStart || startsWord(text, at)))
|
|
114
|
+
) {
|
|
115
|
+
at = skipLine(text, at);
|
|
116
|
+
} else if (blockComments && char === '/' && next === '*') {
|
|
117
|
+
at = skipComment(text, at + 2, '*/');
|
|
118
|
+
} else if (char === '"' || char === "'") {
|
|
119
|
+
at = skipString(text, at, char, script);
|
|
120
|
+
expectsExpression = false;
|
|
121
|
+
} else if (script && char === '`') {
|
|
122
|
+
inTemplate = true;
|
|
123
|
+
} else if (script && char === '/' && expectsExpression) {
|
|
124
|
+
at = skipRegex(text, at);
|
|
125
|
+
expectsExpression = false;
|
|
126
|
+
} else if (openers[char]) {
|
|
127
|
+
stack.push({ at, close: openers[char] });
|
|
128
|
+
expectsExpression = true;
|
|
129
|
+
} else if (/[)\]}]/.test(char)) {
|
|
130
|
+
const open = stack.at(-1);
|
|
131
|
+
if (open?.close === char) {
|
|
132
|
+
stack.pop();
|
|
133
|
+
pairs.set(open.at, at);
|
|
134
|
+
pairs.set(at, open.at);
|
|
135
|
+
inTemplate = open.template === true;
|
|
136
|
+
} else {
|
|
137
|
+
unmatched.add(at);
|
|
138
|
+
// A mismatched closer keeps a distant opener from claiming it later.
|
|
139
|
+
for (const abandoned of stack.splice(0)) {
|
|
140
|
+
unmatched.add(abandoned.at);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
expectsExpression = false;
|
|
144
|
+
} else if (isWordCharacter(char)) {
|
|
145
|
+
const start = at;
|
|
146
|
+
while (at + 1 < text.length && isWordCharacter(text[at + 1])) {
|
|
147
|
+
at++;
|
|
148
|
+
}
|
|
149
|
+
expectsExpression = startsExpression(text.slice(start, at + 1));
|
|
150
|
+
} else {
|
|
151
|
+
expectsExpression = startsExpressionAfter(char);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
for (const open of stack) {
|
|
156
|
+
unmatched.add(open.at);
|
|
157
|
+
}
|
|
158
|
+
return { pairs, unmatched };
|
|
159
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Selection, TextEdit } from './types.ts';
|
|
2
|
+
|
|
3
|
+
interface Mark {
|
|
4
|
+
open: number;
|
|
5
|
+
close: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const limit = 64;
|
|
9
|
+
|
|
10
|
+
/*
|
|
11
|
+
* The closers the editor inserted by itself and the caret has not left yet, which Tab steps over. A
|
|
12
|
+
* mark follows its opener and closer through edits and goes as soon as an edit replaces either one
|
|
13
|
+
* or no caret sits inside the pair any more.
|
|
14
|
+
*/
|
|
15
|
+
export class CloserTracker {
|
|
16
|
+
private marks: Mark[] = [];
|
|
17
|
+
|
|
18
|
+
add(open: number, close: number): void {
|
|
19
|
+
this.marks.push({ open, close });
|
|
20
|
+
if (this.marks.length > limit) {
|
|
21
|
+
this.marks.shift();
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/* The closer that sits at `offset`, which a Tab there steps over. */
|
|
26
|
+
isCloserAt(offset: number): boolean {
|
|
27
|
+
return this.marks.some((mark) => mark.close === offset);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
map(edits: readonly TextEdit[]): void {
|
|
31
|
+
this.marks = this.marks.flatMap((mark) => {
|
|
32
|
+
const open = mapThrough(mark.open, edits);
|
|
33
|
+
const close = mapThrough(mark.close, edits);
|
|
34
|
+
return open === null || close === null ? [] : [{ open, close }];
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
prune(selections: readonly Selection[]): void {
|
|
39
|
+
this.marks = this.marks.filter((mark) => selections.some((selection) => selection.head > mark.open && selection.head <= mark.close));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
clear(): void {
|
|
43
|
+
this.marks = [];
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/* Null when an edit replaced the character at `offset`. */
|
|
48
|
+
function mapThrough(offset: number, edits: readonly TextEdit[]): number | null {
|
|
49
|
+
let delta = 0;
|
|
50
|
+
for (const edit of edits) {
|
|
51
|
+
if (offset < edit.from) {
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
if (offset < edit.to) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
delta += edit.text.length - (edit.to - edit.from);
|
|
58
|
+
}
|
|
59
|
+
return offset + delta;
|
|
60
|
+
}
|