@adecore/editor 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.
Files changed (94) hide show
  1. package/dist/attribution.d.ts +9 -8
  2. package/dist/attribution.js +44 -9
  3. package/dist/bidi.d.ts +44 -0
  4. package/dist/bidi.js +26 -0
  5. package/dist/column-selection.js +2 -1
  6. package/dist/controller.d.ts +6 -1
  7. package/dist/controller.js +93 -3
  8. package/dist/editor.css +81 -1
  9. package/dist/engine.js +184 -41
  10. package/dist/fake.d.ts +43 -5
  11. package/dist/fake.js +117 -13
  12. package/dist/find-search.d.ts +26 -0
  13. package/dist/find-search.js +130 -0
  14. package/dist/find.d.ts +50 -2
  15. package/dist/find.js +575 -25
  16. package/dist/geometry-stops.d.ts +6 -0
  17. package/dist/geometry-stops.js +47 -0
  18. package/dist/grammar-preparation.d.ts +29 -0
  19. package/dist/grammar-preparation.js +147 -0
  20. package/dist/index.d.ts +3 -1
  21. package/dist/index.js +2 -0
  22. package/dist/keymap-table.d.ts +10 -1
  23. package/dist/keymap-table.js +37 -1
  24. package/dist/keymap.d.ts +1 -0
  25. package/dist/keymap.js +4 -1
  26. package/dist/layout-changes.d.ts +6 -0
  27. package/dist/layout-changes.js +27 -0
  28. package/dist/layout.d.ts +45 -2
  29. package/dist/layout.js +202 -58
  30. package/dist/offsets.d.ts +1 -0
  31. package/dist/offsets.js +23 -0
  32. package/dist/outline.js +5 -4
  33. package/dist/paint.d.ts +11 -3
  34. package/dist/paint.js +180 -34
  35. package/dist/search-client.d.ts +27 -0
  36. package/dist/search-client.js +210 -0
  37. package/dist/search-protocol.d.ts +103 -0
  38. package/dist/search-protocol.js +3 -0
  39. package/dist/search-worker.d.ts +11 -0
  40. package/dist/search-worker.js +137 -0
  41. package/dist/semantic.d.ts +2 -1
  42. package/dist/semantic.js +6 -1
  43. package/dist/shiki-document.d.ts +8 -0
  44. package/dist/shiki-document.js +93 -0
  45. package/dist/shiki-worker.d.ts +65 -0
  46. package/dist/shiki-worker.js +265 -0
  47. package/dist/shiki.d.ts +1 -0
  48. package/dist/shiki.js +96 -33
  49. package/dist/testing.d.ts +1 -0
  50. package/dist/tokens.d.ts +2 -0
  51. package/dist/tokens.js +37 -7
  52. package/dist/touch.d.ts +27 -0
  53. package/dist/touch.js +100 -0
  54. package/dist/types.d.ts +84 -2
  55. package/dist/uniform-geometry.d.ts +7 -0
  56. package/dist/uniform-geometry.js +49 -0
  57. package/dist/view.d.ts +68 -17
  58. package/dist/view.js +424 -97
  59. package/dist/worker-client.d.ts +37 -0
  60. package/dist/worker-client.js +195 -0
  61. package/package.json +17 -2
  62. package/src/attribution.ts +50 -14
  63. package/src/bidi.ts +32 -0
  64. package/src/column-selection.ts +2 -1
  65. package/src/controller.ts +97 -3
  66. package/src/editor.css +81 -1
  67. package/src/engine.ts +216 -42
  68. package/src/fake.ts +152 -14
  69. package/src/find-search.ts +165 -0
  70. package/src/find.ts +622 -21
  71. package/src/geometry-stops.ts +51 -0
  72. package/src/grammar-preparation.ts +158 -0
  73. package/src/index.ts +10 -0
  74. package/src/keymap-table.ts +44 -2
  75. package/src/keymap.ts +5 -1
  76. package/src/layout-changes.ts +33 -0
  77. package/src/layout.ts +262 -63
  78. package/src/offsets.ts +24 -0
  79. package/src/outline.ts +5 -4
  80. package/src/paint.ts +202 -36
  81. package/src/search-client.ts +306 -0
  82. package/src/search-protocol.ts +100 -0
  83. package/src/search-worker.ts +171 -0
  84. package/src/semantic.ts +8 -2
  85. package/src/shiki-document.ts +143 -0
  86. package/src/shiki-worker.ts +344 -0
  87. package/src/shiki.ts +103 -33
  88. package/src/testing.ts +2 -0
  89. package/src/tokens.ts +36 -7
  90. package/src/touch.ts +134 -0
  91. package/src/types.ts +174 -8
  92. package/src/uniform-geometry.ts +57 -0
  93. package/src/view.ts +500 -102
  94. package/src/worker-client.ts +212 -0
@@ -0,0 +1,306 @@
1
+ import { createWorkerRequestClient, WorkerRequestError } from './worker-client.ts';
2
+ import type { FindMatch } from '@adecore/editor-core';
3
+ import type { EditorFindQuery } from './types.ts';
4
+ import {
5
+ SEARCH_LIMIT,
6
+ MAX_REPLACEMENT_PAGE,
7
+ MAX_REPLACEMENT_CHARACTERS,
8
+ type EditorSearchRange,
9
+ type EditorSearchNextOptions,
10
+ type EditorSearchReplacementOptions,
11
+ type EditorSearchReplacements,
12
+ type EditorSearchInput,
13
+ type EditorSearchOptions,
14
+ type EditorSearchOutput,
15
+ type EditorSearchResult,
16
+ type EditorSearchSnapshot
17
+ } from './search-protocol.ts';
18
+
19
+ export type EditorSearchErrorKind = 'timeout' | 'superseded' | 'unavailable' | 'syntax' | 'range' | 'snapshot' | 'error' | 'disposed';
20
+
21
+ export class EditorSearchError extends Error {
22
+ readonly kind: EditorSearchErrorKind;
23
+
24
+ constructor(kind: EditorSearchErrorKind, message: string) {
25
+ super(message);
26
+ this.name = 'EditorSearchError';
27
+ this.kind = kind;
28
+ }
29
+ }
30
+
31
+ export interface EditorSearchClientOptions {
32
+ readonly workerFactory: () => Pick<Worker, 'addEventListener' | 'removeEventListener' | 'postMessage' | 'terminate'>;
33
+ /* Milliseconds, starting when the request is posted, including worker startup. Defaults to 1,500. */
34
+ readonly deadline?: number;
35
+ readonly schedule?: (callback: () => void, delay: number) => () => void;
36
+ }
37
+
38
+ export interface EditorSearchClient {
39
+ /* One document per client; a revision must identify the same immutable text for its lifetime. */
40
+ search(snapshot: EditorSearchSnapshot, query: EditorFindQuery, options?: EditorSearchOptions): Promise<EditorSearchResult>;
41
+ match(
42
+ snapshot: EditorSearchSnapshot,
43
+ query: EditorFindQuery,
44
+ range: { readonly from: number; readonly to: number; readonly deadline?: number }
45
+ ): Promise<FindMatch | null>;
46
+ next(snapshot: EditorSearchSnapshot, query: EditorFindQuery, offset: number, options?: EditorSearchNextOptions): Promise<EditorSearchRange | null>;
47
+ replacements(
48
+ snapshot: EditorSearchSnapshot,
49
+ query: EditorFindQuery,
50
+ replacement: string,
51
+ options?: EditorSearchReplacementOptions
52
+ ): Promise<EditorSearchReplacements>;
53
+ cancel(): void;
54
+ /* Frees the snapshot and worker; this client can start again afterward. */
55
+ release(): void;
56
+ dispose(): void;
57
+ }
58
+
59
+ type SearchReply = Exclude<EditorSearchOutput, { type: 'ready' | 'error' }>;
60
+
61
+ function validateSearch(snapshot: EditorSearchSnapshot, query: EditorFindQuery, options: EditorSearchOptions): void {
62
+ const from = options.from ?? 0;
63
+ const to = options.to ?? snapshot.text.length;
64
+ const maximum = options.maxResults ?? SEARCH_LIMIT;
65
+ if (
66
+ !Number.isSafeInteger(snapshot.revision) ||
67
+ snapshot.revision < 0 ||
68
+ !Number.isInteger(from) ||
69
+ !Number.isInteger(to) ||
70
+ from < 0 ||
71
+ to < from ||
72
+ to > snapshot.text.length
73
+ ) {
74
+ throw new EditorSearchError('range', 'Search bounds and revision must identify the current document.');
75
+ }
76
+ if (!Number.isInteger(maximum) || maximum < 0 || maximum > SEARCH_LIMIT) {
77
+ throw new EditorSearchError('range', `Search results must be limited to ${SEARCH_LIMIT} matches or fewer.`);
78
+ }
79
+ if (options.deadline !== undefined && (!Number.isFinite(options.deadline) || options.deadline <= 0 || options.deadline > 2147483647)) {
80
+ throw new EditorSearchError('range', 'A request deadline must fit a positive browser timer.');
81
+ }
82
+ if (query.regex) {
83
+ try {
84
+ // Compilation reports syntax errors without executing the expression on the page.
85
+ new RegExp(query.text, `gmu${query.caseSensitive ? '' : 'i'}`);
86
+ } catch (error) {
87
+ throw new EditorSearchError('syntax', error instanceof Error ? error.message : String(error));
88
+ }
89
+ }
90
+ }
91
+
92
+ function splitsSurrogate(text: string, offset: number): boolean {
93
+ const previous = text.charCodeAt(offset - 1),
94
+ current = text.charCodeAt(offset);
95
+ return previous >= 0xd800 && previous <= 0xdbff && current >= 0xdc00 && current <= 0xdfff;
96
+ }
97
+
98
+ export function createSearchClient(options: EditorSearchClientOptions): EditorSearchClient {
99
+ let revision: number | undefined;
100
+ const transport = createWorkerRequestClient<EditorSearchInput, Exclude<EditorSearchOutput, { type: 'ready' }>>({
101
+ ...options,
102
+ accepts: (reply, input) => reply.revision === input.revision,
103
+ failure: (reply) =>
104
+ reply.type === 'error'
105
+ ? { error: new EditorSearchError(reply.kind, reply.message), restart: reply.kind !== 'syntax' && reply.kind !== 'range' }
106
+ : undefined,
107
+ prepare: (input) => {
108
+ const { text, ...rest } = input;
109
+ const message = { ...rest, ...(revision !== input.revision ? { text } : {}) };
110
+ revision = input.revision;
111
+ return message;
112
+ },
113
+ stopped: () => {
114
+ revision = undefined;
115
+ }
116
+ });
117
+ const run = <Result>(
118
+ snapshot: EditorSearchSnapshot,
119
+ query: EditorFindQuery,
120
+ bounds: EditorSearchOptions,
121
+ input: (id: number) => EditorSearchInput,
122
+ read: (reply: SearchReply) => Result,
123
+ validate?: () => void
124
+ ): Promise<Result> => {
125
+ return transport
126
+ .request(input, bounds.deadline, () => {
127
+ validateSearch(snapshot, query, bounds);
128
+ validate?.();
129
+ })
130
+ .then((reply) => {
131
+ if (reply.type === 'error') {
132
+ throw new EditorSearchError(reply.kind, reply.message);
133
+ }
134
+ return read(reply);
135
+ })
136
+ .catch((error) => {
137
+ throw error instanceof WorkerRequestError
138
+ ? new EditorSearchError(error.kind, error.message)
139
+ : error instanceof EditorSearchError
140
+ ? error
141
+ : new EditorSearchError('error', error instanceof Error ? error.message : String(error));
142
+ });
143
+ };
144
+ return {
145
+ search(snapshot, query, bounds = {}) {
146
+ return run(
147
+ snapshot,
148
+ query,
149
+ bounds,
150
+ (id) => ({
151
+ type: 'search',
152
+ id,
153
+ revision: snapshot.revision,
154
+ text: snapshot.text,
155
+ query: { ...query },
156
+ ...bounds,
157
+ maxResults: bounds.maxResults ?? SEARCH_LIMIT
158
+ }),
159
+ (reply) => {
160
+ if (reply.type !== 'matches') {
161
+ throw new EditorSearchError('error', 'The worker returned an unexpected search response.');
162
+ }
163
+ return { revision: reply.revision, ranges: reply.ranges, limited: reply.limited };
164
+ }
165
+ );
166
+ },
167
+ match(snapshot, query, range) {
168
+ const from = range.from,
169
+ to = range.to;
170
+ const text = snapshot.text;
171
+ return run(
172
+ snapshot,
173
+ query,
174
+ { from, to, maxResults: 1, deadline: range.deadline },
175
+ (id) => ({ type: 'match', id, revision: snapshot.revision, text, query: { ...query }, from, to }),
176
+ (reply) => {
177
+ if (reply.type !== 'match') {
178
+ throw new EditorSearchError('error', 'The worker returned an unexpected match response.');
179
+ }
180
+ if (reply.match === null) {
181
+ return null;
182
+ }
183
+ if (reply.match.from !== from || reply.match.to !== to) {
184
+ throw new EditorSearchError('error', 'The worker returned a different match range.');
185
+ }
186
+ return { ...reply.match, text: text.slice(from, to), revision: reply.revision };
187
+ }
188
+ );
189
+ },
190
+ next(snapshot, query, offset, bounds = {}) {
191
+ return run(
192
+ snapshot,
193
+ query,
194
+ { from: bounds.from, to: bounds.to, maxResults: 1, deadline: bounds.deadline },
195
+ (id) => ({ type: 'next', id, revision: snapshot.revision, text: snapshot.text, query: { ...query }, offset, ...bounds }),
196
+ (reply) => {
197
+ if (reply.type !== 'next') {
198
+ throw new EditorSearchError('error', 'The worker returned an unexpected cursor response.');
199
+ }
200
+ const range = reply.range;
201
+ if (
202
+ range !== null &&
203
+ (!Number.isInteger(range.from) ||
204
+ !Number.isInteger(range.to) ||
205
+ range.from < (bounds.from ?? 0) ||
206
+ range.to < range.from ||
207
+ range.to > (bounds.to ?? snapshot.text.length) ||
208
+ splitsSurrogate(snapshot.text, range.from) ||
209
+ splitsSurrogate(snapshot.text, range.to))
210
+ ) {
211
+ throw new EditorSearchError('error', 'The worker returned invalid cursor bounds.');
212
+ }
213
+ return range;
214
+ }
215
+ );
216
+ },
217
+ replacements(snapshot, query, replacement, options = {}) {
218
+ const { from, to, preserveCase } = options;
219
+ const maxEdits = options.maxEdits ?? 1000;
220
+ const maxCharacters = options.maxCharacters ?? MAX_REPLACEMENT_CHARACTERS;
221
+ return run(
222
+ snapshot,
223
+ query,
224
+ { from, to, maxResults: 1, deadline: options.deadline },
225
+ (id) => ({
226
+ type: 'replacements',
227
+ id,
228
+ revision: snapshot.revision,
229
+ text: snapshot.text,
230
+ query: { ...query },
231
+ from,
232
+ to,
233
+ preserveCase,
234
+ replacement,
235
+ maxEdits,
236
+ maxCharacters
237
+ }),
238
+ (reply) => {
239
+ if (reply.type !== 'replacements') {
240
+ throw new EditorSearchError('error', 'The worker returned an unexpected replacement response.');
241
+ }
242
+ if (reply.ranges.length % 3 !== 0 || reply.ranges.length / 3 > maxEdits || reply.text.length > maxCharacters) {
243
+ throw new EditorSearchError('error', 'The worker returned an invalid replacement page.');
244
+ }
245
+ let characters = 0;
246
+ let previous = from ?? 0;
247
+ let previousEmpty = false;
248
+ for (let index = 0; index < reply.ranges.length; index += 3) {
249
+ const start = reply.ranges[index]!,
250
+ end = reply.ranges[index + 1]!;
251
+ if (
252
+ start < previous ||
253
+ (previousEmpty && start === previous) ||
254
+ end < start ||
255
+ end > (to ?? snapshot.text.length) ||
256
+ splitsSurrogate(snapshot.text, start) ||
257
+ splitsSurrogate(snapshot.text, end)
258
+ ) {
259
+ throw new EditorSearchError('error', 'The worker returned invalid replacement bounds.');
260
+ }
261
+ previous = end;
262
+ previousEmpty = start === end;
263
+ characters += reply.ranges[index + 2]!;
264
+ }
265
+ if (characters !== reply.text.length) {
266
+ throw new EditorSearchError('error', 'The worker returned inconsistent replacement lengths.');
267
+ }
268
+ if (reply.next !== null) {
269
+ const index = reply.ranges.length - 3;
270
+ if (index < 0) {
271
+ throw new EditorSearchError('error', 'The worker returned an empty replacement continuation.');
272
+ }
273
+ const start = reply.ranges[index],
274
+ end = reply.ranges[index + 1];
275
+ const resume = end! + (start === end ? ((snapshot.text.codePointAt(end!) ?? 0) > 0xffff ? 2 : 1) : 0);
276
+ if (reply.next !== resume || reply.next > (to ?? snapshot.text.length)) {
277
+ throw new EditorSearchError('error', 'The worker returned an invalid replacement continuation.');
278
+ }
279
+ }
280
+ return { revision: reply.revision, ranges: reply.ranges, text: reply.text, next: reply.next };
281
+ },
282
+ () => {
283
+ if (
284
+ !Number.isInteger(maxEdits) ||
285
+ maxEdits < 1 ||
286
+ maxEdits > MAX_REPLACEMENT_PAGE ||
287
+ !Number.isInteger(maxCharacters) ||
288
+ maxCharacters < 0 ||
289
+ maxCharacters > MAX_REPLACEMENT_CHARACTERS
290
+ ) {
291
+ throw new EditorSearchError('range', 'Replacement pages need a positive edit limit and a bounded character limit.');
292
+ }
293
+ }
294
+ );
295
+ },
296
+ cancel() {
297
+ transport.cancel();
298
+ },
299
+ release() {
300
+ transport.release();
301
+ },
302
+ dispose() {
303
+ transport.dispose();
304
+ }
305
+ };
306
+ }
@@ -0,0 +1,100 @@
1
+ import type { FindMatch } from '@adecore/editor-core';
2
+ import type { EditorFindQuery } from './types.ts';
3
+
4
+ export const SEARCH_LIMIT = 10000;
5
+ export const MAX_REPLACEMENT_PAGE = 10000;
6
+ export const MAX_REPLACEMENT_CHARACTERS = 4_000_000;
7
+
8
+ export interface EditorSearchSnapshot {
9
+ readonly revision: number;
10
+ readonly text: string;
11
+ }
12
+
13
+ export interface EditorSearchOptions {
14
+ /* Milliseconds including startup and snapshot posting; overrides the client deadline. */
15
+ readonly deadline?: number;
16
+ readonly from?: number;
17
+ readonly to?: number;
18
+ readonly maxResults?: number;
19
+ }
20
+
21
+ export interface EditorSearchResult {
22
+ readonly revision: number;
23
+ /* Pairs of UTF-16 offsets; search marks do not transfer matched text or captures. */
24
+ readonly ranges: Uint32Array;
25
+ readonly limited: boolean;
26
+ }
27
+
28
+ export interface EditorSearchRange {
29
+ readonly from: number;
30
+ readonly to: number;
31
+ }
32
+
33
+ export interface EditorSearchNextOptions {
34
+ readonly deadline?: number;
35
+ readonly from?: number;
36
+ readonly to?: number;
37
+ readonly backwards?: boolean;
38
+ readonly wrap?: boolean;
39
+ }
40
+
41
+ export interface EditorSearchReplacementOptions {
42
+ readonly deadline?: number;
43
+ readonly from?: number;
44
+ readonly to?: number;
45
+ readonly preserveCase?: boolean;
46
+ readonly maxEdits?: number;
47
+ readonly maxCharacters?: number;
48
+ }
49
+
50
+ export interface EditorSearchReplacements {
51
+ readonly revision: number;
52
+ /* Triples [from, to, replacement length], indexing the concatenated text. */
53
+ readonly ranges: Uint32Array;
54
+ readonly text: string;
55
+ readonly next: number | null;
56
+ }
57
+
58
+ export type EditorSearchMatch = Omit<FindMatch, 'revision' | 'text'>;
59
+
60
+ interface SearchInput {
61
+ readonly id: number;
62
+ readonly revision: number;
63
+ readonly text?: string;
64
+ readonly query: EditorFindQuery;
65
+ }
66
+
67
+ export type EditorSearchInput =
68
+ | (SearchInput & { readonly type: 'search'; readonly from?: number; readonly to?: number; readonly maxResults: number })
69
+ | (SearchInput & { readonly type: 'match'; readonly from: number; readonly to: number })
70
+ | (SearchInput & {
71
+ readonly type: 'next';
72
+ readonly offset: number;
73
+ readonly from?: number;
74
+ readonly to?: number;
75
+ readonly backwards?: boolean;
76
+ readonly wrap?: boolean;
77
+ })
78
+ | (SearchInput & {
79
+ readonly type: 'replacements';
80
+ readonly from?: number;
81
+ readonly to?: number;
82
+ readonly replacement: string;
83
+ readonly preserveCase?: boolean;
84
+ readonly maxEdits: number;
85
+ readonly maxCharacters: number;
86
+ });
87
+
88
+ export type EditorSearchOutput =
89
+ | { readonly type: 'ready' }
90
+ | ({ readonly type: 'matches'; readonly id: number } & EditorSearchResult)
91
+ | { readonly type: 'match'; readonly id: number; readonly revision: number; readonly match: EditorSearchMatch | null }
92
+ | { readonly type: 'next'; readonly id: number; readonly revision: number; readonly range: EditorSearchRange | null }
93
+ | ({ readonly type: 'replacements'; readonly id: number } & EditorSearchReplacements)
94
+ | {
95
+ readonly type: 'error';
96
+ readonly id: number;
97
+ readonly revision: number;
98
+ readonly kind: 'syntax' | 'range' | 'snapshot' | 'error';
99
+ readonly message: string;
100
+ };
@@ -0,0 +1,171 @@
1
+ import { iterateMatches, findMatchFrom, replacementText, type FindMatch, type FindOptions } from '@adecore/editor-core';
2
+ import {
3
+ SEARCH_LIMIT,
4
+ MAX_REPLACEMENT_PAGE,
5
+ MAX_REPLACEMENT_CHARACTERS,
6
+ type EditorSearchInput,
7
+ type EditorSearchOutput,
8
+ type EditorSearchSnapshot
9
+ } from './search-protocol.ts';
10
+
11
+ export { createSearchClient, EditorSearchError, type EditorSearchClient, type EditorSearchClientOptions, type EditorSearchErrorKind } from './search-client.ts';
12
+ export type {
13
+ EditorSearchInput,
14
+ EditorSearchOptions,
15
+ EditorSearchOutput,
16
+ EditorSearchResult,
17
+ EditorSearchSnapshot,
18
+ EditorSearchMatch,
19
+ EditorSearchRange,
20
+ EditorSearchNextOptions,
21
+ EditorSearchReplacementOptions,
22
+ EditorSearchReplacements
23
+ } from './search-protocol.ts';
24
+
25
+ const MAX_CAPTURE_CHARACTERS = 4_000_000;
26
+
27
+ export interface EditorSearchWorkerEndpoint {
28
+ addEventListener(type: 'message', listener: (event: MessageEvent<EditorSearchInput>) => void): void;
29
+ removeEventListener(type: 'message', listener: (event: MessageEvent<EditorSearchInput>) => void): void;
30
+ postMessage(message: EditorSearchOutput, options?: { transfer: Transferable[] }): void;
31
+ }
32
+
33
+ function findOptions(request: EditorSearchInput): FindOptions {
34
+ return { caseSensitive: request.query.caseSensitive, wholeWord: request.query.wholeWord, regex: request.query.regex, from: request.from, to: request.to };
35
+ }
36
+
37
+ function captureMatch(found: FindMatch) {
38
+ let characters = 0;
39
+ for (const value of found.captures) {
40
+ characters += value?.length ?? 0;
41
+ }
42
+ for (const value of Object.values(found.groups ?? {})) {
43
+ characters += value?.length ?? 0;
44
+ }
45
+ if (characters > MAX_CAPTURE_CHARACTERS) {
46
+ throw new RangeError('Search captures exceed the response size limit.');
47
+ }
48
+ return { from: found.from, to: found.to, captures: found.captures, groups: found.groups, regex: found.regex };
49
+ }
50
+
51
+ function searchReply(request: EditorSearchInput, snapshot: EditorSearchSnapshot): Exclude<EditorSearchOutput, { type: 'ready' | 'error' }> {
52
+ const { text } = snapshot;
53
+ const options = findOptions(request);
54
+ const header = { id: request.id, revision: request.revision };
55
+ if (request.type === 'next') {
56
+ let found: FindMatch | null = null;
57
+ if (request.backwards) {
58
+ let last: FindMatch | null = null;
59
+ for (const match of iterateMatches(text, request.query.text, request.revision, options)) {
60
+ last = match;
61
+ if (match.from < request.offset) {
62
+ found = match;
63
+ } else if (found !== null || request.wrap === false) {
64
+ break;
65
+ }
66
+ }
67
+ found ??= request.wrap === false ? null : last;
68
+ } else {
69
+ found = findMatchFrom(text, request.query.text, request.revision, request.offset, { ...options, wrap: request.wrap });
70
+ }
71
+ return { type: 'next', ...header, range: found === null ? null : { from: found.from, to: found.to } };
72
+ }
73
+ if (request.type === 'match') {
74
+ const found = iterateMatches(text, request.query.text, request.revision, { ...options, maxResults: 1 }).next().value;
75
+ return { type: 'match', ...header, match: found && found.from === request.from && found.to === request.to ? captureMatch(found) : null };
76
+ }
77
+ if (request.type === 'replacements') {
78
+ if (
79
+ !Number.isInteger(request.maxEdits) ||
80
+ request.maxEdits < 1 ||
81
+ request.maxEdits > MAX_REPLACEMENT_PAGE ||
82
+ !Number.isInteger(request.maxCharacters) ||
83
+ request.maxCharacters < 0 ||
84
+ request.maxCharacters > MAX_REPLACEMENT_CHARACTERS
85
+ ) {
86
+ throw new RangeError('Replacement pages need a positive edit limit and a bounded character limit.');
87
+ }
88
+ const ranges: number[] = [],
89
+ pieces: string[] = [];
90
+ let characters = 0;
91
+ let resume = request.from ?? 0;
92
+ let next: number | null = null;
93
+ for (const match of iterateMatches(text, request.query.text, request.revision, { ...options, maxResults: request.maxEdits + 1 })) {
94
+ if (pieces.length === request.maxEdits) {
95
+ next = resume;
96
+ break;
97
+ }
98
+ const replacement = replacementText(text, match, request.replacement, false, request.preserveCase, request.maxCharacters);
99
+ if (characters + replacement.length > request.maxCharacters) {
100
+ if (pieces.length === 0) {
101
+ throw new RangeError('One replacement exceeds the response size limit.');
102
+ }
103
+ next = resume;
104
+ break;
105
+ }
106
+ characters += replacement.length;
107
+ ranges.push(match.from, match.to, replacement.length);
108
+ pieces.push(replacement);
109
+ // Resuming after an empty match must skip the same full code point as the matcher.
110
+ resume = match.to + (match.from === match.to ? ((text.codePointAt(match.to) ?? 0) > 0xffff ? 2 : 1) : 0);
111
+ }
112
+ return { type: 'replacements', ...header, ranges: Uint32Array.from(ranges), text: pieces.join(''), next };
113
+ }
114
+ if (!Number.isInteger(request.maxResults) || request.maxResults < 0 || request.maxResults > SEARCH_LIMIT) {
115
+ throw new RangeError(`Search results must be limited to ${SEARCH_LIMIT} matches or fewer.`);
116
+ }
117
+ const offsets: number[] = [];
118
+ let limited = false;
119
+ for (const match of iterateMatches(text, request.query.text, request.revision, { ...options, maxResults: request.maxResults + 1 })) {
120
+ if (offsets.length === request.maxResults * 2) {
121
+ limited = true;
122
+ break;
123
+ }
124
+ offsets.push(match.from, match.to);
125
+ }
126
+ return { type: 'matches', ...header, ranges: Uint32Array.from(offsets), limited };
127
+ }
128
+
129
+ /* A running RegExp can only be interrupted by terminating its worker. */
130
+ export function serveEditorSearch(endpoint: EditorSearchWorkerEndpoint): () => void {
131
+ let snapshot: EditorSearchSnapshot | undefined;
132
+ const listener = ({ data: request }: MessageEvent<EditorSearchInput>): void => {
133
+ try {
134
+ if (!Number.isSafeInteger(request.revision) || request.revision < 0) {
135
+ throw new RangeError('The search revision must be a nonnegative safe integer.');
136
+ }
137
+ if (request.text !== undefined) {
138
+ snapshot = { revision: request.revision, text: request.text };
139
+ }
140
+ if (snapshot === undefined || snapshot.revision !== request.revision) {
141
+ endpoint.postMessage({
142
+ type: 'error',
143
+ id: request.id,
144
+ revision: request.revision,
145
+ kind: 'snapshot',
146
+ message: 'The search worker needs the text of this revision.'
147
+ });
148
+ return;
149
+ }
150
+ const response = searchReply(request, snapshot);
151
+ endpoint.postMessage(
152
+ response,
153
+ response.type === 'matches' || response.type === 'replacements' ? { transfer: [response.ranges.buffer] } : undefined
154
+ );
155
+ } catch (error) {
156
+ endpoint.postMessage({
157
+ type: 'error',
158
+ id: request.id,
159
+ revision: request.revision,
160
+ kind: error instanceof SyntaxError ? 'syntax' : error instanceof RangeError ? 'range' : 'error',
161
+ message: error instanceof Error ? error.message : String(error)
162
+ });
163
+ }
164
+ };
165
+ endpoint.addEventListener('message', listener);
166
+ endpoint.postMessage({ type: 'ready' });
167
+ return () => {
168
+ endpoint.removeEventListener('message', listener);
169
+ snapshot = undefined;
170
+ };
171
+ }
package/src/semantic.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { LineToken } from './types.ts';
1
+ import type { LineToken, LineTokenKind } from './types.ts';
2
2
 
3
3
  /* A stretch of one line, in characters from its start, that a language server colors. */
4
4
  export interface SemanticSpan {
@@ -6,6 +6,8 @@ export interface SemanticSpan {
6
6
  readonly to: number;
7
7
  readonly color: string;
8
8
  readonly fontStyle: number;
9
+ /* The span applies only over base tokens of this kind. */
10
+ readonly within?: LineTokenKind;
9
11
  }
10
12
 
11
13
  /*
@@ -44,7 +46,11 @@ export function overlayTokens(base: readonly LineToken[], spans: readonly Semant
44
46
  at = current.from;
45
47
  } else {
46
48
  const stop = Math.min(end, current.to);
47
- push(stop - at, current.color, current.fontStyle);
49
+ if (current.within === undefined || token.kind === current.within) {
50
+ push(stop - at, current.color, current.fontStyle);
51
+ } else {
52
+ push(stop - at, token.color, token.fontStyle);
53
+ }
48
54
  at = stop;
49
55
  }
50
56
  }