@danypops/lector 0.1.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/LICENSE +21 -0
- package/README.md +57 -0
- package/package.json +48 -0
- package/src/adapters/directory-size.ts +19 -0
- package/src/adapters/find-source-files.ts +35 -0
- package/src/adapters/git-repo-fetcher.ts +146 -0
- package/src/adapters/in-memory-content-cache.ts +19 -0
- package/src/adapters/in-memory-search-cache.ts +32 -0
- package/src/adapters/in-memory-symbol-graph.ts +80 -0
- package/src/adapters/in-memory-workspace.ts +28 -0
- package/src/adapters/local-filesystem-workspace.ts +96 -0
- package/src/adapters/local-git.ts +73 -0
- package/src/adapters/lsp/discover-seed-file.ts +130 -0
- package/src/adapters/lsp/json-rpc-stream.ts +67 -0
- package/src/adapters/lsp/language-server-process.ts +184 -0
- package/src/adapters/lsp/lsp-symbol-index.ts +470 -0
- package/src/adapters/lsp/process-resource-usage.ts +61 -0
- package/src/adapters/lsp/typescript-project-files.ts +51 -0
- package/src/adapters/read-only-workspace.ts +23 -0
- package/src/adapters/ripgrep-text-search.ts +97 -0
- package/src/adapters/source-manifest.ts +44 -0
- package/src/adapters/sqlite-content-cache.ts +67 -0
- package/src/adapters/sqlite-search-cache.ts +60 -0
- package/src/adapters/sqlite-symbol-graph.ts +154 -0
- package/src/adapters/tiered-search-cache.ts +29 -0
- package/src/adapters/tree-sitter/typescript-tree-sitter-symbol-index.ts +155 -0
- package/src/cli.ts +777 -0
- package/src/client.ts +63 -0
- package/src/constants.ts +16 -0
- package/src/daemon.ts +166 -0
- package/src/domain/assert-safe-git-argument.ts +19 -0
- package/src/domain/assert-safe-path-segment.ts +17 -0
- package/src/domain/assert-safe-repo-reference.ts +20 -0
- package/src/domain/assert-safe-search-query.ts +17 -0
- package/src/domain/bounded-job-executor.ts +219 -0
- package/src/domain/call-hierarchy.ts +23 -0
- package/src/domain/code-range.ts +11 -0
- package/src/domain/content-hash.ts +14 -0
- package/src/domain/diagnostic.ts +12 -0
- package/src/domain/diagnostics.ts +7 -0
- package/src/domain/document-symbol.ts +19 -0
- package/src/domain/document-symbols.ts +7 -0
- package/src/domain/exact-edit.ts +43 -0
- package/src/domain/find-references.ts +7 -0
- package/src/domain/find-workspace-symbols.ts +7 -0
- package/src/domain/git-diff-result.ts +5 -0
- package/src/domain/git-log-entry.ts +9 -0
- package/src/domain/git-status.ts +17 -0
- package/src/domain/go-to-definition.ts +7 -0
- package/src/domain/go-to-implementation.ts +7 -0
- package/src/domain/hover-at.ts +8 -0
- package/src/domain/hover.ts +7 -0
- package/src/domain/incoming-calls.ts +8 -0
- package/src/domain/language-server-descriptor.ts +122 -0
- package/src/domain/outgoing-calls.ts +8 -0
- package/src/domain/populate-symbol-graph.ts +91 -0
- package/src/domain/prepare-call-hierarchy.ts +8 -0
- package/src/domain/race-workspace-query.ts +39 -0
- package/src/domain/raw-read.ts +24 -0
- package/src/domain/reachable-symbols-from.ts +13 -0
- package/src/domain/repo-fetch-result.ts +18 -0
- package/src/domain/repo-reference.ts +7 -0
- package/src/domain/search-cache-key.ts +16 -0
- package/src/domain/search-text.ts +29 -0
- package/src/domain/symbol-edges-from.ts +9 -0
- package/src/domain/symbol-edges-to.ts +9 -0
- package/src/domain/symbol-graph-generation.ts +14 -0
- package/src/domain/symbol-node-id.ts +13 -0
- package/src/domain/text-search-result.ts +15 -0
- package/src/domain/workspace-query-outcome.ts +24 -0
- package/src/domain/workspace-symbol.ts +14 -0
- package/src/index.ts +121 -0
- package/src/ports/code-intelligence-port.ts +38 -0
- package/src/ports/content-cache-port.ts +52 -0
- package/src/ports/git-port.ts +23 -0
- package/src/ports/repo-fetcher-port.ts +11 -0
- package/src/ports/search-cache-port.ts +15 -0
- package/src/ports/symbol-graph-port.ts +35 -0
- package/src/ports/symbol-index-port.ts +11 -0
- package/src/ports/text-search-port.ts +12 -0
- package/src/ports/workspace-port.ts +35 -0
- package/src/service.ts +961 -0
- package/src/version.ts +6 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { WorkspacePort } from "../ports/workspace-port.ts";
|
|
2
|
+
import type { ContentHash } from "./content-hash.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* An edit intent: write `content` to `path`, guarded by the hash the caller
|
|
6
|
+
* last observed there. `expectedHash: null` asserts the path does not yet
|
|
7
|
+
* exist (a create).
|
|
8
|
+
*/
|
|
9
|
+
export interface ExpectedHashEdit {
|
|
10
|
+
readonly path: string;
|
|
11
|
+
readonly expectedHash: ContentHash | null;
|
|
12
|
+
readonly content: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The committed result of an exact edit. */
|
|
16
|
+
export interface EditOutcome {
|
|
17
|
+
readonly path: string;
|
|
18
|
+
readonly previousHash: ContentHash | null;
|
|
19
|
+
readonly newHash: ContentHash;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Raised when an edit's `expectedHash` no longer matches the workspace:
|
|
24
|
+
* something else changed (or created, or removed) the entry first. The edit
|
|
25
|
+
* is rejected outright rather than silently overwriting — callers must
|
|
26
|
+
* re-observe the entry and retry with a fresh expectedHash.
|
|
27
|
+
*/
|
|
28
|
+
export class StaleExpectedHash extends Error {
|
|
29
|
+
constructor(
|
|
30
|
+
readonly path: string,
|
|
31
|
+
readonly expectedHash: ContentHash | null,
|
|
32
|
+
readonly actualHash: ContentHash | null,
|
|
33
|
+
) {
|
|
34
|
+
super(`stale expected hash at "${path}": expected ${expectedHash ?? "(absent)"}, found ${actualHash ?? "(absent)"}`);
|
|
35
|
+
this.name = "StaleExpectedHash";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Apply an expected-hash-guarded edit to a workspace entry. */
|
|
40
|
+
export async function exactEdit(workspace: WorkspacePort, edit: ExpectedHashEdit): Promise<EditOutcome> {
|
|
41
|
+
const { previousHash, newHash } = await workspace.writeEntry(edit.path, edit.expectedHash, edit.content);
|
|
42
|
+
return { path: edit.path, previousHash, newHash };
|
|
43
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { CodeIntelligencePort } from "../ports/code-intelligence-port.ts";
|
|
2
|
+
import type { WorkspaceLocation } from "./workspace-symbol.ts";
|
|
3
|
+
|
|
4
|
+
/** Every project-wide usage of the symbol at a position. */
|
|
5
|
+
export async function findReferences(index: CodeIntelligencePort, at: WorkspaceLocation, includeDeclaration: boolean): Promise<WorkspaceLocation[]> {
|
|
6
|
+
return index.findReferences(at, includeDeclaration);
|
|
7
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { SymbolIndexPort } from "../ports/symbol-index-port.ts";
|
|
2
|
+
import type { WorkspaceSymbol } from "./workspace-symbol.ts";
|
|
3
|
+
|
|
4
|
+
/** Find workspace symbols matching a fuzzy query string. */
|
|
5
|
+
export async function findWorkspaceSymbols(index: SymbolIndexPort, query: string): Promise<WorkspaceSymbol[]> {
|
|
6
|
+
return index.findSymbols(query);
|
|
7
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** One commit from `git log`. */
|
|
2
|
+
export interface GitLogEntry {
|
|
3
|
+
readonly sha: string;
|
|
4
|
+
readonly authorName: string;
|
|
5
|
+
readonly authorEmail: string;
|
|
6
|
+
/** ISO 8601, as git itself reports it -- never reformatted, to keep timezone info intact. */
|
|
7
|
+
readonly authoredAt: string;
|
|
8
|
+
readonly message: string;
|
|
9
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** One file's status, exposing index (staged) and working-directory (unstaged) status separately rather than a combined code -- see `git status --porcelain` for the full status-letter table. */
|
|
2
|
+
export interface GitStatusEntry {
|
|
3
|
+
readonly path: string;
|
|
4
|
+
/** Present only for a rename/copy entry -- the path this one was renamed/copied from. */
|
|
5
|
+
readonly renamedFrom?: string;
|
|
6
|
+
readonly indexStatus: string;
|
|
7
|
+
readonly workingDirStatus: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** The working tree's overall status: files plus branch tracking state. */
|
|
11
|
+
export interface GitStatusSummary {
|
|
12
|
+
readonly files: readonly GitStatusEntry[];
|
|
13
|
+
readonly ahead: number;
|
|
14
|
+
readonly behind: number;
|
|
15
|
+
readonly current: string | null;
|
|
16
|
+
readonly tracking: string | null;
|
|
17
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { CodeIntelligencePort } from "../ports/code-intelligence-port.ts";
|
|
2
|
+
import type { WorkspaceLocation } from "./workspace-symbol.ts";
|
|
3
|
+
|
|
4
|
+
/** Where the symbol at a position is actually declared. */
|
|
5
|
+
export async function goToDefinition(index: CodeIntelligencePort, at: WorkspaceLocation): Promise<WorkspaceLocation[]> {
|
|
6
|
+
return index.goToDefinition(at);
|
|
7
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { CodeIntelligencePort } from "../ports/code-intelligence-port.ts";
|
|
2
|
+
import type { WorkspaceLocation } from "./workspace-symbol.ts";
|
|
3
|
+
|
|
4
|
+
/** Every concrete implementation of the interface/abstract member at a position -- crosses a port boundary that goToDefinition cannot. */
|
|
5
|
+
export async function goToImplementation(index: CodeIntelligencePort, at: WorkspaceLocation): Promise<WorkspaceLocation[]> {
|
|
6
|
+
return index.goToImplementation(at);
|
|
7
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { CodeIntelligencePort } from "../ports/code-intelligence-port.ts";
|
|
2
|
+
import type { Hover } from "./hover.ts";
|
|
3
|
+
import type { WorkspaceLocation } from "./workspace-symbol.ts";
|
|
4
|
+
|
|
5
|
+
/** Type/doc information for the symbol at a position. */
|
|
6
|
+
export async function hoverAt(index: CodeIntelligencePort, at: WorkspaceLocation): Promise<Hover | undefined> {
|
|
7
|
+
return index.hover(at);
|
|
8
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { CodeRange } from "./code-range.ts";
|
|
2
|
+
|
|
3
|
+
/** Type/doc information for the symbol at a position (LSP textDocument/hover, contents flattened to plain text/markdown). */
|
|
4
|
+
export interface Hover {
|
|
5
|
+
readonly contents: string;
|
|
6
|
+
readonly range?: CodeRange;
|
|
7
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { CodeIntelligencePort } from "../ports/code-intelligence-port.ts";
|
|
2
|
+
import type { IncomingCall } from "./call-hierarchy.ts";
|
|
3
|
+
import type { WorkspaceLocation } from "./workspace-symbol.ts";
|
|
4
|
+
|
|
5
|
+
/** Every real caller of the symbol at `at`, project-wide. */
|
|
6
|
+
export async function incomingCalls(index: CodeIntelligencePort, at: WorkspaceLocation): Promise<IncomingCall[]> {
|
|
7
|
+
return index.incomingCalls(at);
|
|
8
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Everything needed to spawn and address one language's LSP server: how to
|
|
3
|
+
* launch it, which files it applies to, and which directory marks a real
|
|
4
|
+
* project root for it. This exact shape (command/args/extensions/root
|
|
5
|
+
* markers/extra capabilities) is independently converged on by nvim-
|
|
6
|
+
* lspconfig, mason-registry, and multiple real Pi LSP extensions
|
|
7
|
+
* (@dreki-gg/pi-lsp, @narumitw/pi-lsp, @arvoretech/pi-lsp) -- not invented
|
|
8
|
+
* here.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** An npm-module server resolves its JS entry via import.meta.resolve and runs under bun; a system-binary server (gopls, rust-analyzer, clangd) resolves by bare name against PATH. */
|
|
12
|
+
export type LanguageServerLaunch = { readonly kind: "npm-module"; readonly entryModule: string } | { readonly kind: "system-binary"; readonly command: string };
|
|
13
|
+
|
|
14
|
+
export interface LanguageServerDescriptor {
|
|
15
|
+
readonly languageId: string;
|
|
16
|
+
readonly extensions: readonly string[];
|
|
17
|
+
readonly launch: LanguageServerLaunch;
|
|
18
|
+
readonly args: readonly string[];
|
|
19
|
+
/** Checked nearest-first; closest match wins over a more distant one -- a monorepo subproject with its own root marker resolves to itself, not the outer repo. */
|
|
20
|
+
readonly rootMarkers: readonly string[];
|
|
21
|
+
/** Tried before the bounded directory scan when picking a file to warm the server with (e.g. a language's usual entry-point names). */
|
|
22
|
+
readonly commonSeedCandidates: readonly string[];
|
|
23
|
+
/** Extra textDocument/workspace capabilities this specific server gates real features behind (e.g. typescript-language-server withholds diagnostics/callHierarchy unless declared). */
|
|
24
|
+
readonly extraCapabilities?: Record<string, unknown>;
|
|
25
|
+
/** Milliseconds to wait after opening a file before trusting the server's answers -- no server signals "project loaded". Default 1000ms; rust-analyzer needs more (see RUST_DESCRIPTOR). */
|
|
26
|
+
readonly settleMs?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const DEFAULT_SETTLE_MS = 1000;
|
|
30
|
+
|
|
31
|
+
export const TYPESCRIPT_DESCRIPTOR: LanguageServerDescriptor = {
|
|
32
|
+
languageId: "typescript",
|
|
33
|
+
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"],
|
|
34
|
+
launch: { kind: "npm-module", entryModule: "typescript-language-server/lib/cli.mjs" },
|
|
35
|
+
args: ["--stdio"],
|
|
36
|
+
rootMarkers: ["tsconfig.json", "jsconfig.json", "package.json"],
|
|
37
|
+
commonSeedCandidates: ["src/index.ts", "index.ts", "src/main.ts", "main.ts", "src/index.tsx", "index.tsx", "src/index.js", "index.js"],
|
|
38
|
+
// typescript-language-server gates its own diagnosticsSupport/callHierarchyProvider flags
|
|
39
|
+
// on these capabilities being present at all -- omitted, it silently withholds both.
|
|
40
|
+
extraCapabilities: { publishDiagnostics: {}, callHierarchy: {} },
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const PYTHON_DESCRIPTOR: LanguageServerDescriptor = {
|
|
44
|
+
languageId: "python",
|
|
45
|
+
extensions: [".py", ".pyi"],
|
|
46
|
+
launch: { kind: "npm-module", entryModule: "pyright/langserver.index.js" },
|
|
47
|
+
args: ["--stdio"],
|
|
48
|
+
rootMarkers: ["pyrightconfig.json", "pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile"],
|
|
49
|
+
commonSeedCandidates: ["main.py", "src/main.py", "__init__.py", "src/__init__.py"],
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const GO_DESCRIPTOR: LanguageServerDescriptor = {
|
|
53
|
+
languageId: "go",
|
|
54
|
+
extensions: [".go"],
|
|
55
|
+
// gopls ships via `go install`, not npm.
|
|
56
|
+
launch: { kind: "system-binary", command: "gopls" },
|
|
57
|
+
args: ["serve"],
|
|
58
|
+
rootMarkers: ["go.mod", "go.work"],
|
|
59
|
+
commonSeedCandidates: ["main.go", "cmd/main.go"],
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const RUST_DESCRIPTOR: LanguageServerDescriptor = {
|
|
63
|
+
languageId: "rust",
|
|
64
|
+
extensions: [".rs"],
|
|
65
|
+
// rust-analyzer ships via rustup, not npm.
|
|
66
|
+
launch: { kind: "system-binary", command: "rust-analyzer" },
|
|
67
|
+
args: [],
|
|
68
|
+
rootMarkers: ["Cargo.toml"],
|
|
69
|
+
commonSeedCandidates: ["src/main.rs", "src/lib.rs"],
|
|
70
|
+
// A query under ~800ms after didOpen can return a null result; 2500ms holds a real margin.
|
|
71
|
+
settleMs: 2500,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export const CPP_DESCRIPTOR: LanguageServerDescriptor = {
|
|
75
|
+
languageId: "cpp",
|
|
76
|
+
extensions: [".c", ".h", ".cc", ".cpp", ".cxx", ".hh", ".hpp", ".hxx"],
|
|
77
|
+
// clangd ships via LLVM's system packaging, not npm.
|
|
78
|
+
launch: { kind: "system-binary", command: "clangd" },
|
|
79
|
+
args: [],
|
|
80
|
+
rootMarkers: ["compile_commands.json", "compile_flags.txt", "CMakeLists.txt"],
|
|
81
|
+
commonSeedCandidates: ["main.cpp", "main.c", "src/main.cpp", "src/main.c"],
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export const BASH_DESCRIPTOR: LanguageServerDescriptor = {
|
|
85
|
+
languageId: "shellscript",
|
|
86
|
+
extensions: [".sh", ".bash"],
|
|
87
|
+
launch: { kind: "npm-module", entryModule: "bash-language-server/out/cli.js" },
|
|
88
|
+
args: ["start"],
|
|
89
|
+
rootMarkers: [],
|
|
90
|
+
commonSeedCandidates: ["main.sh", "run.sh", "install.sh"],
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export const YAML_DESCRIPTOR: LanguageServerDescriptor = {
|
|
94
|
+
languageId: "yaml",
|
|
95
|
+
extensions: [".yaml", ".yml"],
|
|
96
|
+
launch: { kind: "npm-module", entryModule: "yaml-language-server/bin/yaml-language-server" },
|
|
97
|
+
args: ["--stdio"],
|
|
98
|
+
rootMarkers: [],
|
|
99
|
+
commonSeedCandidates: [],
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/** Every known descriptor, in priority order for ambiguous-extension lookups. */
|
|
103
|
+
export const LANGUAGE_SERVER_DESCRIPTORS: readonly LanguageServerDescriptor[] = [
|
|
104
|
+
TYPESCRIPT_DESCRIPTOR,
|
|
105
|
+
PYTHON_DESCRIPTOR,
|
|
106
|
+
GO_DESCRIPTOR,
|
|
107
|
+
RUST_DESCRIPTOR,
|
|
108
|
+
CPP_DESCRIPTOR,
|
|
109
|
+
BASH_DESCRIPTOR,
|
|
110
|
+
YAML_DESCRIPTOR,
|
|
111
|
+
];
|
|
112
|
+
|
|
113
|
+
/** The first descriptor whose extensions list includes `extension` (leading dot, e.g. ".py"), or undefined if none match. */
|
|
114
|
+
export function descriptorForExtension(extension: string): LanguageServerDescriptor | undefined {
|
|
115
|
+
return LANGUAGE_SERVER_DESCRIPTORS.find((descriptor) => descriptor.extensions.includes(extension));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The descriptor for a file's own extension, e.g. ".py" -> Python. */
|
|
119
|
+
export function descriptorForPath(path: string): LanguageServerDescriptor | undefined {
|
|
120
|
+
const dot = path.lastIndexOf(".");
|
|
121
|
+
return dot === -1 ? undefined : descriptorForExtension(path.slice(dot));
|
|
122
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { CodeIntelligencePort } from "../ports/code-intelligence-port.ts";
|
|
2
|
+
import type { OutgoingCall } from "./call-hierarchy.ts";
|
|
3
|
+
import type { WorkspaceLocation } from "./workspace-symbol.ts";
|
|
4
|
+
|
|
5
|
+
/** Every function/method the symbol at `at` itself calls. */
|
|
6
|
+
export async function outgoingCalls(index: CodeIntelligencePort, at: WorkspaceLocation): Promise<OutgoingCall[]> {
|
|
7
|
+
return index.outgoingCalls(at);
|
|
8
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { CodeIntelligencePort } from "../ports/code-intelligence-port.ts";
|
|
2
|
+
import type { SymbolGraphPort, SymbolNode } from "../ports/symbol-graph-port.ts";
|
|
3
|
+
import type { DocumentSymbolEntry } from "./document-symbol.ts";
|
|
4
|
+
import { deriveSymbolNodeId } from "./symbol-node-id.ts";
|
|
5
|
+
import type { WorkspaceLocation } from "./workspace-symbol.ts";
|
|
6
|
+
|
|
7
|
+
const CALLABLE_KINDS = new Set(["function", "method", "constructor"]);
|
|
8
|
+
|
|
9
|
+
export interface PopulateSymbolGraphResult {
|
|
10
|
+
readonly filesProcessed: number;
|
|
11
|
+
readonly symbolsProcessed: number;
|
|
12
|
+
/** addNode calls made, not necessarily new nodes -- a symbol reached from multiple edges is upserted once per encounter within a run, deduped in-memory. */
|
|
13
|
+
readonly nodesAdded: number;
|
|
14
|
+
readonly edgesAdded: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function toLocation(entry: DocumentSymbolEntry): WorkspaceLocation {
|
|
18
|
+
return { path: entry.selectionRange.path, line: entry.selectionRange.start.line, character: entry.selectionRange.start.character };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface FlattenedEntry {
|
|
22
|
+
readonly entry: DocumentSymbolEntry;
|
|
23
|
+
readonly parentLocation: WorkspaceLocation | undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Depth-first flatten of a documentSymbols hierarchy, keeping each entry's parent location for "contains" edges. */
|
|
27
|
+
function flattenDocumentSymbols(entries: readonly DocumentSymbolEntry[], parentLocation?: WorkspaceLocation): FlattenedEntry[] {
|
|
28
|
+
const flattened: FlattenedEntry[] = [];
|
|
29
|
+
for (const entry of entries) {
|
|
30
|
+
flattened.push({ entry, parentLocation });
|
|
31
|
+
if (entry.children) flattened.push(...flattenDocumentSymbols(entry.children, toLocation(entry)));
|
|
32
|
+
}
|
|
33
|
+
return flattened;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Walks documentSymbols for each file, then outgoingCalls for every callable
|
|
38
|
+
* declaration found, to fill a SymbolGraphPort with real "contains" (free,
|
|
39
|
+
* from the hierarchy already returned) and "calls" (one LSP round trip per
|
|
40
|
+
* callable symbol) edges. maxSymbolsPerFile bounds a single large file's
|
|
41
|
+
* declarations rather than processing an unbounded number from it.
|
|
42
|
+
*/
|
|
43
|
+
export async function populateSymbolGraph(
|
|
44
|
+
index: CodeIntelligencePort,
|
|
45
|
+
graph: SymbolGraphPort,
|
|
46
|
+
files: readonly string[],
|
|
47
|
+
maxSymbolsPerFile: number,
|
|
48
|
+
): Promise<PopulateSymbolGraphResult> {
|
|
49
|
+
let filesProcessed = 0;
|
|
50
|
+
let symbolsProcessed = 0;
|
|
51
|
+
let nodesAdded = 0;
|
|
52
|
+
let edgesAdded = 0;
|
|
53
|
+
const addedNodeIds = new Set<string>();
|
|
54
|
+
|
|
55
|
+
async function ensureNode(node: SymbolNode): Promise<void> {
|
|
56
|
+
if (addedNodeIds.has(node.id)) return;
|
|
57
|
+
addedNodeIds.add(node.id);
|
|
58
|
+
await graph.addNode(node);
|
|
59
|
+
nodesAdded++;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
for (const file of files) {
|
|
63
|
+
const topLevel = await index.documentSymbols(file);
|
|
64
|
+
const flattened = flattenDocumentSymbols(topLevel).slice(0, maxSymbolsPerFile);
|
|
65
|
+
filesProcessed++;
|
|
66
|
+
|
|
67
|
+
for (const { entry, parentLocation } of flattened) {
|
|
68
|
+
symbolsProcessed++;
|
|
69
|
+
const location = toLocation(entry);
|
|
70
|
+
const node: SymbolNode = { id: deriveSymbolNodeId(location), name: entry.name, kind: entry.kind, location };
|
|
71
|
+
await ensureNode(node);
|
|
72
|
+
|
|
73
|
+
if (parentLocation) {
|
|
74
|
+
await graph.addEdge(deriveSymbolNodeId(parentLocation), node.id, "contains");
|
|
75
|
+
edgesAdded++;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (CALLABLE_KINDS.has(entry.kind)) {
|
|
79
|
+
const callees = await index.outgoingCalls(location);
|
|
80
|
+
for (const call of callees) {
|
|
81
|
+
const calleeNode: SymbolNode = { id: deriveSymbolNodeId(call.to.location), name: call.to.name, kind: call.to.kind, location: call.to.location };
|
|
82
|
+
await ensureNode(calleeNode);
|
|
83
|
+
await graph.addEdge(node.id, calleeNode.id, "calls");
|
|
84
|
+
edgesAdded++;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { filesProcessed, symbolsProcessed, nodesAdded, edgesAdded };
|
|
91
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { CodeIntelligencePort } from "../ports/code-intelligence-port.ts";
|
|
2
|
+
import type { CallHierarchyEntry } from "./call-hierarchy.ts";
|
|
3
|
+
import type { WorkspaceLocation } from "./workspace-symbol.ts";
|
|
4
|
+
|
|
5
|
+
/** The call-hierarchy root(s) the symbol at `at` resolves to -- usually zero or one. */
|
|
6
|
+
export async function prepareCallHierarchy(index: CodeIntelligencePort, at: WorkspaceLocation): Promise<CallHierarchyEntry[]> {
|
|
7
|
+
return index.prepareCallHierarchy(at);
|
|
8
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { WorkspaceQueryOutcome } from "./workspace-query-outcome.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Runs one workspace's query against a time budget without ever cancelling it: a timeout means
|
|
5
|
+
* this call reports "loading" and stops waiting, but the real work (e.g. LspSymbolIndex's own
|
|
6
|
+
* cached, shared initialization) keeps running in the background regardless -- a later call
|
|
7
|
+
* against the same workspace finds it already warm. Never throws: a genuine failure becomes an
|
|
8
|
+
* "error" outcome for this one workspace, not a rejection that would abort an entire fan-out.
|
|
9
|
+
*/
|
|
10
|
+
export function raceWorkspaceQuery<T>(
|
|
11
|
+
workspaceId: string,
|
|
12
|
+
run: () => Promise<T>,
|
|
13
|
+
timeoutMs: number,
|
|
14
|
+
loadingMessage: string,
|
|
15
|
+
): Promise<WorkspaceQueryOutcome<T>> {
|
|
16
|
+
return new Promise((resolve) => {
|
|
17
|
+
let settled = false;
|
|
18
|
+
const timer = setTimeout(() => {
|
|
19
|
+
if (settled) return;
|
|
20
|
+
settled = true;
|
|
21
|
+
resolve({ workspaceId, status: "loading", message: loadingMessage });
|
|
22
|
+
}, timeoutMs);
|
|
23
|
+
|
|
24
|
+
run().then(
|
|
25
|
+
(result) => {
|
|
26
|
+
if (settled) return;
|
|
27
|
+
settled = true;
|
|
28
|
+
clearTimeout(timer);
|
|
29
|
+
resolve({ workspaceId, status: "ready", result });
|
|
30
|
+
},
|
|
31
|
+
(error: unknown) => {
|
|
32
|
+
if (settled) return;
|
|
33
|
+
settled = true;
|
|
34
|
+
clearTimeout(timer);
|
|
35
|
+
resolve({ workspaceId, status: "error", message: error instanceof Error ? error.message : String(error) });
|
|
36
|
+
},
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { WorkspacePort } from "../ports/workspace-port.ts";
|
|
2
|
+
import { type ContentHash, contentHashOf } from "./content-hash.ts";
|
|
3
|
+
|
|
4
|
+
/** The result of reading a workspace entry as-is, with no structural interpretation. */
|
|
5
|
+
export interface RawRead {
|
|
6
|
+
readonly path: string;
|
|
7
|
+
readonly content: string;
|
|
8
|
+
readonly hash: ContentHash;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Raised when rawRead is asked for a path the workspace does not have. */
|
|
12
|
+
export class WorkspaceEntryNotFound extends Error {
|
|
13
|
+
constructor(readonly path: string) {
|
|
14
|
+
super(`no workspace entry at "${path}"`);
|
|
15
|
+
this.name = "WorkspaceEntryNotFound";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Read a workspace entry's raw content and current hash. */
|
|
20
|
+
export async function rawRead(workspace: WorkspacePort, path: string): Promise<RawRead> {
|
|
21
|
+
const entry = await workspace.readEntry(path);
|
|
22
|
+
if (!entry.exists) throw new WorkspaceEntryNotFound(path);
|
|
23
|
+
return { path, content: entry.content, hash: contentHashOf(entry.content) };
|
|
24
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { SymbolEdgeKind, SymbolGraphPort, SymbolNode } from "../ports/symbol-graph-port.ts";
|
|
2
|
+
import type { SymbolNodeId } from "./symbol-node-id.ts";
|
|
3
|
+
|
|
4
|
+
/** Every real symbol node reachable from `id`, up to `maxDepth` hops -- ids the graph itself no longer knows about (a stale node) are dropped, not fabricated. */
|
|
5
|
+
export async function reachableSymbolsFrom(
|
|
6
|
+
graph: SymbolGraphPort,
|
|
7
|
+
id: SymbolNodeId,
|
|
8
|
+
options: { maxDepth: number; kind?: SymbolEdgeKind },
|
|
9
|
+
): Promise<SymbolNode[]> {
|
|
10
|
+
const ids = await graph.reachableFrom(id, options);
|
|
11
|
+
const nodes = await Promise.all(ids.map((nodeId) => graph.getNode(nodeId)));
|
|
12
|
+
return nodes.filter((node): node is SymbolNode => node !== undefined);
|
|
13
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Result of ensuring a shallow clone exists for a RepoReference. */
|
|
2
|
+
export interface RepoFetchResult {
|
|
3
|
+
readonly path: string;
|
|
4
|
+
readonly fromCache: boolean;
|
|
5
|
+
/** The ref actually checked out -- may differ from the requested ref if it didn't exist and cloning fell back to the default branch. */
|
|
6
|
+
readonly resolvedRef: string;
|
|
7
|
+
readonly refFallbackOccurred: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Raised when neither the requested ref nor a default-branch fallback could be cloned. */
|
|
11
|
+
export class RepoFetchFailed extends Error {
|
|
12
|
+
constructor(host: string, owner: string, repo: string, ref: string | null, cause: unknown) {
|
|
13
|
+
const target = `${host}/${owner}/${repo}${ref ? `@${ref}` : ""}`;
|
|
14
|
+
const reason = cause instanceof Error ? cause.message : String(cause);
|
|
15
|
+
super(`failed to fetch ${target}: ${reason}`);
|
|
16
|
+
this.name = "RepoFetchFailed";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Identifies one external repo checkout: host/owner/repo plus an optional ref (branch/tag/sha). `ref: null` means "the remote's default branch". */
|
|
2
|
+
export interface RepoReference {
|
|
3
|
+
readonly host: string;
|
|
4
|
+
readonly owner: string;
|
|
5
|
+
readonly repo: string;
|
|
6
|
+
readonly ref: string | null;
|
|
7
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/** Identifies one cached search: which workspace, what query, under what bounds. */
|
|
4
|
+
export interface SearchCacheKey {
|
|
5
|
+
readonly workspaceId: string;
|
|
6
|
+
readonly query: string;
|
|
7
|
+
readonly maxMatches: number;
|
|
8
|
+
readonly maxBytes: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Deterministic string key for a SearchCacheKey -- same fields always yield the same key, across adapters and process restarts. */
|
|
12
|
+
export function deriveSearchCacheKey(key: SearchCacheKey): string {
|
|
13
|
+
return createHash("sha256")
|
|
14
|
+
.update(JSON.stringify({ workspaceId: key.workspaceId, query: key.query, maxMatches: key.maxMatches, maxBytes: key.maxBytes }))
|
|
15
|
+
.digest("hex");
|
|
16
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { SearchCachePort } from "../ports/search-cache-port.ts";
|
|
2
|
+
import type { TextSearchOptions, TextSearchPort } from "../ports/text-search-port.ts";
|
|
3
|
+
import { assertSafeSearchQuery } from "./assert-safe-search-query.ts";
|
|
4
|
+
import type { TextSearchResult } from "./text-search-result.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Check-then-populate cache orchestration, kept as pure domain logic (not duplicated inside
|
|
8
|
+
* every adapter, not left for service.ts to reimplement) -- the same shape as rawRead/exactEdit
|
|
9
|
+
* being pure functions over a port. `cache` is optional: a caller with no cache configured still
|
|
10
|
+
* gets a correct (just uncached) search.
|
|
11
|
+
*/
|
|
12
|
+
export async function searchText(
|
|
13
|
+
textSearch: TextSearchPort,
|
|
14
|
+
cache: SearchCachePort | undefined,
|
|
15
|
+
rootPath: string,
|
|
16
|
+
workspaceId: string,
|
|
17
|
+
query: string,
|
|
18
|
+
options: TextSearchOptions,
|
|
19
|
+
): Promise<TextSearchResult> {
|
|
20
|
+
assertSafeSearchQuery(query);
|
|
21
|
+
const key = { workspaceId, query, maxMatches: options.maxMatches, maxBytes: options.maxBytes };
|
|
22
|
+
if (cache) {
|
|
23
|
+
const cached = await cache.get(key);
|
|
24
|
+
if (cached) return cached;
|
|
25
|
+
}
|
|
26
|
+
const result = await textSearch.search(rootPath, query, options);
|
|
27
|
+
if (cache) await cache.set(key, result);
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { SymbolEdgeKind, SymbolGraphPort, SymbolNode } from "../ports/symbol-graph-port.ts";
|
|
2
|
+
import type { SymbolNodeId } from "./symbol-node-id.ts";
|
|
3
|
+
|
|
4
|
+
/** Every real symbol node `id` directly points to (one hop) -- ids the graph no longer knows about are dropped, not fabricated. */
|
|
5
|
+
export async function symbolEdgesFrom(graph: SymbolGraphPort, id: SymbolNodeId, kind?: SymbolEdgeKind): Promise<SymbolNode[]> {
|
|
6
|
+
const ids = await graph.edgesFrom(id, kind);
|
|
7
|
+
const nodes = await Promise.all(ids.map((nodeId) => graph.getNode(nodeId)));
|
|
8
|
+
return nodes.filter((node): node is SymbolNode => node !== undefined);
|
|
9
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { SymbolEdgeKind, SymbolGraphPort, SymbolNode } from "../ports/symbol-graph-port.ts";
|
|
2
|
+
import type { SymbolNodeId } from "./symbol-node-id.ts";
|
|
3
|
+
|
|
4
|
+
/** Every real symbol node that directly points to `id` (one hop) -- ids the graph no longer knows about are dropped, not fabricated. */
|
|
5
|
+
export async function symbolEdgesTo(graph: SymbolGraphPort, id: SymbolNodeId, kind?: SymbolEdgeKind): Promise<SymbolNode[]> {
|
|
6
|
+
const ids = await graph.edgesTo(id, kind);
|
|
7
|
+
const nodes = await Promise.all(ids.map((nodeId) => graph.getNode(nodeId)));
|
|
8
|
+
return nodes.filter((node): node is SymbolNode => node !== undefined);
|
|
9
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { PopulateSymbolGraphResult } from "./populate-symbol-graph.ts";
|
|
2
|
+
|
|
3
|
+
export interface SymbolGraphGeneration {
|
|
4
|
+
readonly sourceFingerprint: string;
|
|
5
|
+
readonly maxFiles: number;
|
|
6
|
+
readonly maxSymbolsPerFile: number;
|
|
7
|
+
readonly completedAt: number;
|
|
8
|
+
readonly result: PopulateSymbolGraphResult;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type WorkspaceCacheStatus =
|
|
12
|
+
| { readonly status: "not-cached"; readonly reason: "no-completed-generation" | "bounds-changed" | "source-changed" }
|
|
13
|
+
| { readonly status: "caching"; readonly jobId: string }
|
|
14
|
+
| { readonly status: "cached"; readonly generation: SymbolGraphGeneration };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { WorkspaceLocation } from "./workspace-symbol.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A symbol graph node's identity: deterministic from its own declaration
|
|
5
|
+
* location, so the same declaration always maps to the same node across
|
|
6
|
+
* separate indexing passes -- never a random/incrementing id that would
|
|
7
|
+
* make two passes over the same unchanged code disagree with each other.
|
|
8
|
+
*/
|
|
9
|
+
export type SymbolNodeId = string;
|
|
10
|
+
|
|
11
|
+
export function deriveSymbolNodeId(location: WorkspaceLocation): SymbolNodeId {
|
|
12
|
+
return `${location.path}:${location.line}:${location.character}`;
|
|
13
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** One matched line from a text search, with the matched span within it. */
|
|
2
|
+
export interface TextSearchMatch {
|
|
3
|
+
/** Workspace-relative path. */
|
|
4
|
+
readonly path: string;
|
|
5
|
+
readonly lineNumber: number;
|
|
6
|
+
readonly line: string;
|
|
7
|
+
readonly matchStart: number;
|
|
8
|
+
readonly matchEnd: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** `truncated` is honest, not silent: true whenever maxMatches or maxBytes cut the search short. */
|
|
12
|
+
export interface TextSearchResult {
|
|
13
|
+
readonly matches: readonly TextSearchMatch[];
|
|
14
|
+
readonly truncated: boolean;
|
|
15
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "ready" -- the query completed and `result` is the real answer.
|
|
3
|
+
* "loading" -- the underlying work (e.g. a cold-starting language server) hadn't finished within
|
|
4
|
+
* the caller's own budget when this was reported. This is not "not found" and not a failure: the
|
|
5
|
+
* work keeps running in the background (LspSymbolIndex's own initialization is cached and
|
|
6
|
+
* shared, never duplicated by a later call), so the caller can retry shortly and get "ready".
|
|
7
|
+
* A caller (human or agent) must be able to tell "there's nothing here" apart from "there might
|
|
8
|
+
* be something here, it just isn't ready yet" -- collapsing both into an empty result is exactly
|
|
9
|
+
* the silent-failure shape this project has repeatedly found and fixed elsewhere (pyright's
|
|
10
|
+
* workspaceFolders bug, the eslint.config.ts seed-file bug) for a single workspace; a fan-out
|
|
11
|
+
* across many workspaces multiplies the chance of hitting a still-cold one.
|
|
12
|
+
* "error" -- the query for this one workspace genuinely failed (e.g. an unsupported language).
|
|
13
|
+
* Reported per-workspace rather than aborting the whole fan-out, so one bad workspace can't sink
|
|
14
|
+
* every other workspace's real results.
|
|
15
|
+
*
|
|
16
|
+
* A real discriminated union, not one interface with optional fields -- so `status === "ready"`
|
|
17
|
+
* narrows `result` to defined without a caller needing its own assertion.
|
|
18
|
+
*/
|
|
19
|
+
export type WorkspaceQueryOutcome<T> =
|
|
20
|
+
| { readonly workspaceId: string; readonly status: "ready"; readonly result: T }
|
|
21
|
+
| { readonly workspaceId: string; readonly status: "loading"; readonly message: string }
|
|
22
|
+
| { readonly workspaceId: string; readonly status: "error"; readonly message: string };
|
|
23
|
+
|
|
24
|
+
export type WorkspaceQueryStatus = WorkspaceQueryOutcome<unknown>["status"];
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** A location within a workspace file: 1-indexed line and character, matching how humans and CLIs present positions. */
|
|
2
|
+
export interface WorkspaceLocation {
|
|
3
|
+
readonly path: string;
|
|
4
|
+
readonly line: number;
|
|
5
|
+
readonly character: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** One symbol found by a workspace-wide symbol search. */
|
|
9
|
+
export interface WorkspaceSymbol {
|
|
10
|
+
readonly name: string;
|
|
11
|
+
readonly kind: string;
|
|
12
|
+
readonly location: WorkspaceLocation;
|
|
13
|
+
readonly containerName?: string;
|
|
14
|
+
}
|