@type-editor/changeset 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/Span.ts ADDED
@@ -0,0 +1,170 @@
1
+ import {isUndefinedOrNull} from '@type-editor/commons';
2
+
3
+
4
+ /**
5
+ * Stores metadata for a part of a change.
6
+ *
7
+ * A Span represents a contiguous range in a document with associated metadata.
8
+ * Spans are immutable and can be sliced, joined, or cut to create new spans.
9
+ *
10
+ * @template Data - The type of metadata associated with this span.
11
+ */
12
+ export class Span<Data = any> {
13
+
14
+ /** An empty span array constant to avoid allocations. */
15
+ public static readonly none: ReadonlyArray<Span> = [];
16
+
17
+ private readonly _length: number;
18
+ private readonly _data: Data;
19
+
20
+ /**
21
+ * Creates a new Span with the specified length and associated data.
22
+ *
23
+ * @param length - The length of this span (must be non-negative).
24
+ * @param data - The metadata associated with this span.
25
+ */
26
+ constructor (length: number, data: Data) {
27
+ this._length = length;
28
+ this._data = data;
29
+ }
30
+
31
+ /** Returns the length of this span. */
32
+ get length(): number {
33
+ return this._length;
34
+ }
35
+
36
+ /** Returns the metadata associated with this span. */
37
+ get data(): Data {
38
+ return this._data;
39
+ }
40
+
41
+ /**
42
+ * Creates a new span with a different length but the same data.
43
+ * If the length matches, returns this span to avoid allocation.
44
+ *
45
+ * @param length - The length for the new span.
46
+ * @returns A span with the specified length.
47
+ */
48
+ private cut(length: number): Span<Data> {
49
+ return length === this.length ? this : new Span(length, this._data);
50
+ }
51
+
52
+
53
+ /**
54
+ * Slices a range from an array of spans.
55
+ *
56
+ * This extracts a sub-range from a span array, potentially cutting spans
57
+ * that partially overlap with the range boundaries.
58
+ *
59
+ * @template Data - The type of metadata in the spans.
60
+ * @param spans - The array of spans to slice from.
61
+ * @param from - The start position (inclusive).
62
+ * @param to - The end position (exclusive).
63
+ * @returns A new array containing the spans within the specified range.
64
+ */
65
+ public static slice<Data>(spans: ReadonlyArray<Span<Data>>,
66
+ from: number,
67
+ to: number): ReadonlyArray<Span<Data>> {
68
+ // Empty range
69
+ if (from === to) {
70
+ return Span.none;
71
+ }
72
+
73
+ // Fast path: if starting from 0 and spans is empty or has known length
74
+ // Only check for full range when from === 0 to avoid unnecessary iteration
75
+ if (from === 0) {
76
+ // Calculate length only when there's potential for a full-range optimization
77
+ const totalLength = Span.len(spans);
78
+ if (to >= totalLength) {
79
+ return spans;
80
+ }
81
+ }
82
+
83
+ const result: Array<Span<Data>> = [];
84
+ let offset = 0;
85
+
86
+ for (let i = 0; offset < to && i < spans.length; i++) {
87
+ const span: Span<Data> = spans[i];
88
+ const spanEnd: number = offset + span.length;
89
+
90
+ // Calculate how much of this span overlaps with [from, to)
91
+ const overlapStart: number = Math.max(from, offset);
92
+ const overlapEnd: number = Math.min(to, spanEnd);
93
+ const overlapLength: number = overlapEnd - overlapStart;
94
+
95
+ if (overlapLength > 0) {
96
+ result.push(span.cut(overlapLength));
97
+ }
98
+
99
+ offset = spanEnd;
100
+ }
101
+
102
+ return result;
103
+ }
104
+
105
+
106
+ /**
107
+ * Joins two span arrays, potentially combining adjacent spans at the boundary.
108
+ *
109
+ * When the last span of the first array and the first span of the second array
110
+ * meet, the combine function is called. If it returns a value, those spans are
111
+ * merged into a single span with the combined data.
112
+ *
113
+ * @template Data - The type of metadata in the spans.
114
+ * @param spanListA - The first array of spans.
115
+ * @param spanListB - The second array of spans.
116
+ * @param combine - Function to combine the data of adjacent spans. Returns
117
+ * the combined data, or null/undefined if spans should not be merged.
118
+ * @returns A new array containing the joined spans.
119
+ */
120
+ public static join<Data>(spanListA: ReadonlyArray<Span<Data>>,
121
+ spanListB: ReadonlyArray<Span<Data>>,
122
+ combine: (dataA: Data, dataB: Data) => Data): ReadonlyArray<Span<Data>> {
123
+ // Fast paths for empty arrays
124
+ if (spanListA.length === 0) {
125
+ return spanListB;
126
+ }
127
+
128
+ if (spanListB.length === 0) {
129
+ return spanListA;
130
+ }
131
+
132
+ // Try to combine the boundary spans
133
+ const lastSpanA: Span<Data> = spanListA[spanListA.length - 1];
134
+ const firstSpanB: Span<Data> = spanListB[0];
135
+ const combinedData: Data = combine(lastSpanA.data, firstSpanB.data);
136
+
137
+ // If spans cannot be combined, just concatenate
138
+ if (isUndefinedOrNull(combinedData)) {
139
+ return spanListA.concat(spanListB);
140
+ }
141
+
142
+ // Build result with merged boundary span
143
+ const result: Array<Span<Data>> = spanListA.slice(0, spanListA.length - 1);
144
+ const mergedSpan = new Span(lastSpanA.length + firstSpanB.length, combinedData);
145
+ result.push(mergedSpan);
146
+
147
+ // Add remaining spans from spanListB
148
+ for (let i = 1; i < spanListB.length; i++) {
149
+ result.push(spanListB[i]);
150
+ }
151
+
152
+ return result;
153
+ }
154
+
155
+
156
+ /**
157
+ * Calculates the total length of an array of spans.
158
+ *
159
+ * @template Data - The type of metadata in the spans.
160
+ * @param spans - The array of spans to measure.
161
+ * @returns The sum of all span lengths.
162
+ */
163
+ private static len<Data>(spans: ReadonlyArray<Span<Data>>): number {
164
+ let totalLength = 0;
165
+ for (const span of spans) {
166
+ totalLength += span.length;
167
+ }
168
+ return totalLength;
169
+ }
170
+ }
@@ -0,0 +1,100 @@
1
+ import type {Fragment} from '@type-editor/model';
2
+
3
+ import type {Change} from './Change';
4
+ import {DefaultEncoder} from './default-encoder';
5
+ import {runMyersDiff} from './myers-diff/run-myers-diff';
6
+ import {tokenizeFragment} from './tokenizer/tokenize-fragment';
7
+ import type {TokenEncoder} from './types/TokenEncoder';
8
+ import type {TrimmedRange} from './types/TrimmedRange';
9
+
10
+
11
+ /**
12
+ * Compute the difference between two fragments using Myers' diff algorithm.
13
+ *
14
+ * This implementation optimizes by first scanning from both ends to eliminate
15
+ * unchanged content, then applies the Myers algorithm to the remaining content.
16
+ * For performance reasons, the diff computation is limited by MAX_DIFF_SIZE.
17
+ *
18
+ * @param fragA - The first fragment to compare.
19
+ * @param fragB - The second fragment to compare.
20
+ * @param range - The change range to analyze.
21
+ * @param encoder - The encoder to use for tokenization (defaults to DefaultEncoder).
22
+ * @returns An array of Change objects representing the differences.
23
+ */
24
+ export function computeDiff<T>(fragA: Fragment,
25
+ fragB: Fragment,
26
+ range: Change,
27
+ encoder: TokenEncoder<T> = DefaultEncoder as TokenEncoder<T>): Array<Change> {
28
+ const tokensA: Array<T> = tokenizeFragment(fragA, encoder, range.fromA, range.toA, []);
29
+ const tokensB: Array<T> = tokenizeFragment(fragB, encoder, range.fromB, range.toB, []);
30
+
31
+ // Create a bound comparison function to avoid unbound method issues
32
+ const compareTokens = (a: T, b: T): boolean => encoder.compareTokens(a, b);
33
+
34
+ const trimmedRange: TrimmedRange = trimEqualTokens(tokensA, tokensB, compareTokens);
35
+
36
+ if (trimmedRange.start === tokensA.length && trimmedRange.start === tokensB.length) {
37
+ return [];
38
+ }
39
+
40
+ // If the result is simple or too large to compute efficiently, return the entire range
41
+ if (isSimpleDiff(trimmedRange)) {
42
+ return [range.slice(trimmedRange.start, trimmedRange.endA, trimmedRange.start, trimmedRange.endB)];
43
+ }
44
+
45
+ // Apply Myers' diff algorithm to find the optimal diff
46
+ const changes: Array<Change> = runMyersDiff(
47
+ tokensA,
48
+ tokensB,
49
+ trimmedRange,
50
+ compareTokens,
51
+ range
52
+ );
53
+
54
+ return changes ?? [range.slice(trimmedRange.start, trimmedRange.endA, trimmedRange.start, trimmedRange.endB)];
55
+ }
56
+
57
+
58
+ /**
59
+ * Trim equal tokens from both the start and end of the sequences.
60
+ * This optimization reduces the amount of work needed by the diff algorithm.
61
+ *
62
+ * @param tokensA - The first token sequence.
63
+ * @param tokensB - The second token sequence.
64
+ * @param compareTokens - The function to compare tokens for equality.
65
+ * @returns An object containing the trimmed start and end positions.
66
+ */
67
+ function trimEqualTokens<T>(tokensA: Array<T>,
68
+ tokensB: Array<T>,
69
+ compareTokens: (a: T, b: T) => boolean): TrimmedRange {
70
+ let start = 0;
71
+ let endA = tokensA.length;
72
+ let endB = tokensB.length;
73
+
74
+ // Scan from the start
75
+ while (start < tokensA.length && start < tokensB.length && compareTokens(tokensA[start], tokensB[start])) {
76
+ start++;
77
+ }
78
+
79
+ // Scan from the end
80
+ while (endA > start && endB > start && compareTokens(tokensA[endA - 1], tokensB[endB - 1])) {
81
+ endA--;
82
+ endB--;
83
+ }
84
+
85
+ return {start, endA, endB};
86
+ }
87
+
88
+ /**
89
+ * Check if the diff is simple enough to return without running the full algorithm.
90
+ * A diff is considered simple if one or both sequences are empty after trimming,
91
+ * or if the remaining sequences have only one token each.
92
+ *
93
+ * @param range - The trimmed range to check.
94
+ * @returns True if the diff is simple, false otherwise.
95
+ */
96
+ function isSimpleDiff(range: TrimmedRange): boolean {
97
+ return range.endA === range.start
98
+ || range.endB === range.start
99
+ || (range.endA === range.endB && range.endA === range.start + 1);
100
+ }
@@ -0,0 +1,41 @@
1
+ import type {Node, NodeType} from '@type-editor/model';
2
+
3
+ import type {TokenEncoder} from './types/TokenEncoder';
4
+
5
+
6
+ /**
7
+ * The default token encoder for diff operations.
8
+ * - Node start tokens are encoded as strings containing the node name
9
+ * - Characters are encoded as their character code
10
+ * - Node end tokens are encoded as negative type IDs
11
+ */
12
+ export const DefaultEncoder: TokenEncoder<number | string> = {
13
+ encodeCharacter: (char: number): number => char,
14
+ encodeNodeStart: (node: Node): string => node.type.name,
15
+ encodeNodeEnd: (node: Node): number => -getTypeID(node.type),
16
+ compareTokens: (a: number | string, b: number | string): boolean => a === b
17
+ };
18
+
19
+ /**
20
+ * Get a unique numeric ID for a node type.
21
+ * Uses a cached mapping to avoid recomputing IDs for the same type.
22
+ *
23
+ * @param type - The node type to get an ID for.
24
+ * @returns A unique numeric identifier for the node type.
25
+ */
26
+ function getTypeID(type: NodeType): number {
27
+ // Initialize or retrieve the cache from the schema
28
+ let idCache: Record<string, number> = type.schema.cached.changeSetIDs as Record<string, number> | undefined;
29
+
30
+ if (!idCache) {
31
+ // Build the entire ID cache at once to avoid repeated Object.keys() calls
32
+ idCache = {} as Record<string, number>;
33
+ const nodeNames: Array<string> = Object.keys(type.schema.nodes);
34
+ for (let i = 0; i < nodeNames.length; i++) {
35
+ idCache[nodeNames[i]] = i + 1;
36
+ }
37
+ type.schema.cached.changeSetIDs = idCache;
38
+ }
39
+
40
+ return idCache[type.name];
41
+ }
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ export {Change} from './Change';
2
+ export {ChangeSet} from './ChangeSet';
3
+ export {computeDiff} from './compute-diff';
4
+ export {DefaultEncoder} from './default-encoder';
5
+ export {simplifyChanges} from './simplify-changes';
6
+ export {Span} from './Span';
7
+ export type {ChangeSetConfig} from './types/ChangeSetConfig';
8
+ export type {TokenEncoder} from './types/TokenEncoder';
@@ -0,0 +1,7 @@
1
+
2
+
3
+ /**
4
+ * Maximum distance (in characters) between changes for them to be considered
5
+ * candidates for merging during simplification.
6
+ */
7
+ export const MAX_SIMPLIFY_DISTANCE = 30;
@@ -0,0 +1,261 @@
1
+ import type {Change} from '../Change';
2
+ import type {TrimmedRange} from '../types/TrimmedRange';
3
+
4
+
5
+ /**
6
+ * State for accumulating changes during traceback.
7
+ */
8
+ interface ChangeAccumulator {
9
+ fromA: number;
10
+ toA: number;
11
+ fromB: number;
12
+ toB: number;
13
+ }
14
+
15
+ /**
16
+ * The code below will refuse to compute a diff with more than 5000
17
+ * insertions or deletions, which takes about 300ms to reach on my
18
+ * machine. This is a safeguard against runaway computations.
19
+ */
20
+ const MAX_DIFF_SIZE = 5000;
21
+
22
+
23
+ /**
24
+ * Run Myers' diff algorithm to find the optimal sequence of changes.
25
+ *
26
+ * Myers' algorithm uses dynamic programming to find the shortest edit script
27
+ * between two sequences. It explores diagonals in the edit graph, maintaining
28
+ * a frontier of the furthest reaching paths.
29
+ *
30
+ * See: https://neil.fraser.name/writing/diff/myers.pdf
31
+ * See: https://blog.jcoglan.com/2017/02/12/the-myers-diff-algorithm-part-1/
32
+ *
33
+ * @param tokensA - The first token sequence.
34
+ * @param tokensB - The second token sequence.
35
+ * @param range - The trimmed range to process.
36
+ * @param compareTokens - The function to compare tokens for equality.
37
+ * @param changeRange - The original change range for creating result changes.
38
+ * @returns An array of changes, or null if the algorithm exceeded MAX_DIFF_SIZE.
39
+ */
40
+ export function runMyersDiff<T>(tokensA: Array<T>,
41
+ tokensB: Array<T>,
42
+ range: TrimmedRange,
43
+ compareTokens: (a: T, b: T) => boolean,
44
+ changeRange: Change): Array<Change> | null {
45
+ const lengthA: number = range.endA - range.start;
46
+ const lengthB: number = range.endB - range.start;
47
+ const maxOperations: number = Math.min(MAX_DIFF_SIZE, lengthA + lengthB);
48
+ const offset: number = maxOperations + 1;
49
+ const history: Array<Array<number>> = [];
50
+ const frontier: Array<number> = initializeFrontier(offset * 2);
51
+
52
+ for (let editDistance = 0; editDistance <= maxOperations; editDistance++) {
53
+ for (let diagonal = -editDistance; diagonal <= editDistance; diagonal += 2) {
54
+ const found: boolean = exploreDiagonal(
55
+ tokensA,
56
+ tokensB,
57
+ frontier,
58
+ diagonal,
59
+ offset,
60
+ range,
61
+ lengthA,
62
+ lengthB,
63
+ compareTokens
64
+ );
65
+
66
+ if (found) {
67
+ return tracebackChanges(
68
+ history,
69
+ frontier,
70
+ diagonal,
71
+ offset,
72
+ editDistance,
73
+ range,
74
+ changeRange
75
+ );
76
+ }
77
+ }
78
+
79
+ // Save frontier state every other iteration (for odd/even diagonal alternation)
80
+ if (editDistance % 2 === 0) {
81
+ history.push(frontier.slice());
82
+ }
83
+ }
84
+
85
+ // Algorithm exceeded maximum complexity
86
+ return null;
87
+ }
88
+
89
+ /**
90
+ * Initialize the frontier array with -1 values.
91
+ *
92
+ * @param size - The size of the frontier array.
93
+ * @returns The initialized frontier array.
94
+ */
95
+ function initializeFrontier(size: number): Array<number> {
96
+ return new Array<number>(size).fill(-1);
97
+ }
98
+
99
+ /**
100
+ * Explore a diagonal in the edit graph, extending as far as possible with matching tokens.
101
+ *
102
+ * @param tokensA - The first token sequence.
103
+ * @param tokensB - The second token sequence.
104
+ * @param frontier - The current frontier array.
105
+ * @param diagonal - The diagonal to explore.
106
+ * @param offset - The offset for indexing the frontier.
107
+ * @param range - The trimmed range.
108
+ * @param lengthA - The length of sequence A.
109
+ * @param lengthB - The length of sequence B.
110
+ * @param compareTokens - The function to compare tokens.
111
+ * @returns True if a complete path was found, false otherwise.
112
+ */
113
+ function exploreDiagonal<T>(tokensA: Array<T>,
114
+ tokensB: Array<T>,
115
+ frontier: Array<number>,
116
+ diagonal: number,
117
+ offset: number,
118
+ range: TrimmedRange,
119
+ lengthA: number,
120
+ lengthB: number,
121
+ compareTokens: (a: T, b: T) => boolean): boolean {
122
+ const nextValue: number = frontier[diagonal + 1 + offset];
123
+ const prevValue: number = frontier[diagonal - 1 + offset];
124
+
125
+ // Choose the path that goes furthest
126
+ let x: number = nextValue < prevValue ? prevValue : nextValue + 1;
127
+ let y: number = x + diagonal;
128
+
129
+ // Pre-compute start offset to avoid repeated addition in the loop
130
+ const startOffset: number = range.start;
131
+
132
+ // Extend along the diagonal as far as possible with matching tokens
133
+ while (x < lengthA && y < lengthB && compareTokens(tokensA[startOffset + x], tokensB[startOffset + y])) {
134
+ x++;
135
+ y++;
136
+ }
137
+
138
+ frontier[diagonal + offset] = x;
139
+
140
+ // Check if we've reached the end of both sequences
141
+ return x >= lengthA && y >= lengthB;
142
+ }
143
+
144
+ /**
145
+ * Trace back through the history to build the set of changes.
146
+ *
147
+ * @param history - The history of frontier states.
148
+ * @param frontier - The final frontier state.
149
+ * @param diagonal - The final diagonal position.
150
+ * @param offset - The offset for indexing.
151
+ * @param editDistance - The final edit distance.
152
+ * @param range - The trimmed range.
153
+ * @param changeRange - The original change range.
154
+ * @returns An array of changes in forward order.
155
+ */
156
+ function tracebackChanges(history: Array<Array<number>>,
157
+ frontier: Array<number>,
158
+ diagonal: number,
159
+ offset: number,
160
+ editDistance: number,
161
+ range: TrimmedRange,
162
+ changeRange: Change): Array<Change> {
163
+ const changes: Array<Change> = [];
164
+ const minSpan: number = computeMinUnchangedThreshold(range.endA - range.start, range.endB - range.start);
165
+
166
+ // Accumulator for building changes, initialized to "no change pending"
167
+ const accumulator: ChangeAccumulator = {
168
+ fromA: -1,
169
+ toA: -1,
170
+ fromB: -1,
171
+ toB: -1
172
+ };
173
+
174
+ let currentDiagonal: number = diagonal;
175
+ let currentFrontier: Array<number> = frontier;
176
+
177
+ for (let i = editDistance - 1; i >= 0; i--) {
178
+ const nextValue: number = currentFrontier[currentDiagonal + 1 + offset];
179
+ const prevValue: number = currentFrontier[currentDiagonal - 1 + offset];
180
+
181
+ let x: number;
182
+ let y: number;
183
+
184
+ if (nextValue < prevValue) {
185
+ // Deletion
186
+ currentDiagonal--;
187
+ x = prevValue + range.start;
188
+ y = x + currentDiagonal;
189
+ addChange(accumulator, changes, changeRange, minSpan, x, x, y, y + 1);
190
+ } else {
191
+ // Insertion
192
+ currentDiagonal++;
193
+ x = nextValue + range.start;
194
+ y = x + currentDiagonal;
195
+ addChange(accumulator, changes, changeRange, minSpan, x, x + 1, y, y);
196
+ }
197
+
198
+ currentFrontier = history[i >> 1];
199
+ }
200
+
201
+ // Flush any pending change
202
+ if (accumulator.fromA > -1) {
203
+ changes.push(changeRange.slice(accumulator.fromA, accumulator.toA, accumulator.fromB, accumulator.toB));
204
+ }
205
+
206
+ return changes.reverse();
207
+ }
208
+
209
+ /**
210
+ * Compute the minimum length of an unchanged range (not at the start/end of the compared content).
211
+ *
212
+ * The algorithm scales the threshold based on the size of the input, which prevents
213
+ * the diff from becoming fragmented with coincidentally matching characters when
214
+ * comparing larger texts. For small diffs, the threshold is 2; for larger ones,
215
+ * it scales up to a maximum of 15.
216
+ *
217
+ * @param sizeA - The size of the first sequence.
218
+ * @param sizeB - The size of the second sequence.
219
+ * @returns The minimum length for an unchanged range (between 2 and 15).
220
+ */
221
+ function computeMinUnchangedThreshold(sizeA: number, sizeB: number): number {
222
+ const maxSize = Math.max(sizeA, sizeB);
223
+ const scaledThreshold = Math.floor(maxSize / 10);
224
+ return Math.min(15, Math.max(2, scaledThreshold));
225
+ }
226
+
227
+ /**
228
+ * Add a change to the accumulator, merging with the previous change if they're close enough.
229
+ *
230
+ * @param accumulator - The change accumulator state.
231
+ * @param changes - The array of changes being built.
232
+ * @param changeRange - The original change range.
233
+ * @param minSpan - The minimum span between changes to keep them separate.
234
+ * @param fromA - The start position in sequence A.
235
+ * @param toA - The end position in sequence A.
236
+ * @param fromB - The start position in sequence B.
237
+ * @param toB - The end position in sequence B.
238
+ */
239
+ function addChange(accumulator: ChangeAccumulator,
240
+ changes: Array<Change>,
241
+ changeRange: Change,
242
+ minSpan: number,
243
+ fromA: number,
244
+ toA: number,
245
+ fromB: number,
246
+ toB: number): void {
247
+ if (accumulator.fromA > -1 && accumulator.fromA < toA + minSpan) {
248
+ // Merge with the existing accumulated change
249
+ accumulator.fromA = fromA;
250
+ accumulator.fromB = fromB;
251
+ } else {
252
+ // Flush the previous change and start a new one
253
+ if (accumulator.fromA > -1) {
254
+ changes.push(changeRange.slice(accumulator.fromA, accumulator.toA, accumulator.fromB, accumulator.toB));
255
+ }
256
+ accumulator.fromA = fromA;
257
+ accumulator.toA = toA;
258
+ accumulator.fromB = fromB;
259
+ accumulator.toB = toB;
260
+ }
261
+ }
@@ -0,0 +1,43 @@
1
+ import {isLetter} from './is-letter';
2
+
3
+
4
+ /**
5
+ * Expands a position range to word boundaries.
6
+ *
7
+ * If the range starts or ends within a word, expands it to include the entire word.
8
+ * This ensures that changes don't split words in confusing ways.
9
+ *
10
+ * @param text - The text to analyze.
11
+ * @param textStart - The offset where the analyzed text starts in the document.
12
+ * @param textEnd - The end of the text context.
13
+ * @param fromPos - The start position to expand.
14
+ * @param toPos - The end position to expand.
15
+ * @returns A tuple with the expanded [from, to] positions.
16
+ */
17
+ export function expandToWordBoundaries(text: string,
18
+ textStart: number,
19
+ textEnd: number,
20
+ fromPos: number,
21
+ toPos: number): [number, number] {
22
+ let expandedFrom: number = fromPos;
23
+ let expandedTo: number = toPos;
24
+
25
+ // Expand start position backward to word boundary
26
+ if (expandedFrom < textEnd && isLetter(text.charCodeAt(expandedFrom - textStart))) {
27
+ while (expandedFrom > textStart && isLetter(text.charCodeAt(expandedFrom - 1 - textStart))) {
28
+ expandedFrom--;
29
+ }
30
+ }
31
+
32
+ // Expand end position forward to word boundary
33
+ if (expandedTo > textStart
34
+ && expandedTo <= textEnd
35
+ && isLetter(text.charCodeAt(expandedTo - 1 - textStart))) {
36
+
37
+ while (expandedTo < textEnd && isLetter(text.charCodeAt(expandedTo - textStart))) {
38
+ expandedTo++;
39
+ }
40
+ }
41
+
42
+ return [expandedFrom, expandedTo];
43
+ }