@mrclrchtr/supi-lsp 6.0.1 → 6.2.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 +26 -4
- package/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
- package/node_modules/@mrclrchtr/supi-code-runtime/src/capability/types.ts +5 -0
- package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
- package/node_modules/@mrclrchtr/supi-core/src/debug-identity.ts +11 -0
- package/node_modules/@mrclrchtr/supi-core/src/debug.ts +5 -0
- package/node_modules/vscode-jsonrpc/package.json +1 -1
- package/node_modules/vscode-languageserver-protocol/lib/common/protocol.colorProvider.d.ts +1 -1
- package/node_modules/vscode-languageserver-protocol/lib/common/protocol.d.ts +2 -2
- package/node_modules/vscode-languageserver-protocol/lib/common/protocol.fileOperations.d.ts +9 -9
- package/node_modules/vscode-languageserver-protocol/package.json +3 -3
- package/node_modules/vscode-languageserver-types/lib/esm/main.d.ts +2 -2
- package/node_modules/vscode-languageserver-types/lib/esm/main.js +1 -1
- package/node_modules/vscode-languageserver-types/lib/umd/main.d.ts +2 -2
- package/node_modules/vscode-languageserver-types/lib/umd/main.js +1 -1
- package/node_modules/vscode-languageserver-types/package.json +1 -1
- package/package.json +4 -5
- package/src/api.ts +14 -7
- package/src/client/client-diagnostic-cache.ts +1 -1
- package/src/client/client-diagnostic-capabilities.ts +0 -5
- package/src/client/client-diagnostic-publication.ts +6 -3
- package/src/client/client-diagnostic-refresh.ts +2 -1
- package/src/client/client-diagnostic-timing.ts +5 -2
- package/src/client/client-diagnostics.ts +2 -1
- package/src/client/client.ts +23 -5
- package/src/client/transport.ts +40 -18
- package/src/config/config.ts +164 -30
- package/src/config/lsp-settings.ts +9 -87
- package/src/config/server-config.ts +8 -1
- package/src/config/tsconfig-scope.ts +4 -5
- package/src/config/types.ts +1 -29
- package/src/debug-telemetry.ts +7 -21
- package/src/diagnostics/diagnostic-severity.ts +6 -0
- package/src/diagnostics/diagnostic-summary.ts +11 -14
- package/src/diagnostics/evidence.ts +0 -5
- package/src/diagnostics/stale-diagnostics.ts +0 -1
- package/src/diagnostics/workspace-sentinels.ts +29 -49
- package/src/manager/manager-client-state.ts +1 -1
- package/src/manager/manager-diagnostics.ts +4 -8
- package/src/manager/manager-helpers.ts +1 -9
- package/src/manager/manager-project-info.ts +12 -6
- package/src/manager/manager-workspace-recovery.ts +9 -1
- package/src/manager/manager-workspace-symbol.ts +26 -41
- package/src/manager/manager.ts +849 -117
- package/src/provider/lsp-semantic-provider.ts +6 -4
- package/src/provider/semantic-symbol-mapper.ts +59 -28
- package/src/session/runtime-controller.ts +27 -19
- package/src/session/runtime-diagnostic-surface.ts +10 -0
- package/src/session/runtime-diagnostics.ts +26 -0
- package/src/session/runtime-registration.ts +0 -17
- package/src/session/runtime-registry.ts +118 -27
- package/src/session/runtime-transition-debug.ts +7 -2
- package/src/session/scanner.ts +24 -21
- package/src/session/workspace-lsp-runtime.ts +33 -5
- package/src/summary.ts +19 -40
- package/src/utils.ts +13 -19
- package/src/workspace-path-policy.ts +287 -0
- package/src/config/server-actions.ts +0 -59
- package/src/pattern-matcher.ts +0 -24
package/src/session/scanner.ts
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import { dedupeTopmostRoots
|
|
3
|
+
import { dedupeTopmostRoots } from "@mrclrchtr/supi-core/project";
|
|
4
4
|
import type {
|
|
5
5
|
DetectedProjectServer,
|
|
6
6
|
LspConfig,
|
|
7
7
|
MissingServer,
|
|
8
|
-
ProjectServerInfo,
|
|
9
8
|
ServerConfig,
|
|
10
9
|
} from "../config/types.ts";
|
|
11
10
|
import type { LspManager } from "../manager/manager.ts";
|
|
12
11
|
import { commandExists } from "../utils.ts";
|
|
12
|
+
import {
|
|
13
|
+
type AutomaticLspPathPolicy,
|
|
14
|
+
createDefaultAutomaticLspPathPolicy,
|
|
15
|
+
walkAutomaticLspTree,
|
|
16
|
+
} from "../workspace-path-policy.ts";
|
|
13
17
|
|
|
14
18
|
const DEFAULT_MAX_DEPTH = 3;
|
|
15
19
|
|
|
@@ -17,8 +21,14 @@ export function scanProjectCapabilities(
|
|
|
17
21
|
config: LspConfig,
|
|
18
22
|
cwd: string,
|
|
19
23
|
maxDepth: number = DEFAULT_MAX_DEPTH,
|
|
24
|
+
policy: AutomaticLspPathPolicy = createDefaultAutomaticLspPathPolicy(cwd),
|
|
20
25
|
): DetectedProjectServer[] {
|
|
21
|
-
const { markerMatches, extensionBasedServers } = collectServerHints(
|
|
26
|
+
const { markerMatches, extensionBasedServers } = collectServerHints(
|
|
27
|
+
config,
|
|
28
|
+
cwd,
|
|
29
|
+
maxDepth,
|
|
30
|
+
policy,
|
|
31
|
+
);
|
|
22
32
|
|
|
23
33
|
return Object.entries(config.servers)
|
|
24
34
|
.flatMap(([serverName, server]) => {
|
|
@@ -45,11 +55,12 @@ function collectServerHints(
|
|
|
45
55
|
config: LspConfig,
|
|
46
56
|
cwd: string,
|
|
47
57
|
maxDepth: number,
|
|
58
|
+
policy: AutomaticLspPathPolicy,
|
|
48
59
|
): { markerMatches: Map<string, Set<string>>; extensionBasedServers: Set<string> } {
|
|
49
60
|
const ctx: HintContext = { markerMatches: new Map(), extensionBasedServers: new Set() };
|
|
50
61
|
|
|
51
|
-
|
|
52
|
-
inspectDirectory(directory,
|
|
62
|
+
walkAutomaticLspTree(policy, cwd, maxDepth, (directory, entries) => {
|
|
63
|
+
inspectDirectory(directory, new Set(entries.map((entry) => entry.name)), config.servers, ctx);
|
|
53
64
|
});
|
|
54
65
|
|
|
55
66
|
return { markerMatches: ctx.markerMatches, extensionBasedServers: ctx.extensionBasedServers };
|
|
@@ -103,15 +114,10 @@ function hasMatchingFile(directory: string, entryNames: Set<string>, fileTypes:
|
|
|
103
114
|
export async function startDetectedServers(
|
|
104
115
|
manager: LspManager,
|
|
105
116
|
detected: DetectedProjectServer[],
|
|
117
|
+
policy: AutomaticLspPathPolicy = createDefaultAutomaticLspPathPolicy(manager.getCwd()),
|
|
106
118
|
): Promise<void> {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
export function introspectCapabilities(
|
|
111
|
-
manager: LspManager,
|
|
112
|
-
detected: DetectedProjectServer[],
|
|
113
|
-
): ProjectServerInfo[] {
|
|
114
|
-
return manager.getKnownProjectServers(detected);
|
|
119
|
+
const eligible = detected.filter((entry) => policy.isEligible(entry.root, "directory"));
|
|
120
|
+
await Promise.all(eligible.map((entry) => manager.startServerForRoot(entry.name, entry.root)));
|
|
115
121
|
}
|
|
116
122
|
|
|
117
123
|
/**
|
|
@@ -123,17 +129,14 @@ export function scanMissingServers(
|
|
|
123
129
|
config: LspConfig,
|
|
124
130
|
cwd: string,
|
|
125
131
|
maxDepth: number = DEFAULT_MAX_DEPTH,
|
|
132
|
+
policy: AutomaticLspPathPolicy = createDefaultAutomaticLspPathPolicy(cwd),
|
|
126
133
|
): MissingServer[] {
|
|
127
134
|
const foundExtensions = new Set<string>();
|
|
128
135
|
|
|
129
|
-
|
|
130
|
-
for (const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
} catch {
|
|
134
|
-
continue;
|
|
135
|
-
}
|
|
136
|
-
const ext = path.extname(name).slice(1).toLowerCase();
|
|
136
|
+
walkAutomaticLspTree(policy, cwd, maxDepth, (_directory, entries) => {
|
|
137
|
+
for (const entry of entries) {
|
|
138
|
+
if (!entry.isFile() && !entry.isSymbolicLink()) continue;
|
|
139
|
+
const ext = path.extname(entry.name).slice(1).toLowerCase();
|
|
137
140
|
if (ext) foundExtensions.add(ext);
|
|
138
141
|
}
|
|
139
142
|
});
|
|
@@ -13,7 +13,12 @@ import type {
|
|
|
13
13
|
WorkspaceEdit,
|
|
14
14
|
WorkspaceSymbol,
|
|
15
15
|
} from "../config/types.ts";
|
|
16
|
+
import type {
|
|
17
|
+
WorkspaceSentinelScanOptions,
|
|
18
|
+
WorkspaceSentinelSyncResult,
|
|
19
|
+
} from "../diagnostics/workspace-sentinels.ts";
|
|
16
20
|
import type { WorkspaceLspDiagnosticSurface } from "./runtime-diagnostic-surface.ts";
|
|
21
|
+
import type { ProcessCrashRecoverySummary } from "./runtime-diagnostics.ts";
|
|
17
22
|
|
|
18
23
|
export type WorkspaceLspRuntimeState =
|
|
19
24
|
| { kind: "ready"; runtime: WorkspaceLspRuntime }
|
|
@@ -23,9 +28,22 @@ export type WorkspaceLspRuntimeState =
|
|
|
23
28
|
| { kind: "unavailable"; reason: string };
|
|
24
29
|
|
|
25
30
|
export type SemanticReadinessResult =
|
|
26
|
-
| {
|
|
27
|
-
|
|
28
|
-
|
|
31
|
+
| {
|
|
32
|
+
kind: "ready";
|
|
33
|
+
/** Process-crash route recovery observed while establishing file readiness. */
|
|
34
|
+
processCrashRecovery?: ProcessCrashRecoverySummary;
|
|
35
|
+
}
|
|
36
|
+
| {
|
|
37
|
+
kind: "timeout";
|
|
38
|
+
/** Process-crash route recovery observed before the readiness timeout. */
|
|
39
|
+
processCrashRecovery?: ProcessCrashRecoverySummary;
|
|
40
|
+
}
|
|
41
|
+
| {
|
|
42
|
+
kind: "unavailable";
|
|
43
|
+
reason: string;
|
|
44
|
+
/** Process-crash route recovery observed before readiness became unavailable. */
|
|
45
|
+
processCrashRecovery?: ProcessCrashRecoverySummary;
|
|
46
|
+
};
|
|
29
47
|
|
|
30
48
|
/** One mutation response and the exact provider roots from its semantic route. */
|
|
31
49
|
export interface RoutedMutationResponse<T> {
|
|
@@ -40,8 +58,8 @@ export interface RoutedMutationResponse<T> {
|
|
|
40
58
|
* diagnostics, and recovery without exposing clients or the mutable manager.
|
|
41
59
|
* File paths can be absolute or session-cwd-relative. A leading `@` is removed to
|
|
42
60
|
* match Pi's built-in path-tool convention. Positions use raw 0-based LSP coordinates.
|
|
43
|
-
*
|
|
44
|
-
*
|
|
61
|
+
* Request control is forwarded to the routed client and limits the caller's
|
|
62
|
+
* wait without cancelling shared route recovery.
|
|
45
63
|
*/
|
|
46
64
|
export interface WorkspaceLspRuntime extends WorkspaceLspDiagnosticSurface {
|
|
47
65
|
hover(
|
|
@@ -68,9 +86,11 @@ export interface WorkspaceLspRuntime extends WorkspaceLspDiagnosticSurface {
|
|
|
68
86
|
filePath: string,
|
|
69
87
|
control?: CodeRequestControl,
|
|
70
88
|
): Promise<CodeQueryResult<DocumentSymbol[] | SymbolInformation[]>>;
|
|
89
|
+
/** Collect symbols from routes that can contribute within the optional path scopes. */
|
|
71
90
|
workspaceSymbol(
|
|
72
91
|
query: string,
|
|
73
92
|
control?: CodeRequestControl,
|
|
93
|
+
scopes?: readonly string[],
|
|
74
94
|
): Promise<CodeQueryResult<SymbolInformation[] | WorkspaceSymbol[]>>;
|
|
75
95
|
rename(
|
|
76
96
|
filePath: string,
|
|
@@ -94,7 +114,15 @@ export interface WorkspaceLspRuntime extends WorkspaceLspDiagnosticSurface {
|
|
|
94
114
|
control?: CodeRequestControl,
|
|
95
115
|
): Promise<SemanticReadinessResult>;
|
|
96
116
|
getProjectServers(): ProjectServerInfo[];
|
|
117
|
+
/** Check whether automatic LSP work can use and serve the source file. */
|
|
97
118
|
isSupportedSourceFile(filePath: string): boolean;
|
|
119
|
+
/** Inventory policy-eligible workspace sentinels and optional source files. */
|
|
120
|
+
scanWorkspaceSentinels(options?: WorkspaceSentinelScanOptions): Map<string, number>;
|
|
121
|
+
/** Refresh one policy-eligible workspace sentinel and source inventory. */
|
|
122
|
+
syncWorkspaceSentinelSnapshot(
|
|
123
|
+
previous: Map<string, number>,
|
|
124
|
+
options?: WorkspaceSentinelScanOptions,
|
|
125
|
+
): WorkspaceSentinelSyncResult;
|
|
98
126
|
trackFile(filePath: string): Promise<boolean>;
|
|
99
127
|
closeFile(filePath: string): void;
|
|
100
128
|
pruneMissingFiles(): readonly string[];
|
package/src/summary.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type {
|
|
|
3
3
|
ActiveCoverageSummaryEntry,
|
|
4
4
|
OutstandingDiagnosticSummaryEntry,
|
|
5
5
|
} from "./manager/manager-types.ts";
|
|
6
|
+
import type { AutomaticLspPathPolicy } from "./workspace-path-policy.ts";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Display form for a file path used both for human-readable LSP output and as
|
|
@@ -67,9 +68,14 @@ export function normalizeRelevantPaths(relevantPaths: string[]): string[] {
|
|
|
67
68
|
* - candidate has no "/" and no ".": treat as a directory name anywhere in the path
|
|
68
69
|
* - otherwise: treat as a filename and match the basename
|
|
69
70
|
*/
|
|
70
|
-
export function isPathRelevant(
|
|
71
|
+
export function isPathRelevant(
|
|
72
|
+
filePath: string,
|
|
73
|
+
relevantPaths: string[],
|
|
74
|
+
cwd: string,
|
|
75
|
+
policy: AutomaticLspPathPolicy,
|
|
76
|
+
): boolean {
|
|
71
77
|
const normalizedFilePath = normalizeRelevantPath(filePath);
|
|
72
|
-
if (shouldIgnoreLspPath(normalizedFilePath, cwd)) return false;
|
|
78
|
+
if (shouldIgnoreLspPath(normalizedFilePath, cwd, policy)) return false;
|
|
73
79
|
|
|
74
80
|
return relevantPaths.some((candidate) => {
|
|
75
81
|
if (normalizedFilePath === candidate) return true;
|
|
@@ -86,48 +92,21 @@ export function isPathRelevant(filePath: string, relevantPaths: string[], cwd: s
|
|
|
86
92
|
|
|
87
93
|
import { isFileExcludedByTsconfig } from "./config/tsconfig-scope.ts";
|
|
88
94
|
|
|
89
|
-
/** Check whether
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const normalized = normalizeRelevantPath(filePath);
|
|
93
|
-
if (
|
|
94
|
-
normalized === "node_modules" ||
|
|
95
|
-
normalized.startsWith("node_modules/") ||
|
|
96
|
-
normalized.includes("/node_modules/") ||
|
|
97
|
-
normalized === ".pnpm" ||
|
|
98
|
-
normalized.startsWith(".pnpm/") ||
|
|
99
|
-
normalized.includes("/.pnpm/")
|
|
100
|
-
) {
|
|
101
|
-
return false;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const absolutePath = path.resolve(cwd, filePath);
|
|
105
|
-
const relativePath = path.relative(cwd, absolutePath);
|
|
106
|
-
return !(relativePath.startsWith(`..${path.sep}`) || relativePath === "..");
|
|
95
|
+
/** Check whether the supplied automatic path policy allows a file path. */
|
|
96
|
+
export function isInProjectTree(filePath: string, policy: AutomaticLspPathPolicy): boolean {
|
|
97
|
+
return policy.isEligible(filePath);
|
|
107
98
|
}
|
|
108
99
|
|
|
109
|
-
/** Check
|
|
110
|
-
export function
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
if (!isInProjectTree(filePath,
|
|
100
|
+
/** Check automatic and tsconfig diagnostic scope for one file path. */
|
|
101
|
+
export function shouldIgnoreLspPath(
|
|
102
|
+
filePath: string,
|
|
103
|
+
cwd: string,
|
|
104
|
+
policy: AutomaticLspPathPolicy,
|
|
105
|
+
): boolean {
|
|
106
|
+
if (!isInProjectTree(filePath, policy)) return true;
|
|
116
107
|
|
|
117
108
|
const normalized = normalizeRelevantPath(filePath);
|
|
118
|
-
|
|
119
|
-
normalized === "node_modules" ||
|
|
120
|
-
normalized.startsWith("node_modules/") ||
|
|
121
|
-
normalized.includes("/node_modules/") ||
|
|
122
|
-
normalized === ".pnpm" ||
|
|
123
|
-
normalized.startsWith(".pnpm/") ||
|
|
124
|
-
normalized.includes("/.pnpm/") ||
|
|
125
|
-
isFileExcludedByTsconfig(normalized, cwd)
|
|
126
|
-
) {
|
|
127
|
-
return true;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
return false;
|
|
109
|
+
return isFileExcludedByTsconfig(normalized, cwd);
|
|
131
110
|
}
|
|
132
111
|
|
|
133
112
|
function normalizeRelevantPath(filePath: string): string {
|
package/src/utils.ts
CHANGED
|
@@ -3,12 +3,6 @@
|
|
|
3
3
|
import * as fs from "node:fs";
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
|
|
6
|
-
export {
|
|
7
|
-
fileToUri,
|
|
8
|
-
resolveToolPath as resolveSessionPath,
|
|
9
|
-
uriToFile,
|
|
10
|
-
} from "@mrclrchtr/supi-core/path";
|
|
11
|
-
|
|
12
6
|
// ── Language ID Detection ─────────────────────────────────────────────
|
|
13
7
|
|
|
14
8
|
const EXT_TO_LANGUAGE: Record<string, string> = {
|
|
@@ -68,11 +62,6 @@ export function detectLanguageId(filePath: string): string {
|
|
|
68
62
|
return EXT_TO_LANGUAGE[ext] ?? ext;
|
|
69
63
|
}
|
|
70
64
|
|
|
71
|
-
/** Get the file extension (without dot) from a file path. */
|
|
72
|
-
export function getFileExtension(filePath: string): string {
|
|
73
|
-
return path.extname(filePath).slice(1).toLowerCase();
|
|
74
|
-
}
|
|
75
|
-
|
|
76
65
|
// ── PATH Validation ───────────────────────────────────────────────────
|
|
77
66
|
|
|
78
67
|
/**
|
|
@@ -80,9 +69,9 @@ export function getFileExtension(filePath: string): string {
|
|
|
80
69
|
* Uses synchronous check to avoid complexity.
|
|
81
70
|
*/
|
|
82
71
|
export function commandExists(command: string): boolean {
|
|
83
|
-
// If it
|
|
72
|
+
// If it is an absolute path, validate the exact executable file.
|
|
84
73
|
if (path.isAbsolute(command)) {
|
|
85
|
-
return
|
|
74
|
+
return isExecutableFile(command);
|
|
86
75
|
}
|
|
87
76
|
|
|
88
77
|
const pathDirs = (process.env.PATH ?? "").split(path.delimiter);
|
|
@@ -92,13 +81,18 @@ export function commandExists(command: string): boolean {
|
|
|
92
81
|
for (const dir of pathDirs) {
|
|
93
82
|
for (const ext of extensions) {
|
|
94
83
|
const fullPath = path.join(dir, command + ext);
|
|
95
|
-
|
|
96
|
-
fs.accessSync(fullPath, fs.constants.X_OK);
|
|
97
|
-
return true;
|
|
98
|
-
} catch {
|
|
99
|
-
// Not found here, continue
|
|
100
|
-
}
|
|
84
|
+
if (isExecutableFile(fullPath)) return true;
|
|
101
85
|
}
|
|
102
86
|
}
|
|
103
87
|
return false;
|
|
104
88
|
}
|
|
89
|
+
|
|
90
|
+
function isExecutableFile(filePath: string): boolean {
|
|
91
|
+
try {
|
|
92
|
+
if (!fs.statSync(filePath).isFile()) return false;
|
|
93
|
+
fs.accessSync(filePath, fs.constants.X_OK);
|
|
94
|
+
return true;
|
|
95
|
+
} catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { type Dirent, lstatSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import ignore from "ignore";
|
|
4
|
+
import { loadLspSettings } from "./config/lsp-settings.ts";
|
|
5
|
+
|
|
6
|
+
/** Directory names that automatic LSP work never enters or selects. */
|
|
7
|
+
export const AUTOMATIC_LSP_EXCLUDED_DIRECTORIES: readonly string[] = Object.freeze([
|
|
8
|
+
".git",
|
|
9
|
+
".cache",
|
|
10
|
+
".pi",
|
|
11
|
+
".pnpm",
|
|
12
|
+
"node_modules",
|
|
13
|
+
"dist",
|
|
14
|
+
"build",
|
|
15
|
+
"out",
|
|
16
|
+
"coverage",
|
|
17
|
+
".next",
|
|
18
|
+
".nuxt",
|
|
19
|
+
".turbo",
|
|
20
|
+
"__pycache__",
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
const BUILT_IN_EXCLUSIONS = new Set(AUTOMATIC_LSP_EXCLUDED_DIRECTORIES);
|
|
24
|
+
|
|
25
|
+
/** Automatic path rules for one Workspace LSP runtime. The rules do not change. */
|
|
26
|
+
export interface AutomaticLspPathPolicy {
|
|
27
|
+
/** Canonical absolute workspace root used for all matching. */
|
|
28
|
+
readonly workspaceRoot: string;
|
|
29
|
+
/** True when automatic LSP work can use the path. */
|
|
30
|
+
isEligible(candidate: string, kind?: "file" | "directory"): boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Create the automatic path rules for one Workspace LSP runtime. */
|
|
34
|
+
export function createAutomaticLspPathPolicy(
|
|
35
|
+
workspaceRoot: string,
|
|
36
|
+
excludePatterns: readonly string[],
|
|
37
|
+
): AutomaticLspPathPolicy {
|
|
38
|
+
const canonicalRoot = canonicalPath(workspaceRoot);
|
|
39
|
+
const configuredExclusions = createIgnoreMatcher([...excludePatterns]);
|
|
40
|
+
const repositoryRules = compileRepositoryRules(canonicalRoot, configuredExclusions);
|
|
41
|
+
return Object.freeze({
|
|
42
|
+
workspaceRoot: canonicalRoot,
|
|
43
|
+
isEligible(candidate: string, kind: "file" | "directory" = "file"): boolean {
|
|
44
|
+
const relativePath = normalizeCandidate(canonicalRoot, workspaceRoot, candidate);
|
|
45
|
+
if (
|
|
46
|
+
relativePath === null ||
|
|
47
|
+
!isAutomaticCandidateAllowed(workspaceRoot, relativePath, kind)
|
|
48
|
+
) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
return isEligibleRelativePath(
|
|
52
|
+
relativePath,
|
|
53
|
+
kind === "directory",
|
|
54
|
+
configuredExclusions,
|
|
55
|
+
repositoryRules,
|
|
56
|
+
);
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Create path rules for an automatic LSP helper used outside a runtime.
|
|
63
|
+
* Runtime-owned operations should pass the rules created at startup instead.
|
|
64
|
+
*/
|
|
65
|
+
export function createDefaultAutomaticLspPathPolicy(workspaceRoot: string): AutomaticLspPathPolicy {
|
|
66
|
+
return createAutomaticLspPathPolicy(workspaceRoot, loadLspSettings(workspaceRoot).exclude);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Walk paths allowed by the policy without visiting symbolic-link directories.
|
|
71
|
+
*
|
|
72
|
+
* The visitor receives only entries that the automatic LSP policy allows.
|
|
73
|
+
* Return `true` from the visitor to stop the walk after that directory.
|
|
74
|
+
*/
|
|
75
|
+
export function walkAutomaticLspTree(
|
|
76
|
+
policy: AutomaticLspPathPolicy,
|
|
77
|
+
directory: string,
|
|
78
|
+
depth: number,
|
|
79
|
+
onDirectory: (directory: string, entries: readonly Dirent[]) => boolean | undefined,
|
|
80
|
+
): void {
|
|
81
|
+
walkDirectoryTree({
|
|
82
|
+
directory,
|
|
83
|
+
depth,
|
|
84
|
+
includeEntry: (candidate, entry) =>
|
|
85
|
+
policy.isEligible(candidate, entry.isDirectory() ? "directory" : "file"),
|
|
86
|
+
onDirectory,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface DirectoryWalkOptions {
|
|
91
|
+
directory: string;
|
|
92
|
+
depth: number;
|
|
93
|
+
includeEntry: (candidate: string, entry: Dirent) => boolean;
|
|
94
|
+
onDirectory: (directory: string, entries: readonly Dirent[]) => boolean | undefined;
|
|
95
|
+
beforeFilter?: (directory: string) => void;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function walkDirectoryTree(options: DirectoryWalkOptions): boolean {
|
|
99
|
+
const { directory, depth, includeEntry, onDirectory, beforeFilter } = options;
|
|
100
|
+
beforeFilter?.(directory);
|
|
101
|
+
let entries: Dirent[];
|
|
102
|
+
try {
|
|
103
|
+
entries = [...readdirSync(directory, { withFileTypes: true })].sort((a, b) =>
|
|
104
|
+
a.name.localeCompare(b.name),
|
|
105
|
+
);
|
|
106
|
+
} catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const eligibleEntries = entries.filter((entry) =>
|
|
111
|
+
includeEntry(path.join(directory, entry.name), entry),
|
|
112
|
+
);
|
|
113
|
+
if (onDirectory(directory, eligibleEntries) === true) return true;
|
|
114
|
+
if (depth <= 0) return false;
|
|
115
|
+
|
|
116
|
+
for (const entry of eligibleEntries) {
|
|
117
|
+
if (!entry.isDirectory()) continue;
|
|
118
|
+
if (
|
|
119
|
+
walkDirectoryTree({
|
|
120
|
+
...options,
|
|
121
|
+
directory: path.join(directory, entry.name),
|
|
122
|
+
depth: depth - 1,
|
|
123
|
+
})
|
|
124
|
+
) {
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
type IgnoreMatcher = ReturnType<typeof ignore>;
|
|
132
|
+
|
|
133
|
+
function createIgnoreMatcher(patterns: string | readonly string[]): IgnoreMatcher {
|
|
134
|
+
return ignore({ ignorecase: false }).add(patterns);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
interface RepositoryIgnoreRules {
|
|
138
|
+
readonly base: string;
|
|
139
|
+
readonly matcher: IgnoreMatcher;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function compileRepositoryRules(
|
|
143
|
+
workspaceRoot: string,
|
|
144
|
+
configuredExclusions: IgnoreMatcher,
|
|
145
|
+
): readonly RepositoryIgnoreRules[] {
|
|
146
|
+
const rules: RepositoryIgnoreRules[] = [];
|
|
147
|
+
|
|
148
|
+
walkDirectoryTree({
|
|
149
|
+
directory: workspaceRoot,
|
|
150
|
+
depth: Number.MAX_SAFE_INTEGER,
|
|
151
|
+
includeEntry: (candidate, entry) => {
|
|
152
|
+
if (entry.isSymbolicLink() || !entry.isDirectory()) return false;
|
|
153
|
+
const relativePath = path.relative(workspaceRoot, candidate).split(path.sep).join("/");
|
|
154
|
+
return isEligibleRelativePath(relativePath, true, configuredExclusions, rules);
|
|
155
|
+
},
|
|
156
|
+
onDirectory: () => undefined,
|
|
157
|
+
beforeFilter: (directory) => {
|
|
158
|
+
const relativeDirectory = path.relative(workspaceRoot, directory).split(path.sep).join("/");
|
|
159
|
+
const nestedRules = readRepositoryIgnore(directory, relativeDirectory);
|
|
160
|
+
if (nestedRules) rules.push(nestedRules);
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
return Object.freeze([...rules]);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function readRepositoryIgnore(
|
|
168
|
+
directory: string,
|
|
169
|
+
relativeDirectory: string,
|
|
170
|
+
): RepositoryIgnoreRules | null {
|
|
171
|
+
try {
|
|
172
|
+
const matcher = createIgnoreMatcher(readFileSync(path.join(directory, ".gitignore"), "utf-8"));
|
|
173
|
+
return Object.freeze({ base: relativeDirectory, matcher });
|
|
174
|
+
} catch {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function isEligibleRelativePath(
|
|
180
|
+
relativePath: string,
|
|
181
|
+
directory: boolean,
|
|
182
|
+
configuredExclusions: IgnoreMatcher,
|
|
183
|
+
repositoryRules: readonly RepositoryIgnoreRules[],
|
|
184
|
+
): boolean {
|
|
185
|
+
const segments = relativePath.split("/");
|
|
186
|
+
if (
|
|
187
|
+
segments.some(
|
|
188
|
+
(segment, index) =>
|
|
189
|
+
BUILT_IN_EXCLUSIONS.has(segment) && (directory || index < segments.length - 1),
|
|
190
|
+
)
|
|
191
|
+
) {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
if (relativePath === "") return true;
|
|
195
|
+
|
|
196
|
+
const matchPath = directory ? `${relativePath}/` : relativePath;
|
|
197
|
+
if (configuredExclusions.ignores(matchPath)) return false;
|
|
198
|
+
return !isRepositoryIgnored(relativePath, directory, repositoryRules);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function isRepositoryIgnored(
|
|
202
|
+
relativePath: string,
|
|
203
|
+
directory: boolean,
|
|
204
|
+
repositoryRules: readonly RepositoryIgnoreRules[],
|
|
205
|
+
): boolean {
|
|
206
|
+
let ignored = false;
|
|
207
|
+
for (const rules of repositoryRules) {
|
|
208
|
+
const localPath = relativeToRuleBase(rules.base, relativePath);
|
|
209
|
+
if (localPath === null || localPath === "") continue;
|
|
210
|
+
const result = rules.matcher.test(directory ? `${localPath}/` : localPath);
|
|
211
|
+
if (result.ignored) ignored = true;
|
|
212
|
+
else if (result.unignored) ignored = false;
|
|
213
|
+
}
|
|
214
|
+
return ignored;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function relativeToRuleBase(base: string, relativePath: string): string | null {
|
|
218
|
+
if (base === "") return relativePath;
|
|
219
|
+
if (relativePath === base) return "";
|
|
220
|
+
return relativePath.startsWith(`${base}/`) ? relativePath.slice(base.length + 1) : null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function isAutomaticCandidateAllowed(
|
|
224
|
+
suppliedRoot: string,
|
|
225
|
+
relativePath: string,
|
|
226
|
+
kind: "file" | "directory",
|
|
227
|
+
): boolean {
|
|
228
|
+
if (relativePath === "") return true;
|
|
229
|
+
let current = path.resolve(suppliedRoot);
|
|
230
|
+
const segments = relativePath.split("/");
|
|
231
|
+
|
|
232
|
+
for (const [index, segment] of segments.entries()) {
|
|
233
|
+
current = path.join(current, segment);
|
|
234
|
+
let link: ReturnType<typeof lstatSync>;
|
|
235
|
+
try {
|
|
236
|
+
link = lstatSync(current);
|
|
237
|
+
} catch {
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
if (!link.isSymbolicLink()) continue;
|
|
241
|
+
if (index < segments.length - 1) return false;
|
|
242
|
+
|
|
243
|
+
try {
|
|
244
|
+
const target = statSync(current);
|
|
245
|
+
return kind === "file" && target.isFile();
|
|
246
|
+
} catch {
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return true;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function canonicalPath(candidate: string): string {
|
|
255
|
+
try {
|
|
256
|
+
return realpathSync(candidate);
|
|
257
|
+
} catch {
|
|
258
|
+
return path.resolve(candidate);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function normalizeCandidate(
|
|
263
|
+
canonicalRoot: string,
|
|
264
|
+
suppliedRoot: string,
|
|
265
|
+
candidate: string,
|
|
266
|
+
): string | null {
|
|
267
|
+
const absoluteCandidate = path.isAbsolute(candidate)
|
|
268
|
+
? mapSuppliedRootToCanonical(canonicalRoot, suppliedRoot, candidate)
|
|
269
|
+
: path.resolve(canonicalRoot, candidate);
|
|
270
|
+
const relativePath = path.relative(canonicalRoot, absoluteCandidate);
|
|
271
|
+
if (relativePath === "") return "";
|
|
272
|
+
if (relativePath === ".." || relativePath.startsWith(`..${path.sep}`)) return null;
|
|
273
|
+
return relativePath.split(path.sep).join("/");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function mapSuppliedRootToCanonical(
|
|
277
|
+
canonicalRoot: string,
|
|
278
|
+
suppliedRoot: string,
|
|
279
|
+
candidate: string,
|
|
280
|
+
): string {
|
|
281
|
+
const resolvedSuppliedRoot = path.resolve(suppliedRoot);
|
|
282
|
+
const relativePath = path.relative(resolvedSuppliedRoot, path.resolve(candidate));
|
|
283
|
+
if (relativePath === "" || (relativePath !== ".." && !relativePath.startsWith(`..${path.sep}`))) {
|
|
284
|
+
return path.resolve(canonicalRoot, relativePath);
|
|
285
|
+
}
|
|
286
|
+
return path.resolve(candidate);
|
|
287
|
+
}
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
// Server capability action label helpers — maps LSP capabilities to readable action labels.
|
|
2
|
-
|
|
3
|
-
import type { ServerCapabilities } from "../config/types.ts";
|
|
4
|
-
|
|
5
|
-
interface LspServerSupportedActionSpec {
|
|
6
|
-
label: string;
|
|
7
|
-
isSupported: (capabilities: ServerCapabilities | null | undefined) => boolean;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
const LSP_SERVER_SUPPORTED_ACTION_SPECS: readonly LspServerSupportedActionSpec[] = [
|
|
11
|
-
{
|
|
12
|
-
label: "diagnostics [optional file]",
|
|
13
|
-
isSupported: () => true,
|
|
14
|
-
},
|
|
15
|
-
{
|
|
16
|
-
label: "hover(file,line,char)",
|
|
17
|
-
isSupported: (capabilities) => Boolean(capabilities?.hoverProvider),
|
|
18
|
-
},
|
|
19
|
-
{
|
|
20
|
-
label: "definition(file,line,char)",
|
|
21
|
-
isSupported: (capabilities) => Boolean(capabilities?.definitionProvider),
|
|
22
|
-
},
|
|
23
|
-
{
|
|
24
|
-
label: "references(file,line,char)",
|
|
25
|
-
isSupported: (capabilities) => Boolean(capabilities?.referencesProvider),
|
|
26
|
-
},
|
|
27
|
-
{
|
|
28
|
-
label: "implementation(file,line,char)",
|
|
29
|
-
isSupported: (capabilities) => Boolean(capabilities?.implementationProvider),
|
|
30
|
-
},
|
|
31
|
-
{
|
|
32
|
-
label: "symbols(file)",
|
|
33
|
-
isSupported: (capabilities) => Boolean(capabilities?.documentSymbolProvider),
|
|
34
|
-
},
|
|
35
|
-
{
|
|
36
|
-
label: "workspace_symbols(query)",
|
|
37
|
-
isSupported: (capabilities) => Boolean(capabilities?.workspaceSymbolProvider),
|
|
38
|
-
},
|
|
39
|
-
{
|
|
40
|
-
label: "rename(file,line,char,newName)",
|
|
41
|
-
isSupported: (capabilities) => Boolean(capabilities?.renameProvider),
|
|
42
|
-
},
|
|
43
|
-
{
|
|
44
|
-
label: "code_actions(file,line,char)",
|
|
45
|
-
isSupported: (capabilities) => Boolean(capabilities?.codeActionProvider),
|
|
46
|
-
},
|
|
47
|
-
] as const;
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Get the list of supported LSP server actions based on capabilities.
|
|
51
|
-
*/
|
|
52
|
-
export function getSupportedLspServerActions(
|
|
53
|
-
capabilities: ServerCapabilities | null | undefined,
|
|
54
|
-
): string[] {
|
|
55
|
-
if (!capabilities) return [];
|
|
56
|
-
return LSP_SERVER_SUPPORTED_ACTION_SPECS.filter((spec) => spec.isSupported(capabilities)).map(
|
|
57
|
-
(spec) => spec.label,
|
|
58
|
-
);
|
|
59
|
-
}
|
package/src/pattern-matcher.ts
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
import ignore from "ignore";
|
|
2
|
-
|
|
3
|
-
/** Gitignore-style glob pattern matching for path exclusion.
|
|
4
|
-
*
|
|
5
|
-
* Delegates to the {@link https://github.com/kaelzhang/node-ignore | ignore} package,
|
|
6
|
-
* which provides battle-tested .gitignore semantics used by ESLint, Prettier, and others.
|
|
7
|
-
*
|
|
8
|
-
* Supports full gitignore syntax: literal names, `*` / `?` wildcards, `**` recursive globs,
|
|
9
|
-
* leading `/` anchored patterns, trailing `/` directory-only patterns, and `!` negation.
|
|
10
|
-
*
|
|
11
|
-
* **Note:** Patterns that start with `#` are treated as comments unless escaped with `\#`.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Check whether a project-relative file path matches a gitignore-style glob pattern.
|
|
16
|
-
*
|
|
17
|
-
* @param filePath - Relative file path with forward slashes (e.g. `"src/__tests__/bar.test.ts"`)
|
|
18
|
-
* @param pattern - Gitignore-style glob pattern (e.g. `"__tests__/"`, `"*.test.ts"`, `"/build"`)
|
|
19
|
-
* @returns `true` if the file path matches the pattern
|
|
20
|
-
*/
|
|
21
|
-
export function isGlobMatch(filePath: string, pattern: string): boolean {
|
|
22
|
-
if (!filePath || !pattern) return false;
|
|
23
|
-
return ignore().add(pattern).ignores(filePath);
|
|
24
|
-
}
|