@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.
Files changed (59) hide show
  1. package/README.md +26 -4
  2. package/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
  3. package/node_modules/@mrclrchtr/supi-code-runtime/src/capability/types.ts +5 -0
  4. package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
  5. package/node_modules/@mrclrchtr/supi-core/src/debug-identity.ts +11 -0
  6. package/node_modules/@mrclrchtr/supi-core/src/debug.ts +5 -0
  7. package/node_modules/vscode-jsonrpc/package.json +1 -1
  8. package/node_modules/vscode-languageserver-protocol/lib/common/protocol.colorProvider.d.ts +1 -1
  9. package/node_modules/vscode-languageserver-protocol/lib/common/protocol.d.ts +2 -2
  10. package/node_modules/vscode-languageserver-protocol/lib/common/protocol.fileOperations.d.ts +9 -9
  11. package/node_modules/vscode-languageserver-protocol/package.json +3 -3
  12. package/node_modules/vscode-languageserver-types/lib/esm/main.d.ts +2 -2
  13. package/node_modules/vscode-languageserver-types/lib/esm/main.js +1 -1
  14. package/node_modules/vscode-languageserver-types/lib/umd/main.d.ts +2 -2
  15. package/node_modules/vscode-languageserver-types/lib/umd/main.js +1 -1
  16. package/node_modules/vscode-languageserver-types/package.json +1 -1
  17. package/package.json +4 -5
  18. package/src/api.ts +14 -7
  19. package/src/client/client-diagnostic-cache.ts +1 -1
  20. package/src/client/client-diagnostic-capabilities.ts +0 -5
  21. package/src/client/client-diagnostic-publication.ts +6 -3
  22. package/src/client/client-diagnostic-refresh.ts +2 -1
  23. package/src/client/client-diagnostic-timing.ts +5 -2
  24. package/src/client/client-diagnostics.ts +2 -1
  25. package/src/client/client.ts +23 -5
  26. package/src/client/transport.ts +40 -18
  27. package/src/config/config.ts +164 -30
  28. package/src/config/lsp-settings.ts +9 -87
  29. package/src/config/server-config.ts +8 -1
  30. package/src/config/tsconfig-scope.ts +4 -5
  31. package/src/config/types.ts +1 -29
  32. package/src/debug-telemetry.ts +7 -21
  33. package/src/diagnostics/diagnostic-severity.ts +6 -0
  34. package/src/diagnostics/diagnostic-summary.ts +11 -14
  35. package/src/diagnostics/evidence.ts +0 -5
  36. package/src/diagnostics/stale-diagnostics.ts +0 -1
  37. package/src/diagnostics/workspace-sentinels.ts +29 -49
  38. package/src/manager/manager-client-state.ts +1 -1
  39. package/src/manager/manager-diagnostics.ts +4 -8
  40. package/src/manager/manager-helpers.ts +1 -9
  41. package/src/manager/manager-project-info.ts +12 -6
  42. package/src/manager/manager-workspace-recovery.ts +9 -1
  43. package/src/manager/manager-workspace-symbol.ts +26 -41
  44. package/src/manager/manager.ts +849 -117
  45. package/src/provider/lsp-semantic-provider.ts +6 -4
  46. package/src/provider/semantic-symbol-mapper.ts +59 -28
  47. package/src/session/runtime-controller.ts +27 -19
  48. package/src/session/runtime-diagnostic-surface.ts +10 -0
  49. package/src/session/runtime-diagnostics.ts +26 -0
  50. package/src/session/runtime-registration.ts +0 -17
  51. package/src/session/runtime-registry.ts +118 -27
  52. package/src/session/runtime-transition-debug.ts +7 -2
  53. package/src/session/scanner.ts +24 -21
  54. package/src/session/workspace-lsp-runtime.ts +33 -5
  55. package/src/summary.ts +19 -40
  56. package/src/utils.ts +13 -19
  57. package/src/workspace-path-policy.ts +287 -0
  58. package/src/config/server-actions.ts +0 -59
  59. package/src/pattern-matcher.ts +0 -24
@@ -1,15 +1,19 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import { dedupeTopmostRoots, walkProject } from "@mrclrchtr/supi-core/project";
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(config, cwd, maxDepth);
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
- walkProject(cwd, maxDepth, (directory, entryNames) => {
52
- inspectDirectory(directory, entryNames, config.servers, ctx);
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
- await Promise.all(detected.map((entry) => manager.startServerForRoot(entry.name, entry.root)));
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
- walkProject(cwd, maxDepth, (directory, entryNames) => {
130
- for (const name of entryNames) {
131
- try {
132
- if (!fs.statSync(path.join(directory, name)).isFile()) continue;
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
- | { kind: "ready" }
27
- | { kind: "timeout" }
28
- | { kind: "unavailable"; reason: string };
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
- * Provider request control is accepted
44
- * as metadata only in this expansion and is not interpreted by the runtime.
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(filePath: string, relevantPaths: string[], cwd: string): boolean {
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 a file path is inside the project tree (within cwd, not node_modules/.pnpm/out-of-tree).
90
- * Does NOT check tsconfig exclusion use `shouldIgnoreLspPath` for diagnostics/guidance filtering. */
91
- export function isInProjectTree(filePath: string, cwd: string): boolean {
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 whether a file path is inside the current project (not ignored and within cwd). */
110
- export function isProjectSource(filePath: string, cwd: string): boolean {
111
- return isInProjectTree(filePath, cwd);
112
- }
113
-
114
- export function shouldIgnoreLspPath(filePath: string, cwd: string): boolean {
115
- if (!isInProjectTree(filePath, cwd)) return true;
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
- if (
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's an absolute path, check directly
72
+ // If it is an absolute path, validate the exact executable file.
84
73
  if (path.isAbsolute(command)) {
85
- return fs.existsSync(command);
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
- try {
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
- }
@@ -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
- }