@mrclrchtr/supi-lsp 11.3.0 → 11.4.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.
- package/README.md +1 -1
- package/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
- package/node_modules/@mrclrchtr/supi-code-runtime/src/api.ts +2 -1
- package/node_modules/@mrclrchtr/supi-code-runtime/src/declaration-level.ts +34 -0
- package/node_modules/@mrclrchtr/supi-code-runtime/src/types.ts +13 -5
- package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
- package/node_modules/semver/classes/range.js +2 -2
- package/node_modules/semver/internal/constants.js +30 -0
- package/node_modules/semver/internal/trim-comparator-whitespace.js +438 -0
- package/node_modules/semver/package.json +1 -1
- package/package.json +3 -3
- package/src/config/tsconfig-scope.ts +10 -0
- package/src/manager/manager-typescript-coverage.ts +118 -0
- package/src/manager/manager.ts +64 -2
- package/src/manager/typescript-file-local.ts +87 -0
- package/src/provider/semantic-symbol-mapper.ts +22 -17
- package/src/session/runtime-registry.ts +49 -4
package/README.md
CHANGED
|
@@ -100,7 +100,7 @@ For example, select a compiler installed as `my-ts6` without changing the TLS co
|
|
|
100
100
|
- `code_health` and `/supi-ci-status` show the `native` or `tsserver` backend and resolved compiler version when known, separately from wrapper version. Manual/legacy-fallback launches have no inferred compiler identity. Missing versions remain unknown. Selection failures identify the package/root and a corrective action.
|
|
101
101
|
- Topmost-root collapsing remains unchanged. Mixed nested backends, Windows live qualification, dependency watchers, performance benchmarking, and full TLS/native feature parity are outside this change. Existing legacy TLS support is not deliberately removed.
|
|
102
102
|
|
|
103
|
-
Focused qualification
|
|
103
|
+
Focused qualification uses the native TypeScript 7 alias (`@typescript/native`), TLS, ordinary TypeScript 6, and `@typescript/typescript6` backed by `@typescript/old` → TypeScript 6. Exact versions come from the root `package.json` and lockfile; the wrapper's dependency range does not fix its compiler version, so the lockfile does. SuPi retains TS6 for its compiler-API dependency and legacy-server tests. Workspace typechecking and declaration builds use the native TS7 launcher directly to avoid ambiguity between the installed `tsc` binaries.
|
|
104
104
|
|
|
105
105
|
The required [Linux/macOS CI matrix](../../.github/workflows/ci.yml) keeps [selection/layout tests](__tests__/unit/typescript-launch.test.ts), [real native lifecycle tests](__tests__/integration/workspace-runtime.integration.typescript-native.test.ts), and [wrapper lifecycle tests](__tests__/integration/workspace-runtime.integration.typescript-launch.test.ts) separate from the [public TS/JS semantic matrix](../supi-code-intelligence/__tests__/acceptance/language-baseline-harness.ts). Required missing/unavailable behavior fails rather than skips. The matrix covers hover, definitions, references, meaningful implementations, document/workspace symbols, confirmed error → clean diagnostics, refresh, cross-file invalidation, Unicode-safe rename plan/apply and stale-plan rejection, and precise organize-imports. The TypeScript public matrix also checks unused-import removal, unchanged source during planning, stale-plan rejection, and preservation of unrelated text; native support is imports-only. Native TSX/JSX and module suffixes receive routing/synchronization checks rather than duplicate matrices. Existing stale-result and edit-safety suites remain in place.
|
|
106
106
|
|
|
@@ -14,6 +14,7 @@ export type {
|
|
|
14
14
|
StructuralProvider,
|
|
15
15
|
StructuralResult,
|
|
16
16
|
} from "./capability/types.ts";
|
|
17
|
+
export { childDeclarationLevel } from "./declaration-level.ts";
|
|
17
18
|
export type {
|
|
18
19
|
CFamilyExtension,
|
|
19
20
|
CFamilyFileAssociationResolver,
|
|
@@ -50,7 +51,7 @@ export type {
|
|
|
50
51
|
CodeResult,
|
|
51
52
|
CodeSymbol,
|
|
52
53
|
ConfidenceMode,
|
|
53
|
-
|
|
54
|
+
DeclarationLevel,
|
|
54
55
|
DisambiguationCandidate,
|
|
55
56
|
DocumentCodeSymbol,
|
|
56
57
|
DocumentEditPrecondition,
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { DeclarationLevel } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
/** Declarations whose body holds statements, so nested declarations are local to it. */
|
|
4
|
+
const FUNCTION_LIKE_KINDS = new Set(["function", "method", "constructor"]);
|
|
5
|
+
/** Bindings whose value may be a function (locals) or an object literal (members). */
|
|
6
|
+
const VALUE_BINDING_KINDS = new Set(["variable", "constant", "property", "field"]);
|
|
7
|
+
/** Kinds that name an entry of an object, type, or enum rather than a statement-level binding. */
|
|
8
|
+
const MEMBER_ENTRY_KINDS = new Set(["property", "field", "method", "enummember", "key", "event"]);
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Level of a declaration reported directly inside `parent`.
|
|
12
|
+
*
|
|
13
|
+
* Inside a local declaration everything stays local. A function, method, or
|
|
14
|
+
* constructor makes its nested declarations local. A variable, constant,
|
|
15
|
+
* property, or field holds either a function body (nested bindings are local)
|
|
16
|
+
* or an object literal (nested properties and methods are members). Every
|
|
17
|
+
* other container — class, interface, enum, struct, namespace, module, or a
|
|
18
|
+
* document section — makes its nested declarations members.
|
|
19
|
+
*
|
|
20
|
+
* Kinds compare case-insensitively, so LSP SymbolKind names and Tree-sitter
|
|
21
|
+
* outline kinds share one rule.
|
|
22
|
+
*/
|
|
23
|
+
export function childDeclarationLevel(
|
|
24
|
+
parent: { readonly kind: string | null; readonly level: DeclarationLevel },
|
|
25
|
+
childKind: string | null,
|
|
26
|
+
): Exclude<DeclarationLevel, "top-level" | "unknown"> {
|
|
27
|
+
if (parent.level === "local") return "local";
|
|
28
|
+
const parentKind = parent.kind?.toLowerCase() ?? "";
|
|
29
|
+
if (FUNCTION_LIKE_KINDS.has(parentKind)) return "local";
|
|
30
|
+
if (VALUE_BINDING_KINDS.has(parentKind)) {
|
|
31
|
+
return MEMBER_ENTRY_KINDS.has(childKind?.toLowerCase() ?? "") ? "member" : "local";
|
|
32
|
+
}
|
|
33
|
+
return "member";
|
|
34
|
+
}
|
|
@@ -34,8 +34,16 @@ export interface SymbolAnchor {
|
|
|
34
34
|
character: number;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
/**
|
|
38
|
-
|
|
37
|
+
/**
|
|
38
|
+
* Where a declaration sits within its file, as reported by a provider.
|
|
39
|
+
*
|
|
40
|
+
* - `top-level`: directly in the file, not inside another declaration.
|
|
41
|
+
* - `member`: directly inside a class, interface, enum, struct, namespace,
|
|
42
|
+
* module, or object literal (methods, fields, properties, enum members).
|
|
43
|
+
* - `local`: inside the body of a function, method, or constructor.
|
|
44
|
+
* - `unknown`: the provider reported a flat symbol list without hierarchy.
|
|
45
|
+
*/
|
|
46
|
+
export type DeclarationLevel = "top-level" | "member" | "local" | "unknown";
|
|
39
47
|
|
|
40
48
|
/**
|
|
41
49
|
* A discovered symbol / declaration.
|
|
@@ -54,7 +62,7 @@ export interface CodeSymbol {
|
|
|
54
62
|
file: string;
|
|
55
63
|
declarationAnchor: SymbolAnchor;
|
|
56
64
|
nameAnchor?: SymbolAnchor;
|
|
57
|
-
/** Named
|
|
65
|
+
/** Named enclosing declaration when reported; absence does not prove a top-level declaration. */
|
|
58
66
|
container?: string | null;
|
|
59
67
|
}
|
|
60
68
|
|
|
@@ -63,10 +71,10 @@ export interface CodeSymbol {
|
|
|
63
71
|
*
|
|
64
72
|
* `unknown` means the provider returned a flat observation without an actual
|
|
65
73
|
* hierarchy. A reported container name remains metadata and does not promote
|
|
66
|
-
* a flat observation to known
|
|
74
|
+
* a flat observation to a known level.
|
|
67
75
|
*/
|
|
68
76
|
export interface DocumentCodeSymbol extends CodeSymbol {
|
|
69
|
-
|
|
77
|
+
level: DeclarationLevel;
|
|
70
78
|
}
|
|
71
79
|
|
|
72
80
|
// ── Result types ───────────────────────────────────────────────────────
|
|
@@ -119,7 +119,7 @@ class Range {
|
|
|
119
119
|
debug('hyphen replace', range)
|
|
120
120
|
|
|
121
121
|
// `> 1.2.3 < 1.2.5` => `>1.2.3 <1.2.5`
|
|
122
|
-
range = range
|
|
122
|
+
range = trimComparatorWhitespace(range)
|
|
123
123
|
debug('comparator trim', range)
|
|
124
124
|
|
|
125
125
|
// `~ 1.2.3` => `~1.2.3`
|
|
@@ -224,11 +224,11 @@ const parseOptions = require('../internal/parse-options')
|
|
|
224
224
|
const Comparator = require('./comparator')
|
|
225
225
|
const debug = require('../internal/debug')
|
|
226
226
|
const SemVer = require('./semver')
|
|
227
|
+
const trimComparatorWhitespace = require('../internal/trim-comparator-whitespace')
|
|
227
228
|
const {
|
|
228
229
|
safeRe: re,
|
|
229
230
|
src,
|
|
230
231
|
t,
|
|
231
|
-
comparatorTrimReplace,
|
|
232
232
|
tildeTrimReplace,
|
|
233
233
|
caretTrimReplace,
|
|
234
234
|
} = require('../internal/re')
|
|
@@ -15,6 +15,35 @@ const MAX_SAFE_COMPONENT_LENGTH = 16
|
|
|
15
15
|
// the shortest version with a build 0.0.0+BUILD.
|
|
16
16
|
const MAX_SAFE_BUILD_LENGTH = MAX_LENGTH - 6
|
|
17
17
|
|
|
18
|
+
// ECMAScript whitespace and line terminators matched by \s.
|
|
19
|
+
const WHITESPACE_CHARACTERS = [
|
|
20
|
+
'\t',
|
|
21
|
+
'\n',
|
|
22
|
+
'\v',
|
|
23
|
+
'\f',
|
|
24
|
+
'\r',
|
|
25
|
+
' ',
|
|
26
|
+
'\u00a0',
|
|
27
|
+
'\u1680',
|
|
28
|
+
'\u2000',
|
|
29
|
+
'\u2001',
|
|
30
|
+
'\u2002',
|
|
31
|
+
'\u2003',
|
|
32
|
+
'\u2004',
|
|
33
|
+
'\u2005',
|
|
34
|
+
'\u2006',
|
|
35
|
+
'\u2007',
|
|
36
|
+
'\u2008',
|
|
37
|
+
'\u2009',
|
|
38
|
+
'\u200a',
|
|
39
|
+
'\u2028',
|
|
40
|
+
'\u2029',
|
|
41
|
+
'\u202f',
|
|
42
|
+
'\u205f',
|
|
43
|
+
'\u3000',
|
|
44
|
+
'\ufeff',
|
|
45
|
+
]
|
|
46
|
+
|
|
18
47
|
const RELEASE_TYPES = [
|
|
19
48
|
'major',
|
|
20
49
|
'premajor',
|
|
@@ -30,6 +59,7 @@ module.exports = {
|
|
|
30
59
|
MAX_SAFE_COMPONENT_LENGTH,
|
|
31
60
|
MAX_SAFE_BUILD_LENGTH,
|
|
32
61
|
MAX_SAFE_INTEGER,
|
|
62
|
+
WHITESPACE_CHARACTERS,
|
|
33
63
|
RELEASE_TYPES,
|
|
34
64
|
SEMVER_SPEC_VERSION,
|
|
35
65
|
FLAG_INCLUDE_PRERELEASE: 0b001,
|
|
@@ -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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrclrchtr/supi-lsp",
|
|
3
|
-
"version": "11.
|
|
3
|
+
"version": "11.4.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.
|
|
42
|
-
"@mrclrchtr/supi-core": "11.
|
|
41
|
+
"@mrclrchtr/supi-code-runtime": "11.4.0",
|
|
42
|
+
"@mrclrchtr/supi-core": "11.4.0"
|
|
43
43
|
},
|
|
44
44
|
"bundledDependencies": [
|
|
45
45
|
"@mrclrchtr/supi-code-runtime",
|
|
@@ -162,6 +162,16 @@ export function getFileScopeDecision(filePath: string, cwd: string): FileScopeDe
|
|
|
162
162
|
};
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
/**
|
|
166
|
+
* Whether a project config's program includes a file, by its own file set or
|
|
167
|
+
* include/exclude rules. Unlike {@link getFileScopeDecision}, the config need
|
|
168
|
+
* not be the file's nearest one (e.g. a test config including `../src`).
|
|
169
|
+
*/
|
|
170
|
+
export function projectConfigIncludesFile(configPath: string, filePath: string): boolean {
|
|
171
|
+
const parsed = parseProjectConfig(configPath);
|
|
172
|
+
return parsed !== null && decideFileScope(parsed, path.resolve(filePath)).included;
|
|
173
|
+
}
|
|
174
|
+
|
|
165
175
|
function isOutOfTree(cwd: string, absolutePath: string): boolean {
|
|
166
176
|
const relative = path.relative(path.resolve(cwd), absolutePath);
|
|
167
177
|
return relative.startsWith(`..${path.sep}`) || relative === "..";
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import { collectExtendedProjectConfigs } from "../config/tsconfig-extends.ts";
|
|
3
|
+
import { normalizeTsconfigPath } from "../config/tsconfig-path.ts";
|
|
4
|
+
import { getFileScopeDecision, projectConfigIncludesFile } from "../config/tsconfig-scope.ts";
|
|
5
|
+
import { type AutomaticLspPathPolicy, walkAutomaticLspTree } from "../workspace-path-policy.ts";
|
|
6
|
+
|
|
7
|
+
/** Directory depth searched for project configs below a TypeScript route root. */
|
|
8
|
+
const PROJECT_CONFIG_SCAN_DEPTH = 8;
|
|
9
|
+
/** Project config paths named in one coverage reason. */
|
|
10
|
+
const MAX_NAMED_PROJECTS = 3;
|
|
11
|
+
/** File names tsserver loads as projects; `tsconfig.*.json` variants are never auto-loaded. */
|
|
12
|
+
const LOADABLE_PROJECT_CONFIGS = new Set(["tsconfig.json", "jsconfig.json"]);
|
|
13
|
+
|
|
14
|
+
/** Find loadable project configs under a route root, honoring the automatic path policy. */
|
|
15
|
+
export function discoverTypeScriptProjectConfigs(
|
|
16
|
+
root: string,
|
|
17
|
+
policy: AutomaticLspPathPolicy,
|
|
18
|
+
): string[] {
|
|
19
|
+
const configs: string[] = [];
|
|
20
|
+
walkAutomaticLspTree(policy, root, PROJECT_CONFIG_SCAN_DEPTH, (directory, entries) => {
|
|
21
|
+
for (const entry of entries) {
|
|
22
|
+
if (entry.isFile() && LOADABLE_PROJECT_CONFIGS.has(entry.name)) {
|
|
23
|
+
configs.push(path.join(directory, entry.name));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return false;
|
|
27
|
+
});
|
|
28
|
+
return configs;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Project configs whose program includes `file` but show no sign of being loaded.
|
|
33
|
+
*
|
|
34
|
+
* tsserver answers project-wide requests such as references and
|
|
35
|
+
* implementations only from loaded projects, and loads a project only when one
|
|
36
|
+
* of its files is open. An unloaded including project (typically a test
|
|
37
|
+
* config that includes `../src`) can hold matches the answer omits.
|
|
38
|
+
*
|
|
39
|
+
* A project counts as loaded when it is the nearest config of an open file or
|
|
40
|
+
* of a file in the answer (`resultFiles`): tsserver also loads projects on its
|
|
41
|
+
* own during a search, and a match from a project's own file proves it was searched.
|
|
42
|
+
*
|
|
43
|
+
* Base configs extended by another discovered config are skipped: their
|
|
44
|
+
* default include usually spans the whole tree while they own only root
|
|
45
|
+
* tooling files, so counting them would mark nearly every answer partial.
|
|
46
|
+
*/
|
|
47
|
+
export function findUnloadedTypeScriptProjects(options: {
|
|
48
|
+
readonly file: string;
|
|
49
|
+
readonly cwd: string;
|
|
50
|
+
readonly openFiles: readonly string[];
|
|
51
|
+
readonly resultFiles?: readonly string[];
|
|
52
|
+
readonly configs: readonly string[];
|
|
53
|
+
}): string[] {
|
|
54
|
+
const baseConfigs = new Set(
|
|
55
|
+
options.configs.flatMap((config) => [...collectExtendedProjectConfigs(config)]),
|
|
56
|
+
);
|
|
57
|
+
const loaded = new Set<string>();
|
|
58
|
+
for (const loadedFile of [...options.openFiles, ...(options.resultFiles ?? [])]) {
|
|
59
|
+
const decision = getFileScopeDecision(loadedFile, options.cwd);
|
|
60
|
+
if (decision.status === "included" && decision.configPath) {
|
|
61
|
+
loaded.add(normalizeTsconfigPath(decision.configPath));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return options.configs.filter((config) => {
|
|
65
|
+
const key = normalizeTsconfigPath(config);
|
|
66
|
+
return (
|
|
67
|
+
!loaded.has(key) && !baseConfigs.has(key) && projectConfigIncludesFile(config, options.file)
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Other projects that tsserver's implementation search skips for `file`.
|
|
74
|
+
*
|
|
75
|
+
* tsserver 6 and earlier answer `textDocument/implementation` from the file's
|
|
76
|
+
* default project only, unlike references, which span loaded projects. Any
|
|
77
|
+
* other non-base project may implement the target through an import, and
|
|
78
|
+
* opening its files does not change the answer.
|
|
79
|
+
*/
|
|
80
|
+
export function findSkippedImplementationProjects(options: {
|
|
81
|
+
readonly file: string;
|
|
82
|
+
readonly cwd: string;
|
|
83
|
+
readonly configs: readonly string[];
|
|
84
|
+
}): string[] {
|
|
85
|
+
const baseConfigs = new Set(
|
|
86
|
+
options.configs.flatMap((config) => [...collectExtendedProjectConfigs(config)]),
|
|
87
|
+
);
|
|
88
|
+
const decision = getFileScopeDecision(options.file, options.cwd);
|
|
89
|
+
const own = decision.configPath ? normalizeTsconfigPath(decision.configPath) : undefined;
|
|
90
|
+
return options.configs.filter((config) => {
|
|
91
|
+
const key = normalizeTsconfigPath(config);
|
|
92
|
+
return key !== own && !baseConfigs.has(key);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Partial-result reason for tsserver's single-project implementation search. */
|
|
97
|
+
export function singleProjectImplementationReason(skipped: readonly string[]): string {
|
|
98
|
+
const noun = skipped.length === 1 ? "1 other project is" : `${skipped.length} other projects are`;
|
|
99
|
+
return (
|
|
100
|
+
"TypeScript coverage is limited: the TypeScript 6 or earlier server (tsserver) " +
|
|
101
|
+
`searches implementations only in this file's project; ${noun} not searched. ` +
|
|
102
|
+
"Use references to find uses in other projects, or use TypeScript 7."
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Actionable partial-result reason naming a bounded set of unloaded projects. */
|
|
107
|
+
export function typeScriptCoverageGapReason(unloaded: readonly string[], cwd: string): string {
|
|
108
|
+
const named = unloaded
|
|
109
|
+
.slice(0, MAX_NAMED_PROJECTS)
|
|
110
|
+
.map((config) => path.relative(cwd, config) || path.basename(config));
|
|
111
|
+
const more = unloaded.length > named.length ? `, +${unloaded.length - named.length} more` : "";
|
|
112
|
+
const noun = unloaded.length === 1 ? "project that includes" : "projects that include";
|
|
113
|
+
return (
|
|
114
|
+
`TypeScript coverage is limited: ${unloaded.length} ${noun} this file ` +
|
|
115
|
+
`${unloaded.length === 1 ? "is" : "are"} not loaded (${named.join(", ")}${more}). ` +
|
|
116
|
+
"Open a file from each project and retry."
|
|
117
|
+
);
|
|
118
|
+
}
|
package/src/manager/manager.ts
CHANGED
|
@@ -128,6 +128,13 @@ import type {
|
|
|
128
128
|
OutstandingDiagnosticSummaryEntry,
|
|
129
129
|
ServerStatus,
|
|
130
130
|
} from "./manager-types.ts";
|
|
131
|
+
import {
|
|
132
|
+
discoverTypeScriptProjectConfigs,
|
|
133
|
+
findSkippedImplementationProjects,
|
|
134
|
+
findUnloadedTypeScriptProjects,
|
|
135
|
+
singleProjectImplementationReason,
|
|
136
|
+
typeScriptCoverageGapReason,
|
|
137
|
+
} from "./manager-typescript-coverage.ts";
|
|
131
138
|
import {
|
|
132
139
|
recoverWorkspaceDiagnostics as recoverWorkspaceDiagnosticsImpl,
|
|
133
140
|
type WorkspaceRecoveryResult,
|
|
@@ -141,6 +148,7 @@ import {
|
|
|
141
148
|
workspaceSymbolCollectionResult,
|
|
142
149
|
workspaceSymbolCoverageGap,
|
|
143
150
|
} from "./manager-workspace-symbol.ts";
|
|
151
|
+
import { isFileLocalTopLevelDeclaration } from "./typescript-file-local.ts";
|
|
144
152
|
|
|
145
153
|
type UnavailableReason = "missing-command" | "start-failed" | "runtime-error";
|
|
146
154
|
|
|
@@ -412,6 +420,8 @@ export class LspManager {
|
|
|
412
420
|
private warmedSemanticProjects = new Set<string>();
|
|
413
421
|
/** In-flight warm-up probes keyed by project key so concurrent callers share one probe. */
|
|
414
422
|
private pendingWarmProbes = new Map<string, Promise<void>>();
|
|
423
|
+
/** Loadable TypeScript project configs per route root; cleared when a config changes. */
|
|
424
|
+
private typeScriptProjectConfigs = new Map<string, string[]>();
|
|
415
425
|
/** One process-crash recovery budget and shared replacement per route. */
|
|
416
426
|
private processCrashRecoveries = new Map<string, ProcessCrashRecoveryState>();
|
|
417
427
|
/** Client generations that completed the initialize handshake. */
|
|
@@ -572,6 +582,53 @@ export class LspManager {
|
|
|
572
582
|
);
|
|
573
583
|
}
|
|
574
584
|
|
|
585
|
+
/**
|
|
586
|
+
* Coverage gap for a project-wide TypeScript request answered by `client`.
|
|
587
|
+
*
|
|
588
|
+
* Returns a partial-result reason when a TypeScript project that includes
|
|
589
|
+
* `filePath` owns neither an open file nor a file in `resultFiles`, so
|
|
590
|
+
* tsserver may not have searched it; otherwise null. Non-TypeScript routes
|
|
591
|
+
* always return null.
|
|
592
|
+
*
|
|
593
|
+
* Implementation requests not known to use the native backend report every
|
|
594
|
+
* other project instead: tsserver searches only the file's own project.
|
|
595
|
+
*
|
|
596
|
+
* No gap applies when `position` (zero-based) names a top-level declaration
|
|
597
|
+
* its module does not export: no other project can reference it.
|
|
598
|
+
*/
|
|
599
|
+
typeScriptProjectCoverageGap(
|
|
600
|
+
client: LspClient,
|
|
601
|
+
filePath: string,
|
|
602
|
+
request: "references" | "implementation",
|
|
603
|
+
evidence: {
|
|
604
|
+
readonly resultFiles?: readonly string[];
|
|
605
|
+
readonly position?: { readonly line: number; readonly character: number };
|
|
606
|
+
} = {},
|
|
607
|
+
): string | null {
|
|
608
|
+
const { resultFiles = [], position } = evidence;
|
|
609
|
+
if (client.name !== "typescript") return null;
|
|
610
|
+
const rootKey = path.resolve(client.root);
|
|
611
|
+
let configs = this.typeScriptProjectConfigs.get(rootKey);
|
|
612
|
+
if (!configs) {
|
|
613
|
+
configs = discoverTypeScriptProjectConfigs(rootKey, this.automaticPathPolicy);
|
|
614
|
+
this.typeScriptProjectConfigs.set(rootKey, configs);
|
|
615
|
+
}
|
|
616
|
+
const file = resolveSessionPath(this.cwd, filePath);
|
|
617
|
+
if (position && isFileLocalTopLevelDeclaration(file, position)) return null;
|
|
618
|
+
if (request === "implementation" && client.typescriptSelection?.backend !== "native") {
|
|
619
|
+
const skipped = findSkippedImplementationProjects({ file, cwd: this.cwd, configs });
|
|
620
|
+
return skipped.length > 0 ? singleProjectImplementationReason(skipped) : null;
|
|
621
|
+
}
|
|
622
|
+
const unloaded = findUnloadedTypeScriptProjects({
|
|
623
|
+
file,
|
|
624
|
+
cwd: this.cwd,
|
|
625
|
+
openFiles: client.openFiles,
|
|
626
|
+
resultFiles,
|
|
627
|
+
configs,
|
|
628
|
+
});
|
|
629
|
+
return unloaded.length > 0 ? typeScriptCoverageGapReason(unloaded, this.cwd) : null;
|
|
630
|
+
}
|
|
631
|
+
|
|
575
632
|
/** Get or create an LSP client for the given file. */
|
|
576
633
|
async getClientForFile(
|
|
577
634
|
filePath: string,
|
|
@@ -2397,7 +2454,7 @@ export class LspManager {
|
|
|
2397
2454
|
*/
|
|
2398
2455
|
noteWorkspaceChanges(changes: FileEvent[]): void {
|
|
2399
2456
|
if (changes.length > 0) this.invalidationEpoch++;
|
|
2400
|
-
invalidateProjectConfigCaches(changes);
|
|
2457
|
+
if (invalidateProjectConfigCaches(changes)) this.typeScriptProjectConfigs.clear();
|
|
2401
2458
|
this.notifyWorkspaceFileChanges(changes);
|
|
2402
2459
|
}
|
|
2403
2460
|
|
|
@@ -2586,6 +2643,7 @@ export class LspManager {
|
|
|
2586
2643
|
this.warmedWorkspaceSymbolFiles.clear();
|
|
2587
2644
|
this.warmedSemanticProjects.clear();
|
|
2588
2645
|
this.pendingWarmProbes.clear();
|
|
2646
|
+
this.typeScriptProjectConfigs.clear();
|
|
2589
2647
|
this.pendingRestarts.clear();
|
|
2590
2648
|
}
|
|
2591
2649
|
/** Get status of all servers. */
|
|
@@ -3437,15 +3495,19 @@ export class LspManager {
|
|
|
3437
3495
|
}
|
|
3438
3496
|
}
|
|
3439
3497
|
|
|
3440
|
-
|
|
3498
|
+
/** Invalidate parsed project configs; returns whether any config changed. */
|
|
3499
|
+
function invalidateProjectConfigCaches(changes: readonly FileEvent[]): boolean {
|
|
3500
|
+
let changed = false;
|
|
3441
3501
|
for (const change of changes) {
|
|
3442
3502
|
const configPath = uriToFile(change.uri);
|
|
3443
3503
|
if (!isProjectConfigFileName(path.basename(configPath))) continue;
|
|
3504
|
+
changed = true;
|
|
3444
3505
|
invalidateTsconfigCacheForConfig(configPath);
|
|
3445
3506
|
if (change.type === FileChangeType.Created) {
|
|
3446
3507
|
invalidateTsconfigCacheForConfigDir(path.dirname(configPath));
|
|
3447
3508
|
}
|
|
3448
3509
|
}
|
|
3510
|
+
return changed;
|
|
3449
3511
|
}
|
|
3450
3512
|
|
|
3451
3513
|
function collectClientDiagnosticEvidenceDocuments(
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Whether the identifier at `position` names a top-level declaration that only
|
|
6
|
+
* its own file can reference: the file is an ES module and the declaration is
|
|
7
|
+
* neither exported by a modifier nor by an export clause or assignment.
|
|
8
|
+
*
|
|
9
|
+
* Other TypeScript projects can then hold no references or implementations,
|
|
10
|
+
* so a project coverage gap cannot apply. Script files (no import or export),
|
|
11
|
+
* members, unreadable files, and non-declaration positions return false.
|
|
12
|
+
*
|
|
13
|
+
* @param position Zero-based LSP position.
|
|
14
|
+
*/
|
|
15
|
+
export function isFileLocalTopLevelDeclaration(
|
|
16
|
+
filePath: string,
|
|
17
|
+
position: { readonly line: number; readonly character: number },
|
|
18
|
+
): boolean {
|
|
19
|
+
let text: string;
|
|
20
|
+
try {
|
|
21
|
+
text = fs.readFileSync(filePath, "utf8");
|
|
22
|
+
} catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
const sourceFile = ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true);
|
|
26
|
+
if (!ts.isExternalModule(sourceFile)) return false;
|
|
27
|
+
let offset: number;
|
|
28
|
+
try {
|
|
29
|
+
offset = ts.getPositionOfLineAndCharacter(sourceFile, position.line, position.character);
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
const statement = sourceFile.statements.find(
|
|
34
|
+
(candidate) => candidate.getStart(sourceFile) <= offset && offset < candidate.end,
|
|
35
|
+
);
|
|
36
|
+
if (!statement) return false;
|
|
37
|
+
const name = topLevelDeclarationNameAt(statement, offset, sourceFile);
|
|
38
|
+
return name !== null && !hasExportModifier(statement) && !exportedByName(sourceFile, name);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Name of the declaration `statement` declares at `offset`, or null. */
|
|
42
|
+
function topLevelDeclarationNameAt(
|
|
43
|
+
statement: ts.Statement,
|
|
44
|
+
offset: number,
|
|
45
|
+
sourceFile: ts.SourceFile,
|
|
46
|
+
): string | null {
|
|
47
|
+
const names: ts.Identifier[] = [];
|
|
48
|
+
if (
|
|
49
|
+
ts.isFunctionDeclaration(statement) ||
|
|
50
|
+
ts.isClassDeclaration(statement) ||
|
|
51
|
+
ts.isInterfaceDeclaration(statement) ||
|
|
52
|
+
ts.isTypeAliasDeclaration(statement) ||
|
|
53
|
+
ts.isEnumDeclaration(statement)
|
|
54
|
+
) {
|
|
55
|
+
if (statement.name) names.push(statement.name);
|
|
56
|
+
} else if (ts.isVariableStatement(statement)) {
|
|
57
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
58
|
+
if (ts.isIdentifier(declaration.name)) names.push(declaration.name);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const hit = names.find((name) => name.getStart(sourceFile) <= offset && offset <= name.end);
|
|
62
|
+
return hit ? hit.text : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function hasExportModifier(statement: ts.Statement): boolean {
|
|
66
|
+
return (
|
|
67
|
+
ts.canHaveModifiers(statement) &&
|
|
68
|
+
(ts.getModifiers(statement) ?? []).some(
|
|
69
|
+
(modifier) =>
|
|
70
|
+
modifier.kind === ts.SyntaxKind.ExportKeyword ||
|
|
71
|
+
modifier.kind === ts.SyntaxKind.DeclareKeyword,
|
|
72
|
+
)
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** `export { name }`, `export default name`, or `export = name` in this file. */
|
|
77
|
+
function exportedByName(sourceFile: ts.SourceFile, name: string): boolean {
|
|
78
|
+
return sourceFile.statements.some((statement) => {
|
|
79
|
+
if (ts.isExportAssignment(statement)) {
|
|
80
|
+
return ts.isIdentifier(statement.expression) && statement.expression.text === name;
|
|
81
|
+
}
|
|
82
|
+
if (!ts.isExportDeclaration(statement) || statement.moduleSpecifier) return false;
|
|
83
|
+
const clause = statement.exportClause;
|
|
84
|
+
if (!clause || !ts.isNamedExports(clause)) return false;
|
|
85
|
+
return clause.elements.some((element) => (element.propertyName ?? element.name).text === name);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
@@ -2,10 +2,11 @@ import { readFile } from "node:fs/promises";
|
|
|
2
2
|
import type {
|
|
3
3
|
CodeLocation,
|
|
4
4
|
CodeSymbol,
|
|
5
|
-
|
|
5
|
+
DeclarationLevel,
|
|
6
6
|
DocumentCodeSymbol,
|
|
7
7
|
SourceRange,
|
|
8
8
|
} from "@mrclrchtr/supi-code-runtime/api";
|
|
9
|
+
import { childDeclarationLevel } from "@mrclrchtr/supi-code-runtime/api";
|
|
9
10
|
import { uriToFile } from "@mrclrchtr/supi-core/path";
|
|
10
11
|
import type {
|
|
11
12
|
DocumentSymbol,
|
|
@@ -100,15 +101,26 @@ export async function mapDocumentSymbols(
|
|
|
100
101
|
): Promise<DocumentCodeSymbol[]> {
|
|
101
102
|
return flattenDocumentSymbols(symbols, filePath, null, {
|
|
102
103
|
sourceLines: await readSourceLines(filePath),
|
|
103
|
-
|
|
104
|
+
parent: null,
|
|
104
105
|
});
|
|
105
106
|
}
|
|
106
107
|
|
|
108
|
+
/** Level of a symbol reported inside `parent`, or top-level without one. */
|
|
109
|
+
function levelWithin(
|
|
110
|
+
parent: { readonly kind: string; readonly level: DeclarationLevel } | null,
|
|
111
|
+
kind: string,
|
|
112
|
+
): DeclarationLevel {
|
|
113
|
+
return parent ? childDeclarationLevel(parent, kind) : "top-level";
|
|
114
|
+
}
|
|
115
|
+
|
|
107
116
|
function flattenDocumentSymbols(
|
|
108
117
|
symbols: DocumentSymbol[] | SymbolInformation[],
|
|
109
118
|
filePath: string,
|
|
110
119
|
container: string | null,
|
|
111
|
-
context: {
|
|
120
|
+
context: {
|
|
121
|
+
sourceLines: readonly string[] | null;
|
|
122
|
+
parent: { readonly kind: string; readonly level: DeclarationLevel } | null;
|
|
123
|
+
},
|
|
112
124
|
): DocumentCodeSymbol[] {
|
|
113
125
|
const result: DocumentCodeSymbol[] = [];
|
|
114
126
|
for (const sym of symbols) {
|
|
@@ -116,15 +128,13 @@ function flattenDocumentSymbols(
|
|
|
116
128
|
let document: DocumentSymbol | null = null;
|
|
117
129
|
if (isSymbolInformation(sym)) information = sym;
|
|
118
130
|
else document = sym;
|
|
131
|
+
const kind = symbolKindName(sym.kind);
|
|
132
|
+
const level = information ? "unknown" : levelWithin(context.parent, kind);
|
|
133
|
+
const childContext = { sourceLines: context.sourceLines, parent: { kind, level } };
|
|
119
134
|
const declarationRange = toValidRange(document?.range ?? information?.location.range);
|
|
120
135
|
if (!declarationRange) {
|
|
121
136
|
if (document?.children?.length) {
|
|
122
|
-
result.push(
|
|
123
|
-
...flattenDocumentSymbols(document.children, filePath, sym.name, {
|
|
124
|
-
sourceLines: context.sourceLines,
|
|
125
|
-
nesting: "nested",
|
|
126
|
-
}),
|
|
127
|
-
);
|
|
137
|
+
result.push(...flattenDocumentSymbols(document.children, filePath, sym.name, childContext));
|
|
128
138
|
}
|
|
129
139
|
continue;
|
|
130
140
|
}
|
|
@@ -134,23 +144,18 @@ function flattenDocumentSymbols(
|
|
|
134
144
|
: null;
|
|
135
145
|
const symbol: DocumentCodeSymbol = {
|
|
136
146
|
name: sym.name,
|
|
137
|
-
kind
|
|
147
|
+
kind,
|
|
138
148
|
file: filePath,
|
|
139
149
|
declarationAnchor: { line: declStart.line + 1, character: declStart.character + 1 },
|
|
140
150
|
container: information?.containerName ?? container,
|
|
141
|
-
|
|
151
|
+
level,
|
|
142
152
|
};
|
|
143
153
|
if (nameStart) {
|
|
144
154
|
symbol.nameAnchor = { line: nameStart.line + 1, character: nameStart.character + 1 };
|
|
145
155
|
}
|
|
146
156
|
result.push(symbol);
|
|
147
157
|
if (document?.children?.length) {
|
|
148
|
-
result.push(
|
|
149
|
-
...flattenDocumentSymbols(document.children, filePath, sym.name, {
|
|
150
|
-
sourceLines: context.sourceLines,
|
|
151
|
-
nesting: "nested",
|
|
152
|
-
}),
|
|
153
|
-
);
|
|
158
|
+
result.push(...flattenDocumentSymbols(document.children, filePath, sym.name, childContext));
|
|
154
159
|
}
|
|
155
160
|
}
|
|
156
161
|
return result;
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
// Shared session-scoped LSP service registry reused by peer extensions.
|
|
2
2
|
|
|
3
3
|
// biome-ignore lint/style/noExcessiveLinesPerFile: session registry, runtime recovery, and telemetry stay in one file; splitting would hide the recovery contract.
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
4
5
|
import {
|
|
5
6
|
type CodeQueryResult,
|
|
6
7
|
type CodeRequestControl,
|
|
7
8
|
isCodeRequestInterruption,
|
|
8
9
|
mapCodeQueryResult,
|
|
10
|
+
partialCodeQuery,
|
|
9
11
|
throwIfCodeRequestInterrupted,
|
|
10
12
|
unavailableCodeQuery,
|
|
11
13
|
} from "@mrclrchtr/supi-code-runtime/api";
|
|
@@ -193,9 +195,14 @@ class DefaultWorkspaceLspRuntime implements WorkspaceLspRuntime {
|
|
|
193
195
|
control,
|
|
194
196
|
});
|
|
195
197
|
if (!client) return unavailableFileQuery("references", resolvedPath);
|
|
196
|
-
return
|
|
197
|
-
|
|
198
|
-
|
|
198
|
+
return this.withProjectCoverage(
|
|
199
|
+
client,
|
|
200
|
+
{ file: resolvedPath, position },
|
|
201
|
+
"references",
|
|
202
|
+
mapCodeQueryResult(
|
|
203
|
+
await client.references(resolvedPath, position, control),
|
|
204
|
+
(data) => data ?? [],
|
|
205
|
+
),
|
|
199
206
|
);
|
|
200
207
|
}
|
|
201
208
|
|
|
@@ -211,7 +218,30 @@ class DefaultWorkspaceLspRuntime implements WorkspaceLspRuntime {
|
|
|
211
218
|
control,
|
|
212
219
|
});
|
|
213
220
|
if (!client) return unavailableFileQuery("implementation", resolvedPath);
|
|
214
|
-
return
|
|
221
|
+
return this.withProjectCoverage(
|
|
222
|
+
client,
|
|
223
|
+
{ file: resolvedPath, position },
|
|
224
|
+
"implementation",
|
|
225
|
+
await client.implementation(resolvedPath, position, control),
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Downgrade a completed project-wide answer to partial when the TypeScript
|
|
231
|
+
* server may not have searched every relevant project.
|
|
232
|
+
*/
|
|
233
|
+
private withProjectCoverage<T extends Location[] | Location | LocationLink[] | null>(
|
|
234
|
+
client: NonNullable<Awaited<ReturnType<LspManager["ensureFileOpen"]>>>,
|
|
235
|
+
target: { readonly file: string; readonly position: Position },
|
|
236
|
+
request: "references" | "implementation",
|
|
237
|
+
result: CodeQueryResult<T>,
|
|
238
|
+
): CodeQueryResult<T> {
|
|
239
|
+
if (result.kind !== "completed") return result;
|
|
240
|
+
const gap = this.manager.typeScriptProjectCoverageGap(client, target.file, request, {
|
|
241
|
+
resultFiles: locationFiles(result.data),
|
|
242
|
+
position: target.position,
|
|
243
|
+
});
|
|
244
|
+
return gap ? partialCodeQuery(result.data, gap) : result;
|
|
215
245
|
}
|
|
216
246
|
|
|
217
247
|
async documentSymbols(
|
|
@@ -605,6 +635,21 @@ export async function waitForWorkspaceLspRuntime(
|
|
|
605
635
|
return state;
|
|
606
636
|
}
|
|
607
637
|
|
|
638
|
+
/** Local file paths of LSP locations or location links; non-file URIs are skipped. */
|
|
639
|
+
function locationFiles(data: Location[] | Location | LocationLink[] | null): string[] {
|
|
640
|
+
const items: (Location | LocationLink)[] =
|
|
641
|
+
data === null ? [] : Array.isArray(data) ? data : [data];
|
|
642
|
+
return items.flatMap((item) => {
|
|
643
|
+
const uri = "targetUri" in item ? item.targetUri : item.uri;
|
|
644
|
+
if (!uri.startsWith("file:")) return [];
|
|
645
|
+
try {
|
|
646
|
+
return [fileURLToPath(uri)];
|
|
647
|
+
} catch {
|
|
648
|
+
return [];
|
|
649
|
+
}
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
|
|
608
653
|
/** Remove the LSP service state for a session cwd. */
|
|
609
654
|
export function clearWorkspaceLspRuntime(cwd: string): void {
|
|
610
655
|
registry.clear(cwd);
|