@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.
Files changed (80) hide show
  1. package/LICENSE +110 -0
  2. package/LICENSE.apache-2.0.txt +201 -0
  3. package/NOTICE +11 -0
  4. package/README.md +54 -0
  5. package/dist/block-folds.d.ts +3 -0
  6. package/dist/block-folds.js +112 -0
  7. package/dist/brackets.d.ts +5 -0
  8. package/dist/brackets.js +136 -0
  9. package/dist/closer-tracker.d.ts +9 -0
  10. package/dist/closer-tracker.js +46 -0
  11. package/dist/comments.d.ts +14 -0
  12. package/dist/comments.js +344 -0
  13. package/dist/document.d.ts +148 -0
  14. package/dist/document.js +1729 -0
  15. package/dist/edit-source.d.ts +19 -0
  16. package/dist/edit-source.js +1 -0
  17. package/dist/enter.d.ts +17 -0
  18. package/dist/enter.js +387 -0
  19. package/dist/fold-roles.d.ts +6 -0
  20. package/dist/fold-roles.js +142 -0
  21. package/dist/index.d.ts +10 -0
  22. package/dist/index.js +7 -0
  23. package/dist/languages.d.ts +12 -0
  24. package/dist/languages.js +51 -0
  25. package/dist/lexical-folds.d.ts +10 -0
  26. package/dist/lexical-folds.js +116 -0
  27. package/dist/lexical.d.ts +16 -0
  28. package/dist/lexical.js +74 -0
  29. package/dist/line-commands.d.ts +9 -0
  30. package/dist/line-commands.js +306 -0
  31. package/dist/markup-regions.d.ts +8 -0
  32. package/dist/markup-regions.js +331 -0
  33. package/dist/offsets.d.ts +2 -0
  34. package/dist/offsets.js +14 -0
  35. package/dist/paste.d.ts +9 -0
  36. package/dist/paste.js +108 -0
  37. package/dist/preserve-case.d.ts +1 -0
  38. package/dist/preserve-case.js +61 -0
  39. package/dist/rope.d.ts +22 -0
  40. package/dist/rope.js +246 -0
  41. package/dist/search.d.ts +26 -0
  42. package/dist/search.js +85 -0
  43. package/dist/structure.d.ts +50 -0
  44. package/dist/structure.js +124 -0
  45. package/dist/text-span.d.ts +7 -0
  46. package/dist/text-span.js +211 -0
  47. package/dist/types.d.ts +70 -0
  48. package/dist/types.js +1 -0
  49. package/dist/typing-context.d.ts +30 -0
  50. package/dist/typing-context.js +270 -0
  51. package/dist/typing-handlers.d.ts +15 -0
  52. package/dist/typing-handlers.js +125 -0
  53. package/dist/words.d.ts +7 -0
  54. package/dist/words.js +72 -0
  55. package/package.json +34 -3
  56. package/src/block-folds.ts +117 -0
  57. package/src/brackets.ts +159 -0
  58. package/src/closer-tracker.ts +60 -0
  59. package/src/comments.ts +411 -0
  60. package/src/document.ts +2021 -0
  61. package/src/edit-source.ts +25 -0
  62. package/src/enter.ts +452 -0
  63. package/src/fold-roles.ts +166 -0
  64. package/src/index.ts +22 -0
  65. package/src/languages.ts +77 -0
  66. package/src/lexical-folds.ts +138 -0
  67. package/src/lexical.ts +113 -0
  68. package/src/line-commands.ts +356 -0
  69. package/src/markup-regions.ts +333 -0
  70. package/src/offsets.ts +16 -0
  71. package/src/paste.ts +126 -0
  72. package/src/preserve-case.ts +62 -0
  73. package/src/rope.ts +278 -0
  74. package/src/search.ts +119 -0
  75. package/src/structure.ts +223 -0
  76. package/src/text-span.ts +230 -0
  77. package/src/types.ts +141 -0
  78. package/src/typing-context.ts +329 -0
  79. package/src/typing-handlers.ts +143 -0
  80. package/src/words.ts +83 -0
@@ -0,0 +1,77 @@
1
+ export interface BlockComment {
2
+ open: string;
3
+ close: string;
4
+ }
5
+
6
+ /* Either marker can be missing: CSS has no line comments and Python no block comments. */
7
+ export interface CommentSyntax {
8
+ line: string | null;
9
+ block: BlockComment | null;
10
+ }
11
+
12
+ /* The part of a Vue single file component a line sits in. */
13
+ export type VueRegion = 'template' | 'script' | 'style';
14
+
15
+ const slashStar: BlockComment = { open: '/*', close: '*/' };
16
+ const html: BlockComment = { open: '<!--', close: '-->' };
17
+
18
+ const cLike: CommentSyntax = { line: '//', block: slashStar };
19
+ const hash: CommentSyntax = { line: '#', block: null };
20
+ const markup: CommentSyntax = { line: null, block: html };
21
+ const none: CommentSyntax = { line: null, block: null };
22
+
23
+ const syntaxes = new Map<string, CommentSyntax>();
24
+
25
+ function register(ids: string, syntax: CommentSyntax): void {
26
+ for (const id of ids.split(' ')) {
27
+ syntaxes.set(id, syntax);
28
+ }
29
+ }
30
+
31
+ register(
32
+ 'typescript javascript typescriptreact javascriptreact tsx jsx ts js mjs cjs mts cts java c cpp c++ csharp cs go rust rs swift kotlin kt scala dart php scss less zig groq json jsonc json5 prisma astro svelte objective-c objc glsl hlsl proto protobuf hcl terraform',
33
+ cLike
34
+ );
35
+ register('css', { line: null, block: slashStar });
36
+ register('html xml svg xsl markdown md mdx vue', markup);
37
+ register(
38
+ 'python py yaml yml shellscript shell sh bash zsh fish toml ruby rb perl r make makefile docker dockerfile dotenv ignore nix graphql powershell ps1 properties julia jl elixir ex exs coffeescript coffee cmake',
39
+ hash
40
+ );
41
+ register('ini', { line: ';', block: null });
42
+ register('sql', { line: '--', block: slashStar });
43
+ register('lua', { line: '--', block: { open: '--[[', close: ']]' } });
44
+ register('haskell hs', { line: '--', block: { open: '{-', close: '-}' } });
45
+ register('plaintext text txt log', none);
46
+
47
+ /* 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
+ const id = language.toLowerCase();
50
+ if (id === 'vue') {
51
+ if (region === 'script') {
52
+ return cLike;
53
+ }
54
+ return region === 'style' ? { line: null, block: slashStar } : markup;
55
+ }
56
+ return syntaxes.get(id) ?? none;
57
+ }
58
+
59
+ /* Plain text has no structure to pair, indent or comment. */
60
+ export function isPlainText(language: string): boolean {
61
+ return /^(plaintext|text|txt|log)$/i.test(language);
62
+ }
63
+
64
+ /* The region of a `.vue` file a line is in, from the top-level tags above it. */
65
+ export function vueRegionAt(lineText: (line: number) => string, line: number): VueRegion | null {
66
+ let region: VueRegion | null = null;
67
+ for (let index = 0; index <= line; index++) {
68
+ const text = lineText(index);
69
+ const open = /^<(template|script|style)\b/.exec(text);
70
+ if (open) {
71
+ region = open[1] as VueRegion;
72
+ } else if (/^<\/(template|script|style)\s*>/.test(text) && index < line) {
73
+ region = null;
74
+ }
75
+ }
76
+ return region;
77
+ }
@@ -0,0 +1,138 @@
1
+ import { commentSyntax } from './languages.ts';
2
+ import type { FoldRole, FoldingRange } from './structure.ts';
3
+ import type { DocumentLine } from './rope.ts';
4
+
5
+ /* The words a line starts with when it brings another file in, by language. A language not listed here has no import list to fold. */
6
+ const IMPORT_WORDS = new Map<string, RegExp>();
7
+
8
+ function register(languages: string, words: string): void {
9
+ // The word must stand alone, and `import(` and `import.meta` are expressions and not an import.
10
+ const pattern = new RegExp(`^(?:${words})\\b(?![(.=,;:])`);
11
+ for (const language of languages.split(' ')) {
12
+ IMPORT_WORDS.set(language, pattern);
13
+ }
14
+ }
15
+
16
+ register(
17
+ 'typescript javascript typescriptreact javascriptreact tsx jsx ts js mjs cjs mts cts vue svelte astro java kotlin scala groovy go swift dart haskell elm',
18
+ 'import'
19
+ );
20
+ register('python py', 'import|from');
21
+ register('php rust rs perl', 'use');
22
+ register('csharp cs', 'using');
23
+
24
+ /* Whether a line, without its indentation, brings another file in. */
25
+ export function isImportLine(language: string, trimmed: string): boolean {
26
+ return IMPORT_WORDS.get(language.toLowerCase())?.test(trimmed) ?? false;
27
+ }
28
+
29
+ const REGION_START = /^\s*(?:\/\/|#|<!--|\/\*|--)\s*(?:#\s*)?region\b/;
30
+ const REGION_END = /^\s*(?:\/\/|#|<!--|\/\*|--)\s*(?:#\s*)?endregion\b/;
31
+
32
+ export interface LexicalFoldOptions {
33
+ /* Each on unless false. */
34
+ imports?: boolean;
35
+ regions?: boolean;
36
+ lineComments?: boolean;
37
+ }
38
+
39
+ function bracketDelta(text: string): number {
40
+ let depth = 0;
41
+ for (const character of text) {
42
+ if (character === '{' || character === '(' || character === '[') {
43
+ depth++;
44
+ } else if (character === '}' || character === ')' || character === ']') {
45
+ depth--;
46
+ }
47
+ }
48
+ return depth;
49
+ }
50
+
51
+ /* A fold of whole lines, from the end of the first to the end of the last. */
52
+ export function rangeOf(
53
+ startLine: number,
54
+ endLine: number,
55
+ getLine: (line: number) => DocumentLine,
56
+ kind: FoldingRange['kind'],
57
+ role?: FoldRole
58
+ ): FoldingRange {
59
+ const start = getLine(startLine);
60
+ return {
61
+ startLine,
62
+ endLine,
63
+ from: start.start + (start.text.length - start.text.trimStart().length),
64
+ to: getLine(endLine).end,
65
+ kind,
66
+ ...(role === undefined ? {} : { role })
67
+ };
68
+ }
69
+
70
+ /*
71
+ * The folds that come from how lines begin and need no syntax tree: a run of lines that import, a run
72
+ * of lines of line comments, and what sits between a `// region` line and its `// endregion`.
73
+ */
74
+ export function lexicalFolds(lineCount: number, getLine: (line: number) => DocumentLine, language: string, options: LexicalFoldOptions = {}): FoldingRange[] {
75
+ const result: FoldingRange[] = [];
76
+ const id = language.toLowerCase();
77
+ const words = options.imports === false ? undefined : IMPORT_WORDS.get(id);
78
+ const marker = options.lineComments === false ? null : commentSyntax(id).line;
79
+ const regions: number[] = [];
80
+ let importStart = -1;
81
+ let importEnd = -1;
82
+ let commentStart = -1;
83
+ let commentEnd = -1;
84
+ const flushImports = (): void => {
85
+ if (importStart !== -1 && importEnd > importStart) {
86
+ result.push(rangeOf(importStart, importEnd, getLine, 'imports', 'imports'));
87
+ }
88
+ importStart = -1;
89
+ };
90
+ const flushComments = (): void => {
91
+ if (commentStart !== -1 && commentEnd > commentStart) {
92
+ result.push(rangeOf(commentStart, commentEnd, getLine, 'line-comments'));
93
+ }
94
+ commentStart = -1;
95
+ };
96
+ for (let line = 0; line < lineCount; line++) {
97
+ const text = getLine(line).text;
98
+ const trimmed = text.trimStart();
99
+ const isRegionMarker = options.regions !== false && (REGION_START.test(text) || REGION_END.test(text));
100
+ if (isRegionMarker) {
101
+ flushComments();
102
+ if (REGION_START.test(text)) {
103
+ regions.push(line);
104
+ } else {
105
+ const start = regions.pop();
106
+ if (start !== undefined && line > start) {
107
+ result.push(rangeOf(start, line, getLine, 'region', 'region'));
108
+ }
109
+ }
110
+ continue;
111
+ }
112
+ if (marker !== null && trimmed.startsWith(marker)) {
113
+ commentStart = commentStart === -1 ? line : commentStart;
114
+ commentEnd = line;
115
+ } else {
116
+ flushComments();
117
+ }
118
+ if (words === undefined) {
119
+ continue;
120
+ }
121
+ if (words.test(trimmed)) {
122
+ let last = line;
123
+ let depth = bracketDelta(text);
124
+ while (depth > 0 && last + 1 < lineCount) {
125
+ last++;
126
+ depth += bracketDelta(getLine(last).text);
127
+ }
128
+ importStart = importStart === -1 ? line : importStart;
129
+ importEnd = last;
130
+ line = last;
131
+ } else if (trimmed !== '' && !(marker !== null && trimmed.startsWith(marker))) {
132
+ flushImports();
133
+ }
134
+ }
135
+ flushComments();
136
+ flushImports();
137
+ return result;
138
+ }
package/src/lexical.ts ADDED
@@ -0,0 +1,113 @@
1
+ export const openers: Readonly<Record<string, string>> = { '(': ')', '[': ']', '{': '}' };
2
+
3
+ /* Words after which a `/` starts a regex and a `{` an object or a block, not a division or a statement end. */
4
+ const expressionWords = /^(return|throw|case|yield|await|new|delete|typeof|void|in|of|instanceof)$/;
5
+
6
+ /* Whether a `#` at this index stands where a word would start, after whitespace, a separator or an opener. */
7
+ export function startsWord(text: string, at: number): boolean {
8
+ return at === 0 || /[\s;|&(]/.test(text[at - 1]!);
9
+ }
10
+
11
+ /*
12
+ * What a language's comments and braces look like to the lexer. The lists are positive: a language
13
+ * nobody listed has no comment syntax, so a `/*` in a YAML value or a `#` in shell never reads as
14
+ * the wrong comment for the rest of the file. The ids are the ones Shiki gives the editor.
15
+ */
16
+ interface LanguageRules {
17
+ slash: boolean;
18
+ /* `any` is a comment wherever it stands, `boundary` only at the start of a word, as in shell and YAML. */
19
+ hash: false | 'any' | 'boundary';
20
+ dash: boolean;
21
+ block: boolean;
22
+ markup: boolean;
23
+ /* Blocks are in braces, so a statement may be left open and a `case` label opens a block. */
24
+ braced: boolean;
25
+ }
26
+
27
+ const NO_RULES: LanguageRules = { slash: false, hash: false, dash: false, block: false, markup: false, braced: false };
28
+ const languageRules = new Map<string, LanguageRules>();
29
+
30
+ function register(ids: string, rules: Partial<LanguageRules>): void {
31
+ for (const id of ids.split(' ')) {
32
+ languageRules.set(id, { ...NO_RULES, ...rules });
33
+ }
34
+ }
35
+
36
+ register(
37
+ 'typescript javascript typescriptreact javascriptreact tsx jsx ts js mjs cjs mts cts java c cpp c++ csharp cs go rust rs swift kotlin kt scala dart zig groq prisma astro svelte objective-c objc glsl hlsl proto protobuf scss less jsonc json5',
38
+ { slash: true, block: true, braced: true }
39
+ );
40
+ register('php', { slash: true, hash: 'any', block: true, braced: true });
41
+ register('vue', { slash: true, block: true, markup: true, braced: true });
42
+ register('hcl terraform', { slash: true, hash: 'any', block: true, braced: true });
43
+ register('css', { block: true });
44
+ register('html xml svg xsl markdown md mdx', { markup: true });
45
+ register('python py toml ruby rb dotenv ignore nix powershell ps1 properties julia jl elixir ex exs coffeescript coffee', { hash: 'any' });
46
+ register('shellscript shell sh bash zsh fish yaml yml make makefile docker dockerfile cmake', { hash: 'boundary' });
47
+ register('perl r graphql', { hash: 'any', braced: true });
48
+ register('sql', { dash: true, block: true });
49
+ register('lua haskell hs', { dash: true });
50
+
51
+ function rulesOf(language: string): LanguageRules {
52
+ return languageRules.get(language.toLowerCase()) ?? NO_RULES;
53
+ }
54
+
55
+ export function isScript(language: string): boolean {
56
+ return /^(typescript|javascript|typescriptreact|javascriptreact|tsx|jsx|ts|js|vue)$/i.test(language);
57
+ }
58
+
59
+ export function isPhp(language: string): boolean {
60
+ return /^php$/i.test(language);
61
+ }
62
+
63
+ export function hasHashComments(language: string): boolean {
64
+ return rulesOf(language).hash !== false;
65
+ }
66
+
67
+ /* Whether a `#` only starts a comment at the start of a word, which keeps `$#` and `${#list}` in shell code. */
68
+ export function hashNeedsWordStart(language: string): boolean {
69
+ return rulesOf(language).hash === 'boundary';
70
+ }
71
+
72
+ export function hasDashComments(language: string): boolean {
73
+ return rulesOf(language).dash;
74
+ }
75
+
76
+ export function hasBlockComments(language: string): boolean {
77
+ return rulesOf(language).block;
78
+ }
79
+
80
+ export function hasSlashComments(language: string): boolean {
81
+ return rulesOf(language).slash;
82
+ }
83
+
84
+ export function hasMarkupComments(language: string): boolean {
85
+ return rulesOf(language).markup;
86
+ }
87
+
88
+ /* Languages whose blocks are in braces: where a statement can be left open and a `case` label opens a block. */
89
+ export function isBraced(language: string): boolean {
90
+ return rulesOf(language).braced;
91
+ }
92
+
93
+ /* Languages whose indentation follows their brackets, which Auto-indent Lines can work out. */
94
+ export function indentsByBrackets(language: string): boolean {
95
+ return (isBraced(language) && !/^(svelte|astro)$/i.test(language)) || /^css$/i.test(language);
96
+ }
97
+
98
+ export function hasSmartSemicolon(language: string): boolean {
99
+ return /^(typescript|javascript|typescriptreact|javascriptreact|tsx|jsx|ts|js|php)$/i.test(language);
100
+ }
101
+
102
+ export function isWordCharacter(character: string): boolean {
103
+ return /[\p{L}\p{N}_$]/u.test(character);
104
+ }
105
+
106
+ export function startsExpression(word: string): boolean {
107
+ return expressionWords.test(word);
108
+ }
109
+
110
+ /* Whether what follows a punctuation character is an expression, so a `/` there is a regex. */
111
+ export function startsExpressionAfter(character: string): boolean {
112
+ return /[=,:;!?+\-*%&|^~<>]/.test(character);
113
+ }
@@ -0,0 +1,356 @@
1
+ import type { EditPlan, EditSource } from './edit-source.ts';
2
+ import { commentSyntax } from './languages.ts';
3
+ import { continues } from './enter.ts';
4
+ import { indentsByBrackets } from './lexical.ts';
5
+ import { markupGuard } from './markup-regions.ts';
6
+ import { mapOffset } from './offsets.ts';
7
+ import type { Selection, TextEdit } from './types.ts';
8
+ import type { TypingContext } from './typing-context.ts';
9
+
10
+ export interface LineCommandOptions {
11
+ language: string;
12
+ /* One level of indentation. */
13
+ unit: string;
14
+ }
15
+
16
+ const identifier = /[\p{L}\p{N}\p{M}\p{Pc}$]/u;
17
+
18
+ function rangeOf(selection: Selection): { from: number; to: number } {
19
+ return { from: Math.min(selection.anchor, selection.head), to: Math.max(selection.anchor, selection.head) };
20
+ }
21
+
22
+ function whitespaceOf(text: string): string {
23
+ return /^[\t ]*/.exec(text)![0];
24
+ }
25
+
26
+ function isBlank(text: string): boolean {
27
+ return text.trim() === '';
28
+ }
29
+
30
+ /* How far the edits in front of an offset moved it. An edit that starts at the offset is not in front of it. */
31
+ function shiftBefore(offset: number, edits: readonly TextEdit[]): number {
32
+ return edits.filter((edit) => edit.from < offset).reduce((sum, edit) => sum + edit.text.length - (Math.min(edit.to, offset) - edit.from), 0);
33
+ }
34
+
35
+ /* The offset of the first character after `from` that is not whitespace, line breaks included. */
36
+ function skipWhitespace(source: EditSource, from: number): number {
37
+ const last = source.line(source.lineCount - 1).end;
38
+ let offset = from;
39
+ while (offset < last && /\s/.test(source.charAt(offset))) {
40
+ offset++;
41
+ }
42
+ return offset;
43
+ }
44
+
45
+ /* What goes where two lines meet: nothing before a closer, a comma or a dot and after an opener, else a space. */
46
+ function spacingBetween(previous: string, next: string): string {
47
+ if (next === '' || /^[)\],;]/.test(next) || /^\.(?![.\d])/.test(next) || /[([]$/.test(previous)) {
48
+ return '';
49
+ }
50
+ return ' ';
51
+ }
52
+
53
+ interface Join {
54
+ from: number;
55
+ to: number;
56
+ text: string;
57
+ /* Where the caret goes in the text, which is where the lines were split. */
58
+ caret: number;
59
+ }
60
+
61
+ /* The comment a line ends in, as its start and the marker it opens with. */
62
+ function endComment(source: EditSource, options: LineCommandOptions, line: number): { start: number; marker: string } | null {
63
+ const bounds = source.line(line);
64
+ const syntax = commentSyntax(options.language, source.region(line));
65
+ const context = source.context(bounds.end);
66
+ if (context.mode !== 'line-comment' || context.commentStart === undefined || context.commentStart < bounds.start || syntax.line === null) {
67
+ return null;
68
+ }
69
+ return { start: context.commentStart, marker: syntax.line };
70
+ }
71
+
72
+ /* One join of a line with the next content after it. */
73
+ function joinAfter(source: EditSource, options: LineCommandOptions, index: number): Join | null {
74
+ const left = source.line(index);
75
+ const next = skipWhitespace(source, left.end);
76
+ const trimmedEnd = left.start + left.text.trimEnd().length;
77
+ const nextLine = source.line(source.lineAt(next));
78
+ const content = source.slice(next, nextLine.end).trimEnd();
79
+ if (isBlank(left.text)) {
80
+ return { from: left.start, to: nextLine.start, text: '', caret: whitespaceOf(nextLine.text).length };
81
+ }
82
+ const comment = endComment(source, options, index);
83
+ if (comment) {
84
+ const marker = comment.marker.trimEnd();
85
+ if (content.startsWith(marker)) {
86
+ const rest = content.slice(marker.length);
87
+ const dropped = next + marker.length + (rest.startsWith(' ') ? 1 : 0);
88
+ const remainder = rest.startsWith(' ') ? rest.slice(1) : rest;
89
+ return { from: trimmedEnd, to: dropped, text: remainder === '' ? '' : ' ', caret: 0 };
90
+ }
91
+ }
92
+ return { from: trimmedEnd, to: next, text: spacingBetween(source.slice(left.start, trimmedEnd), content), caret: 0 };
93
+ }
94
+
95
+ /*
96
+ * Joins each line with the next, or the lines of a selection into one. The break and the whitespace
97
+ * around it become a space, a `//` comment line joined to another loses its second marker and the
98
+ * end of line comment of a line that code follows becomes a block comment.
99
+ */
100
+ export function planJoinLines(source: EditSource, selections: readonly Selection[], options: LineCommandOptions): EditPlan | null {
101
+ interface Range {
102
+ first: number;
103
+ count: number;
104
+ }
105
+ const ranges: Range[] = [];
106
+ for (const selection of selections) {
107
+ const { from, to } = rangeOf(selection);
108
+ const first = source.lineAt(from);
109
+ let last = from === to ? first + 1 : source.lineAt(to);
110
+ if (from !== to && last > first && source.line(last).start === to) {
111
+ last--;
112
+ }
113
+ if (last >= source.lineCount) {
114
+ continue;
115
+ }
116
+ const previous = ranges.at(-1);
117
+ if (previous && first <= previous.first + previous.count) {
118
+ previous.count = Math.max(previous.count, last - previous.first);
119
+ } else {
120
+ ranges.push({ first, count: last - first });
121
+ }
122
+ }
123
+ const edits: TextEdit[] = [];
124
+ const carets: { at: number; extra: number }[] = [];
125
+ for (const range of ranges) {
126
+ let line = range.first;
127
+ for (let done = 0; done < range.count && line < source.lineCount - 1; done++) {
128
+ const join = joinAfter(source, options, line);
129
+ if (!join) {
130
+ break;
131
+ }
132
+ const comment = endComment(source, options, line);
133
+ const nextStart = skipWhitespace(source, source.line(line).end);
134
+ const nextLine = source.line(source.lineAt(nextStart));
135
+ const converts =
136
+ comment !== null &&
137
+ !isBlank(source.slice(source.line(line).start, comment.start)) &&
138
+ !source.slice(nextStart, nextLine.end).startsWith(comment.marker.trimEnd()) &&
139
+ commentSyntax(options.language, source.region(line)).block?.open === '/*';
140
+ if (converts && comment) {
141
+ const body = source.slice(comment.start + comment.marker.length, join.from);
142
+ if (!body.includes('*/')) {
143
+ edits.push({ from: comment.start, to: comment.start + comment.marker.length, text: '/*' });
144
+ const closing = (body.startsWith(' ') && !body.endsWith(' ') ? ' ' : '') + '*/';
145
+ edits.push({ from: join.from, to: join.to, text: closing + join.text });
146
+ carets.push({ at: join.from, extra: closing.length });
147
+ line = source.lineAt(nextStart);
148
+ continue;
149
+ }
150
+ }
151
+ edits.push({ from: join.from, to: join.to, text: join.text });
152
+ carets.push({ at: join.from, extra: join.caret });
153
+ line = source.lineAt(nextStart);
154
+ }
155
+ }
156
+ if (edits.length === 0) {
157
+ return null;
158
+ }
159
+ edits.sort((left, right) => left.from - right.from);
160
+ const result = selections.map((selection) => {
161
+ const { from, to } = rangeOf(selection);
162
+ if (from !== to) {
163
+ const end = mapOffset(to, edits);
164
+ return { anchor: end, head: end };
165
+ }
166
+ const line = source.line(source.lineAt(from));
167
+ const caret = carets.find((entry) => entry.at >= line.start && entry.at <= line.end);
168
+ const base = caret ? caret.at + shiftBefore(caret.at, edits) + caret.extra : mapOffset(from, edits);
169
+ return { anchor: base, head: base };
170
+ });
171
+ return { edits, selections: result };
172
+ }
173
+
174
+ /* The identifier characters around an offset. */
175
+ function wordAround(source: EditSource, offset: number): { from: number; to: number } {
176
+ const line = source.line(source.lineAt(offset));
177
+ const column = offset - line.start;
178
+ let from = column;
179
+ let to = column;
180
+ while (from > 0 && identifier.test(line.text[from - 1]!)) {
181
+ from--;
182
+ }
183
+ while (to < line.text.length && identifier.test(line.text[to]!)) {
184
+ to++;
185
+ }
186
+ return { from: line.start + from, to: line.start + to };
187
+ }
188
+
189
+ /* A string escape such as `\n` stays as written. */
190
+ function convertCase(text: string, lower: boolean, inString: boolean): string {
191
+ const convert = (part: string): string => (lower ? part.toLowerCase() : part.toUpperCase());
192
+ if (!inString) {
193
+ return convert(text);
194
+ }
195
+ return text
196
+ .split(/(\\(?:u\{[0-9a-fA-F]+\}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|.))/)
197
+ .map((part, index) => (index % 2 === 1 ? part : convert(part)))
198
+ .join('');
199
+ }
200
+
201
+ /*
202
+ * Flips the case of what is selected, or of the word at a caret with nothing selected, which stays
203
+ * selected. Text with any capital turns to lower case, and text without turns to upper case; with
204
+ * several carets one answer goes for all of them.
205
+ */
206
+ export function planToggleCase(source: EditSource, selections: readonly Selection[]): EditPlan | null {
207
+ const targets = selections.map((selection) => {
208
+ const { from, to } = rangeOf(selection);
209
+ return from === to ? { ...wordAround(source, from), word: true } : { from, to, word: false };
210
+ });
211
+ const texts = targets.map((target) => source.slice(target.from, target.to));
212
+ const lower = texts.some((text) => text !== text.toLowerCase());
213
+ const edits: TextEdit[] = [];
214
+ const result: Selection[] = [];
215
+ let delta = 0;
216
+ for (const [index, target] of targets.entries()) {
217
+ const context: TypingContext = source.context(target.from);
218
+ const inString = context.mode === 'quote' || context.mode === 'template';
219
+ const converted = convertCase(texts[index]!, lower, inString);
220
+ if (converted !== texts[index]) {
221
+ edits.push({ from: target.from, to: target.to, text: converted });
222
+ }
223
+ const selection = selections[index]!;
224
+ const reversed = selection.anchor > selection.head;
225
+ const start = target.from + delta;
226
+ const end = target.from + delta + converted.length;
227
+ result.push(target.word || !reversed ? { anchor: start, head: end } : { anchor: end, head: start });
228
+ delta += converted.length - texts[index]!.length;
229
+ }
230
+ if (targets.every((target) => target.from === target.to)) {
231
+ return null;
232
+ }
233
+ return { edits, selections: result };
234
+ }
235
+
236
+ interface Frame {
237
+ opener: number;
238
+ closer: string;
239
+ }
240
+
241
+ /* The brackets still open at the start of a line, innermost first. */
242
+ function frames(context: TypingContext): Frame[] {
243
+ const result: Frame[] = [];
244
+ for (let bracket = context.bracket; bracket; bracket = bracket.previous) {
245
+ result.push({ opener: bracket.at, closer: bracket.close });
246
+ }
247
+ return result;
248
+ }
249
+
250
+ const caseLabel = /^(?:case\b[^]*|default)\s*:$/;
251
+
252
+ /*
253
+ * Sets the indentation of the lines to what their brackets say: a line is one level in from the line
254
+ * its innermost bracket opened on, a closing bracket lines up with it, the lines of a case follow
255
+ * their label and a line after an unfinished statement goes in once. Lines in comments and strings,
256
+ * and in languages that are not bracketed, are left alone.
257
+ */
258
+ export function planAutoIndent(source: EditSource, lines: readonly number[], options: LineCommandOptions): EditPlan | null {
259
+ if (!indentsByBrackets(options.language)) {
260
+ return null;
261
+ }
262
+ const indents = new Map<number, string>();
263
+ const indentOf = (line: number): string => indents.get(line) ?? whitespaceOf(source.line(line).text);
264
+ const edits: TextEdit[] = [];
265
+ const isMarkup = markupGuard(source, options.language);
266
+ for (const line of lines) {
267
+ const bounds = source.line(line);
268
+ const context = source.context(bounds.start);
269
+ if (isBlank(bounds.text) || context.mode !== 'code' || isMarkup(line)) {
270
+ continue;
271
+ }
272
+ const target = targetIndent(source, options, line, context, indentOf);
273
+ indents.set(line, target);
274
+ const current = whitespaceOf(bounds.text);
275
+ if (current !== target) {
276
+ edits.push({ from: bounds.start, to: bounds.start + current.length, text: target });
277
+ }
278
+ }
279
+ return edits.length === 0 ? null : { edits, selections: [] };
280
+ }
281
+
282
+ function targetIndent(source: EditSource, options: LineCommandOptions, line: number, context: TypingContext, indentOf: (line: number) => string): string {
283
+ const text = source.line(line).text.trimStart();
284
+ const open = frames(context);
285
+ const innermost = open[0];
286
+ if (innermost && text.startsWith(innermost.closer)) {
287
+ return indentOf(source.lineAt(innermost.opener));
288
+ }
289
+ let base = innermost ? indentOf(source.lineAt(innermost.opener)) + options.unit : '';
290
+ if (innermost?.closer === '}' && !caseLabel.test(text)) {
291
+ const label = openCaseLabel(source, line, context.bracket);
292
+ if (label !== null) {
293
+ base = indentOf(label) + options.unit;
294
+ }
295
+ }
296
+ const previous = previousCodeLine(source, line);
297
+ if (previous !== null && source.context(source.line(previous).start).bracket === context.bracket && lineContinues(source, options, previous)) {
298
+ return isContinuation(source, options, previous) ? indentOf(previous) : indentOf(previous) + options.unit;
299
+ }
300
+ return base;
301
+ }
302
+
303
+ function lineContinues(source: EditSource, options: LineCommandOptions, line: number): boolean {
304
+ const bounds = source.line(line);
305
+ const text = bounds.text.trimEnd();
306
+ return text.length > 0 && continues(text, source.context(bounds.start + text.length), options.language);
307
+ }
308
+
309
+ /* Whether the line goes on with a statement the line before it left open. */
310
+ function isContinuation(source: EditSource, options: LineCommandOptions, line: number): boolean {
311
+ const previous = previousCodeLine(source, line);
312
+ return (
313
+ previous !== null &&
314
+ source.context(source.line(previous).start).bracket === source.context(source.line(line).start).bracket &&
315
+ lineContinues(source, options, previous)
316
+ );
317
+ }
318
+
319
+ function previousCodeLine(source: EditSource, line: number): number | null {
320
+ for (let candidate = line - 1; candidate >= 0; candidate--) {
321
+ if (!isBlank(source.line(candidate).text)) {
322
+ return candidate;
323
+ }
324
+ }
325
+ return null;
326
+ }
327
+
328
+ /* Whether `bracket` is `outer` or sits inside it. */
329
+ function within(bracket: TypingContext['bracket'], outer: TypingContext['bracket']): boolean {
330
+ for (let current = bracket; ; current = current.previous) {
331
+ if (current === outer) {
332
+ return true;
333
+ }
334
+ if (!current) {
335
+ return false;
336
+ }
337
+ }
338
+ }
339
+
340
+ /* The line of the `case` label the lines of this block run under, if there is one. */
341
+ function openCaseLabel(source: EditSource, line: number, block: TypingContext['bracket']): number | null {
342
+ for (let candidate = line - 1; candidate >= 0 && candidate >= line - 200; candidate--) {
343
+ const bounds = source.line(candidate);
344
+ if (isBlank(bounds.text)) {
345
+ continue;
346
+ }
347
+ const state = source.context(bounds.start).bracket;
348
+ if (!within(state, block)) {
349
+ return null;
350
+ }
351
+ if (state === block && caseLabel.test(bounds.text.trim())) {
352
+ return candidate;
353
+ }
354
+ }
355
+ return null;
356
+ }