@mrclrchtr/supi-lsp 11.3.0 → 12.0.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 (31) hide show
  1. package/README.md +22 -17
  2. package/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
  3. package/node_modules/@mrclrchtr/supi-code-runtime/src/api.ts +3 -1
  4. package/node_modules/@mrclrchtr/supi-code-runtime/src/declaration-level.ts +34 -0
  5. package/node_modules/@mrclrchtr/supi-code-runtime/src/types.ts +64 -28
  6. package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
  7. package/node_modules/semver/classes/range.js +2 -2
  8. package/node_modules/semver/internal/constants.js +30 -0
  9. package/node_modules/semver/internal/trim-comparator-whitespace.js +438 -0
  10. package/node_modules/semver/package.json +1 -1
  11. package/package.json +3 -3
  12. package/src/api.ts +2 -2
  13. package/src/client/client-diagnostic-capabilities.ts +13 -3
  14. package/src/client/client-diagnostics.ts +25 -9
  15. package/src/client/client-document-sync.ts +7 -5
  16. package/src/client/client-edit-capture.ts +258 -0
  17. package/src/client/client.ts +64 -11
  18. package/src/config/capabilities.ts +8 -0
  19. package/src/config/operation-support.ts +48 -79
  20. package/src/config/tsconfig-scope.ts +10 -0
  21. package/src/manager/manager-typescript-coverage.ts +118 -0
  22. package/src/manager/manager.ts +67 -3
  23. package/src/manager/typescript-file-local.ts +87 -0
  24. package/src/provider/lsp-refactor-provider.ts +21 -35
  25. package/src/provider/refactor-command-capture.ts +139 -0
  26. package/src/provider/refactor-planning.ts +202 -47
  27. package/src/provider/semantic-symbol-mapper.ts +22 -17
  28. package/src/session/route-boundary.ts +26 -0
  29. package/src/session/runtime-registry.ts +106 -16
  30. package/src/session/runtime-rename.ts +3 -0
  31. package/src/session/workspace-lsp-runtime.ts +22 -1
@@ -0,0 +1,438 @@
1
+ // @ts-check
2
+ 'use strict'
3
+
4
+ // Reproduce safeRe[COMPARATORTRIM] replacement boundaries, not version
5
+ // validation. Each end-index finder returns an exclusive end, or its start
6
+ // on no match.
7
+ // Positions count from zero. An end of 3 means characters 0, 1, and 2 were read;
8
+ // range.slice(start, end) gives the matched text, not a converted number.
9
+ const {
10
+ MAX_LENGTH,
11
+ MAX_SAFE_BUILD_LENGTH,
12
+ WHITESPACE_CHARACTERS,
13
+ } = require('./constants')
14
+
15
+ /**
16
+ * Remembers the last two rows of digits so their characters need not be reread.
17
+ * Positions start at -1 until a row has been found.
18
+ *
19
+ * @typedef {object} DigitRunCache
20
+ * @property {number} start The first character of the most recently found row.
21
+ * @property {number} end The position after that row's last character.
22
+ * @property {number} previousStart The first character of the previous row.
23
+ * @property {number} previousEnd The position after the previous row.
24
+ */
25
+
26
+ // One entry per UTF-16 code unit keeps membership checks constant-time.
27
+ const whitespace = new Uint8Array(0x10000)
28
+ for (const char of WHITESPACE_CHARACTERS) {
29
+ whitespace[char.charCodeAt(0)] = 1
30
+ }
31
+
32
+ /**
33
+ * Checks whether a character is whitespace, like a space, tab, or newline.
34
+ * Looking past the end of the string returns false.
35
+ *
36
+ * @param {string|undefined} char One character, or undefined past the string's end.
37
+ * @returns {boolean} Whether the character is whitespace.
38
+ * @example isWhitespace(' ') // true
39
+ * @example isWhitespace('1') // false
40
+ */
41
+ const isWhitespace = char =>
42
+ char !== undefined && whitespace[char.charCodeAt(0)] === 1
43
+
44
+ /**
45
+ * Checks for v, =, or whitespace before the version number.
46
+ * These are historical version prefixes, not range operators:
47
+ * < and > are handled separately, as are tilde and caret ranges.
48
+ *
49
+ * @param {string|undefined} char One character to check.
50
+ * @returns {boolean} Whether the character can belong to a version prefix.
51
+ * @example isVersionPrefix('=') // true
52
+ * @example isVersionPrefix('>') // false
53
+ */
54
+ const isVersionPrefix = char =>
55
+ char === 'v' ||
56
+ char === '=' ||
57
+ isWhitespace(char)
58
+
59
+ /**
60
+ * Checks whether a character is one of the digits 0 through 9.
61
+ *
62
+ * @param {string|undefined} char One character to check.
63
+ * @returns {boolean} Whether the character is an ASCII digit.
64
+ * @example isDigit('7') // true
65
+ * @example isDigit('.') // false
66
+ */
67
+ const isDigit = char => char !== undefined && char >= '0' && char <= '9'
68
+
69
+ /**
70
+ * Checks whether a character is an English letter, uppercase or lowercase.
71
+ *
72
+ * @param {string|undefined} char One character to check.
73
+ * @returns {boolean} Whether the character is an ASCII letter.
74
+ * @example isLetter('b') // true
75
+ * @example isLetter('7') // false
76
+ */
77
+ const isLetter = char =>
78
+ char !== undefined && (
79
+ (char >= 'a' && char <= 'z') ||
80
+ (char >= 'A' && char <= 'Z')
81
+ )
82
+
83
+ /**
84
+ * Checks for a letter, digit, or hyphen: the characters allowed inside
85
+ * prerelease and build names such as beta-1.
86
+ *
87
+ * @param {string|undefined} char One character to check.
88
+ * @returns {boolean} Whether the character can be part of an identifier.
89
+ * @example isIdentifierCharacter('-') // true
90
+ * @example isIdentifierCharacter('.') // false; dots separate identifiers
91
+ */
92
+ const isIdentifierCharacter = char => isDigit(char) || isLetter(char) || char === '-'
93
+
94
+ /**
95
+ * Checks whether a version component can begin here: with a digit or
96
+ * a wildcard such as x, X, or *.
97
+ *
98
+ * @param {string|undefined} char One character to check.
99
+ * @returns {boolean} Whether a number or wildcard can start with this character.
100
+ * @example isVersionStart('x') // true
101
+ * @example isVersionStart('v') // false; v belongs to the prefix
102
+ */
103
+ const isVersionStart = char =>
104
+ isDigit(char) ||
105
+ char === 'x' ||
106
+ char === 'X' ||
107
+ char === '*'
108
+
109
+ /**
110
+ * Finds the end of a row of digits, but returns no more than limit digits.
111
+ * Remembers the last two rows so looking at them again does not reread them.
112
+ *
113
+ * @param {string} range The text to read; it is not changed.
114
+ * @param {number} start The position of the first character to check.
115
+ * @param {number} limit The most digit characters the returned match can include.
116
+ * @param {DigitRunCache} digitRuns Shared cache of digit-run positions, updated in place.
117
+ * @returns {number} The position after the matched digits, or start if none match.
118
+ * @example findDigitsEndIndex('123abc', 0, 10, digitRuns) // 3, pointing at a
119
+ * @example findDigitsEndIndex('123abc', 0, 2, digitRuns) // 2, pointing at the unread 3
120
+ */
121
+ const findDigitsEndIndex = (range, start, limit, digitRuns) => {
122
+ if (!isDigit(range[start])) {
123
+ return start
124
+ }
125
+ if (start >= digitRuns.start && start < digitRuns.end) {
126
+ return Math.min(start + limit, digitRuns.end)
127
+ }
128
+ if (start >= digitRuns.previousStart && start < digitRuns.previousEnd) {
129
+ return Math.min(start + limit, digitRuns.previousEnd)
130
+ }
131
+ let end = start + 1
132
+ while (isDigit(range[end])) {
133
+ end++
134
+ }
135
+ // A failed full version can rewind from its second run to its first.
136
+ digitRuns.previousStart = digitRuns.start
137
+ digitRuns.previousEnd = digitRuns.end
138
+ digitRuns.start = start
139
+ digitRuns.end = end
140
+ return Math.min(start + limit, end)
141
+ }
142
+
143
+ /**
144
+ * Reads a number using the old regex's length and leading-zero rules.
145
+ * Strict mode reads just the first zero in 012; loose mode can read all three.
146
+ *
147
+ * @param {string} range The text to read; it is not changed.
148
+ * @param {number} start The position where the number should begin.
149
+ * @param {boolean} loose Whether to allow leading zeros.
150
+ * @param {DigitRunCache} digitRuns Shared digit-run cache, updated as needed.
151
+ * @returns {number} The position after the matched digits, not their numeric value.
152
+ * @example findVersionNumberEndIndex('012', 0, false, digitRuns) // 1
153
+ * @example findVersionNumberEndIndex('012', 0, true, digitRuns) // 3
154
+ */
155
+ const findVersionNumberEndIndex = (range, start, loose, digitRuns) => {
156
+ if (!loose && range[start] === '0') {
157
+ return start + 1
158
+ }
159
+ // Strict syntax allows an initial nonzero digit plus MAX_LENGTH digits.
160
+ return findDigitsEndIndex(range, start, loose ? MAX_LENGTH : MAX_LENGTH + 1, digitRuns)
161
+ }
162
+
163
+ /**
164
+ * Reads one piece of a prerelease name, such as beta or 1 in beta.1.
165
+ * Tries the letter-or-hyphen form first, then the number-only form.
166
+ *
167
+ * @param {string} range The text to read; it is not changed.
168
+ * @param {number} start The position where the identifier should begin.
169
+ * @param {boolean} loose Whether numeric identifiers may have leading zeros.
170
+ * @param {DigitRunCache} digitRuns Shared digit-run cache, updated as needed.
171
+ * @returns {number} The position after the identifier, or start if none matches.
172
+ * @example findPrereleaseIdentifierEndIndex('beta.1', 0, false, digitRuns) // 4, at the dot
173
+ * @example findPrereleaseIdentifierEndIndex('001beta', 0, false, digitRuns) // 7
174
+ */
175
+ const findPrereleaseIdentifierEndIndex = (range, start, loose, digitRuns) => {
176
+ const digitsEnd = findDigitsEndIndex(range, start, MAX_LENGTH, digitRuns)
177
+ // The legacy regex tries a nonnumeric identifier before a numeric one.
178
+ if (isLetter(range[digitsEnd]) || range[digitsEnd] === '-') {
179
+ let end = digitsEnd + 1
180
+ const limit = end + MAX_SAFE_BUILD_LENGTH
181
+ while (end < limit && isIdentifierCharacter(range[end])) {
182
+ end++
183
+ }
184
+ return end
185
+ }
186
+ return findVersionNumberEndIndex(range, start, loose, digitRuns)
187
+ }
188
+
189
+ /**
190
+ * Reads a prerelease suffix such as -beta.1, one dot-separated piece at a time.
191
+ * Loose mode also allows the name without its opening hyphen.
192
+ *
193
+ * @param {string} range The text to read; it is not changed.
194
+ * @param {number} start The position where the suffix should begin, including -.
195
+ * @param {boolean} loose Whether the opening hyphen is optional and zeros allowed.
196
+ * @param {DigitRunCache} digitRuns Shared digit-run cache, updated as needed.
197
+ * @returns {number} The position after the matched suffix, or start if absent.
198
+ * @example findPrereleaseEndIndex('-beta.1+sha', 0, false, digitRuns) // 7, at the plus
199
+ * @example findPrereleaseEndIndex('beta', 0, false, digitRuns) // 0; no opening hyphen
200
+ */
201
+ const findPrereleaseEndIndex = (range, start, loose, digitRuns) => {
202
+ const hyphen = range[start] === '-'
203
+ if (!loose && !hyphen) {
204
+ return start
205
+ }
206
+ const identifierStart = start + (hyphen ? 1 : 0)
207
+ let end = findPrereleaseIdentifierEndIndex(range, identifierStart, loose, digitRuns)
208
+ if (end === identifierStart) {
209
+ if (!loose || !hyphen) {
210
+ return start
211
+ }
212
+ // In loose syntax, a lone hyphen can itself be the identifier.
213
+ end = findPrereleaseIdentifierEndIndex(range, start, loose, digitRuns)
214
+ }
215
+ while (range[end] === '.') {
216
+ const next = findPrereleaseIdentifierEndIndex(range, end + 1, loose, digitRuns)
217
+ if (next === end + 1) {
218
+ break
219
+ }
220
+ end = next
221
+ }
222
+ return end
223
+ }
224
+
225
+ /**
226
+ * Reads one build-name piece, such as sha or 123 in +sha.123.
227
+ * Stops at a disallowed character or the old regex's length limit.
228
+ *
229
+ * @param {string} range The text to read; it is not changed.
230
+ * @param {number} start The position where the identifier should begin.
231
+ * @returns {number} The position after the identifier, or start if none matches.
232
+ * @example findBuildIdentifierEndIndex('sha.123', 0) // 3, at the dot
233
+ * @example findBuildIdentifierEndIndex('001', 0) // 3; leading zeros are allowed
234
+ */
235
+ const findBuildIdentifierEndIndex = (range, start) => {
236
+ let end = start
237
+ while (end - start < MAX_SAFE_BUILD_LENGTH && isIdentifierCharacter(range[end])) {
238
+ end++
239
+ }
240
+ return end
241
+ }
242
+
243
+ /**
244
+ * Reads optional build metadata such as +sha.123.
245
+ * Leaves the position unchanged if there is no plus sign followed by a name.
246
+ *
247
+ * @param {string} range The text to read; it is not changed.
248
+ * @param {number} start The position where the opening + should be.
249
+ * @returns {number} The position after the matched metadata, or start if absent.
250
+ * @example findBuildEndIndex('+sha.123 <2', 0) // 8, at the space
251
+ * @example findBuildEndIndex('+', 0) // 0; a plus sign alone is not a match
252
+ */
253
+ const findBuildEndIndex = (range, start) => {
254
+ if (range[start] !== '+') {
255
+ return start
256
+ }
257
+ let end = findBuildIdentifierEndIndex(range, start + 1)
258
+ if (end === start + 1) {
259
+ return start
260
+ }
261
+ while (range[end] === '.') {
262
+ const next = findBuildIdentifierEndIndex(range, end + 1)
263
+ if (next === end + 1) {
264
+ break
265
+ }
266
+ end = next
267
+ }
268
+ return end
269
+ }
270
+
271
+ /**
272
+ * Reads three numbers separated by dots, then any prerelease and build suffixes.
273
+ * Uses loose rules, so forms such as 01.02.03beta can match here.
274
+ *
275
+ * @param {string} range The text to read; it is not changed.
276
+ * @param {number} start The position of the first number, after any prefix.
277
+ * @param {DigitRunCache} digitRuns Shared digit-run cache, updated as needed.
278
+ * @returns {number} The position after the version match, or start if none matches.
279
+ * @example findLooseFullVersionEndIndex('1.2.3-beta+sha <2', 0, digitRuns) // 14
280
+ * @example findLooseFullVersionEndIndex('1.2', 0, digitRuns) // 0; needs three numbers
281
+ */
282
+ const findLooseFullVersionEndIndex = (range, start, digitRuns) => {
283
+ let end = start
284
+ for (let component = 0; component < 3; component++) {
285
+ const next = findVersionNumberEndIndex(range, end, true, digitRuns)
286
+ if (next === end) {
287
+ return start
288
+ }
289
+ end = next
290
+ if (component < 2) {
291
+ if (range[end] !== '.') {
292
+ return start
293
+ }
294
+ end++
295
+ }
296
+ }
297
+ const prereleaseEnd = findPrereleaseEndIndex(range, end, true, digitRuns)
298
+ return findBuildEndIndex(range, prereleaseEnd)
299
+ }
300
+
301
+ /**
302
+ * Reads one component of a partial or wildcard version, such as 2 or x in 2.x.
303
+ * Numbers use strict leading-zero rules; wildcards take up one character.
304
+ *
305
+ * @param {string} range The text to read; it is not changed.
306
+ * @param {number} start The position where the component should begin.
307
+ * @param {DigitRunCache} digitRuns Shared digit-run cache, updated as needed.
308
+ * @returns {number} The position after the component, or start if none matches.
309
+ * @example findPartialVersionComponentEndIndex('2.x', 0, digitRuns) // 1, at the dot
310
+ * @example findPartialVersionComponentEndIndex('2.x', 2, digitRuns) // 3, after x
311
+ */
312
+ const findPartialVersionComponentEndIndex = (range, start, digitRuns) => {
313
+ if (isDigit(range[start])) {
314
+ return findVersionNumberEndIndex(range, start, false, digitRuns)
315
+ }
316
+ return isVersionStart(range[start]) ? start + 1 : start
317
+ }
318
+
319
+ /**
320
+ * Reads up to three components, allowing forms such as 1, 1.2, or 1.x.
321
+ * After the third component, also reads any strict prerelease and build suffixes.
322
+ *
323
+ * @param {string} range The text to read; it is not changed.
324
+ * @param {number} start The position of a number or wildcard, after any prefix.
325
+ * @param {DigitRunCache} digitRuns Shared digit-run cache, updated as needed.
326
+ * @returns {number} The position after the matched version text.
327
+ * @example findPartialVersionEndIndex('1.x <2', 0, digitRuns) // 3, at the space
328
+ * @example findPartialVersionEndIndex('1.', 0, digitRuns) // 1; the trailing dot is unread
329
+ */
330
+ const findPartialVersionEndIndex = (range, start, digitRuns) => {
331
+ let end = findPartialVersionComponentEndIndex(range, start, digitRuns)
332
+ for (let component = 0; component < 2; component++) {
333
+ if (range[end] !== '.') {
334
+ return end
335
+ }
336
+ const next = findPartialVersionComponentEndIndex(range, end + 1, digitRuns)
337
+ if (next === end + 1) {
338
+ return end
339
+ }
340
+ end = next
341
+ }
342
+ const prereleaseEnd = findPrereleaseEndIndex(range, end, false, digitRuns)
343
+ return findBuildEndIndex(range, prereleaseEnd)
344
+ }
345
+
346
+ /**
347
+ * Finds where a version match ends, without deciding whether the range is valid.
348
+ * Tries a loose full version first, then a partial or wildcard version, just
349
+ * like the old regex's LOOSEPLAIN and XRANGEPLAIN alternatives.
350
+ *
351
+ * @param {string} range The text to read; it is not changed.
352
+ * @param {number} start The position of a number or wildcard, after any prefix.
353
+ * @param {DigitRunCache} digitRuns Shared digit-run cache, updated as needed.
354
+ * @returns {number} The position where scanning the next candidate can resume.
355
+ * @example findVersionEndIndex('1.2.3 <2', 0, digitRuns) // 5, at the space
356
+ * @example findVersionEndIndex('1.x <2', 0, digitRuns) // 3, at the space
357
+ */
358
+ const findVersionEndIndex = (range, start, digitRuns) => {
359
+ const fullVersionEnd = findLooseFullVersionEndIndex(range, start, digitRuns)
360
+ return fullVersionEnd !== start
361
+ ? fullVersionEnd
362
+ : findPartialVersionEndIndex(range, start, digitRuns)
363
+ }
364
+
365
+ /**
366
+ * Finds the one whitespace character the old replacement would remove.
367
+ * Keeps leading whitespace, skips an optional operator, and checks immediately
368
+ * after it. Returns -1 if there is nothing to remove.
369
+ *
370
+ * @param {string} range The text containing a candidate already found by the caller.
371
+ * @param {number} matchStart The candidate's position, including leading whitespace.
372
+ * @returns {number} The position of the removable whitespace, or -1 if absent.
373
+ * @example findRemovableWhitespaceIndex(' > 1', 0) // 2; keep the space at position 0
374
+ * @example findRemovableWhitespaceIndex('>1', 0) // -1
375
+ */
376
+ const findRemovableWhitespaceIndex = (range, matchStart) => {
377
+ let position = matchStart
378
+ if (isWhitespace(range[position])) {
379
+ position++
380
+ }
381
+ if (range[position] === '<' || range[position] === '>') {
382
+ position++
383
+ }
384
+ if (range[position] === '=') {
385
+ position++
386
+ }
387
+ return isWhitespace(range[position]) ? position : -1
388
+ }
389
+
390
+ /**
391
+ * Joins an operator to its version, turning > 1.2.3 into >1.2.3.
392
+ * Copies everything else according to the old regex's matching rules.
393
+ * Moves forward through prefixes and whole versions to avoid repeatedly
394
+ * searching the same characters on long inputs.
395
+ *
396
+ * @param {string} range The range text to process; the original string is unchanged.
397
+ * @returns {string} The range with matched operator whitespace removed.
398
+ * @example trimComparatorWhitespace('> 1.2.3 < 2') // '>1.2.3 <2'
399
+ * @example trimComparatorWhitespace('> invalid') // '> invalid'
400
+ */
401
+ const trimComparatorWhitespace = range => {
402
+ let result = ''
403
+ let copyFrom = 0
404
+ let cursor = 0
405
+ const digitRuns = { start: -1, end: -1, previousStart: -1, previousEnd: -1 }
406
+
407
+ while (cursor < range.length) {
408
+ const matchStart = cursor
409
+ if (range[cursor] === '<' || range[cursor] === '>') {
410
+ cursor++
411
+ } else if (!isVersionPrefix(range[cursor]) && !isVersionStart(range[cursor])) {
412
+ cursor++
413
+ continue
414
+ }
415
+
416
+ // Consume a v/=/whitespace run once, even when no version follows it.
417
+ while (cursor < range.length && isVersionPrefix(range[cursor])) {
418
+ cursor++
419
+ }
420
+ if (!isVersionStart(range[cursor])) {
421
+ continue
422
+ }
423
+
424
+ const whitespacePosition = findRemovableWhitespaceIndex(range, matchStart)
425
+ if (whitespacePosition !== -1) {
426
+ result += range.slice(copyFrom, whitespacePosition)
427
+ copyFrom = whitespacePosition + 1
428
+ }
429
+
430
+ // Skip the entire match, so a prerelease's trailing v cannot be
431
+ // reconsidered as the prefix of another version.
432
+ cursor = findVersionEndIndex(range, cursor, digitRuns)
433
+ }
434
+
435
+ return result + range.slice(copyFrom)
436
+ }
437
+
438
+ module.exports = trimComparatorWhitespace
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "semver",
3
- "version": "7.8.5",
3
+ "version": "7.8.6",
4
4
  "description": "The semantic version parser used by npm.",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-lsp",
3
- "version": "11.3.0",
3
+ "version": "12.0.0",
4
4
  "description": "Language Server Protocol runtime for SuPi code intelligence",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -38,8 +38,8 @@
38
38
  "vscode-jsonrpc": "^10.0.0",
39
39
  "vscode-languageserver-protocol": "^3.18.5",
40
40
  "vscode-languageserver-types": "^3.18.5",
41
- "@mrclrchtr/supi-code-runtime": "11.3.0",
42
- "@mrclrchtr/supi-core": "11.3.0"
41
+ "@mrclrchtr/supi-code-runtime": "12.0.0",
42
+ "@mrclrchtr/supi-core": "12.0.0"
43
43
  },
44
44
  "bundledDependencies": [
45
45
  "@mrclrchtr/supi-code-runtime",
package/src/api.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  // Public API surface for the LSP session-scoped service.
2
2
 
3
+ export type { CommandEditCaptureResult } from "./client/client-edit-capture.ts";
3
4
  export { isMissingFileError } from "./client/client-file-state.ts";
4
5
  export {
5
6
  getExplicitlyDisabledLanguages,
@@ -7,10 +8,8 @@ export {
7
8
  loadConfig,
8
9
  } from "./config/config.ts";
9
10
  export {
10
- codeActionKindsForOperation,
11
11
  getServerOperationSupport,
12
12
  isCodeActionKindInRange,
13
- REFACTOR_CODE_ACTION_KINDS,
14
13
  supportsCodeActionResolve,
15
14
  supportsCodeActions,
16
15
  supportsPrepareRename,
@@ -91,6 +90,7 @@ export type {
91
90
  ProcessCrashRecoveryOutcome,
92
91
  ProcessCrashRecoveryReport,
93
92
  RecoverDiagnosticsResult,
93
+ RoutedCodeActionResponse,
94
94
  RoutedMutationDocumentSnapshot,
95
95
  RoutedMutationResponse,
96
96
  SemanticReadinessResult,
@@ -7,6 +7,7 @@
7
7
  // dynamic set for `textDocument/diagnostic` is non-empty.
8
8
 
9
9
  import { posix as posixPath } from "node:path";
10
+ import type { CFamilyFileAssociationResolver } from "@mrclrchtr/supi-code-runtime/api";
10
11
  import { uriToFile } from "@mrclrchtr/supi-core/path";
11
12
  import { detectLanguageId } from "../utils.ts";
12
13
 
@@ -51,12 +52,21 @@ export function isValidDocumentSelector(value: unknown): boolean {
51
52
  );
52
53
  }
53
54
 
54
- /** Match one valid document selector against a file URI. */
55
- export function isDocumentSelectorApplicable(selector: unknown, uri: string): boolean {
55
+ /**
56
+ * Match one valid document selector against a file URI.
57
+ *
58
+ * Pass the client's file associations so the language matches the ID sent in
59
+ * `didOpen` (for example, a `.h` file associated with `cpp`).
60
+ */
61
+ export function isDocumentSelectorApplicable(
62
+ selector: unknown,
63
+ uri: string,
64
+ associations?: CFamilyFileAssociationResolver,
65
+ ): boolean {
56
66
  if (selector === undefined || selector === null) return true;
57
67
  if (!isValidDocumentSelector(selector)) return false;
58
68
  if (!Array.isArray(selector) || selector.length === 0) return false;
59
- const language = detectLanguageId(uriToFile(uri));
69
+ const language = detectLanguageId(uriToFile(uri), associations);
60
70
  const scheme = readUriScheme(uri);
61
71
  const filePath = uriToFile(uri).replaceAll("\\", "/");
62
72
  return selector.some((entry) => {
@@ -237,22 +237,17 @@ export class ClientDiagnostics {
237
237
  for (const update of updates) {
238
238
  const document = this.#openDocs.get(update.document.uri);
239
239
  if (!document) throw new Error("Semantic input changed while synchronization was running.");
240
- this.#synchronizeTrackedDocument(
241
- update.document.uri,
242
- update.document.filePath,
243
- update.content,
244
- document,
245
- );
240
+ this.#synchronizeTrackedDocument(update.document, update.content, document);
246
241
  this.#failedUris.delete(update.document.uri);
247
242
  }
248
243
  }
249
244
 
250
245
  /** Apply one tracked-document change with the shared client state. */
251
246
  #synchronizeTrackedDocument(
252
- uri: string,
253
- filePath: string,
247
+ { uri, filePath }: { readonly uri: string; readonly filePath: string },
254
248
  content: string,
255
249
  document: OpenDocumentState,
250
+ options: { fullText?: boolean } = {},
256
251
  ): void {
257
252
  synchronizeTrackedDocument({
258
253
  uri,
@@ -267,6 +262,7 @@ export class ClientDiagnostics {
267
262
  markUnversionedSyncMoment: () => this.#unversionedPushSyncMoments.set(uri, Date.now()),
268
263
  clearFailedFile: () => this.#failedUris.delete(uri),
269
264
  open: () => this.didOpen(filePath, content),
265
+ ...(options.fullText ? { fullText: true } : {}),
270
266
  });
271
267
  }
272
268
 
@@ -394,7 +390,27 @@ export class ClientDiagnostics {
394
390
  const nextFingerprint = fingerprintDocumentContent(content);
395
391
  if (doc.contentFingerprint === nextFingerprint) return;
396
392
  this.#advanceInputRevision();
397
- this.#synchronizeTrackedDocument(uri, filePath, content, doc);
393
+ this.#synchronizeTrackedDocument({ uri, filePath }, content, doc);
394
+ }
395
+
396
+ /**
397
+ * Re-send an open document's tracked content under a new version, even when
398
+ * it is unchanged. The tracked content matches disk: semantic requests
399
+ * synchronize open documents with disk before they run. Used after a server ran a command whose pushed
400
+ * edit was refused, so the server's model matches the client again.
401
+ * Returns the versions before and after, or null when the document is not open.
402
+ */
403
+ resynchronizeOpenDocument(filePath: string): { from: number; to: number } | null {
404
+ if (!this.host.isOperational()) return null;
405
+ const uri = fileToUri(filePath);
406
+ const doc = this.#openDocs.get(uri);
407
+ if (!doc) return null;
408
+ const from = doc.version;
409
+ this.#advanceInputRevision();
410
+ // Full text: the server may have changed its own copy, so a range computed
411
+ // from the client's content could no longer cover the whole document.
412
+ this.#synchronizeTrackedDocument({ uri, filePath }, doc.content, doc, { fullText: true });
413
+ return { from, to: doc.version };
398
414
  }
399
415
 
400
416
  /** Send a save after a disk change that the input barrier observed. */
@@ -73,6 +73,8 @@ interface SynchronizeDocumentOptions {
73
73
  documentSync: NormalizedDocumentSync;
74
74
  waiters: DiagnosticWaitRegistry;
75
75
  sendNotification: NotificationSender;
76
+ /** Send the whole text without a range, even under incremental sync (LSP allows both). */
77
+ fullText?: boolean;
76
78
  }
77
79
 
78
80
  function endPosition(content: string): { line: number; character: number } {
@@ -117,11 +119,9 @@ export function synchronizeDocument(options: SynchronizeDocumentOptions): void {
117
119
  retainDocumentSynchronization(options);
118
120
  options.document.version = options.version;
119
121
  options.document.synchronizationId = options.synchronizationId;
120
- const changes = contentChanges(
121
- options.document.content,
122
- options.content,
123
- options.documentSync.change,
124
- );
122
+ const changes = options.fullText
123
+ ? [{ text: options.content }]
124
+ : contentChanges(options.document.content, options.content, options.documentSync.change);
125
125
  options.document.content = options.content;
126
126
  options.document.contentFingerprint = fingerprintDocumentContent(options.content);
127
127
  if (options.documentSync.change === TextDocumentSyncKind.None) return;
@@ -161,6 +161,7 @@ export function synchronizeTrackedDocument(options: {
161
161
  markUnversionedSyncMoment?(): void;
162
162
  clearFailedFile?(): void;
163
163
  open(): void;
164
+ fullText?: boolean;
164
165
  }): void {
165
166
  if (!options.document) {
166
167
  options.open();
@@ -177,6 +178,7 @@ export function synchronizeTrackedDocument(options: {
177
178
  documentSync: options.documentSync,
178
179
  waiters: options.waiters,
179
180
  sendNotification: options.sendNotification,
181
+ ...(options.fullText ? { fullText: true } : {}),
180
182
  });
181
183
  options.clearFailedFile?.();
182
184
  }