forge-cpp-mcp 0.2.1__py3-none-any.whl
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.
- forge_cpp_mcp-0.2.1.dist-info/METADATA +436 -0
- forge_cpp_mcp-0.2.1.dist-info/RECORD +83 -0
- forge_cpp_mcp-0.2.1.dist-info/WHEEL +4 -0
- forge_cpp_mcp-0.2.1.dist-info/entry_points.txt +2 -0
- forge_cpp_mcp-0.2.1.dist-info/licenses/LICENSE +21 -0
- forgemcp/__init__.py +5 -0
- forgemcp/assets/clangd-result.html +119 -0
- forgemcp/assets/cmake-build.html +111 -0
- forgemcp/assets/cmake-configure.html +111 -0
- forgemcp/assets/cmake-profiles.html +111 -0
- forgemcp/assets/cmake-test.html +111 -0
- forgemcp/assets/process-details.html +113 -0
- forgemcp/assets/process-overview.html +111 -0
- forgemcp/assets/toolsets.html +111 -0
- forgemcp/assets/workspace-file.html +119 -0
- forgemcp/assets/workspace-result.html +119 -0
- forgemcp/assets/workspace-search.html +119 -0
- forgemcp/assets/workspace-tree.html +119 -0
- forgemcp/assets.py +62 -0
- forgemcp/clangd/__init__.py +1 -0
- forgemcp/clangd/errors.py +29 -0
- forgemcp/clangd/models.py +94 -0
- forgemcp/clangd/service.py +1279 -0
- forgemcp/clangd/session.py +520 -0
- forgemcp/cmake/__init__.py +1 -0
- forgemcp/cmake/errors.py +5 -0
- forgemcp/cmake/profiles.py +95 -0
- forgemcp/cmake/service.py +895 -0
- forgemcp/completion.py +49 -0
- forgemcp/icons/clangd.LICENSE +219 -0
- forgemcp/icons/clangd.svg +25 -0
- forgemcp/icons/cmake.svg +9 -0
- forgemcp/icons/process.svg +4 -0
- forgemcp/icons/toolchain.svg +1 -0
- forgemcp/icons/workspace-edit.svg +1 -0
- forgemcp/icons/workspace-file.svg +1 -0
- forgemcp/icons/workspace-search.svg +1 -0
- forgemcp/icons/workspace.svg +4 -0
- forgemcp/markdown.py +308 -0
- forgemcp/process/__init__.py +21 -0
- forgemcp/process/errors.py +21 -0
- forgemcp/process/models.py +171 -0
- forgemcp/process/service.py +830 -0
- forgemcp/process/transcript.py +116 -0
- forgemcp/progress.py +40 -0
- forgemcp/server.py +196 -0
- forgemcp/toolchain/__init__.py +1 -0
- forgemcp/toolchain/discovery.py +25 -0
- forgemcp/toolchain/errors.py +41 -0
- forgemcp/toolchain/loader.py +48 -0
- forgemcp/toolchain/providers/__init__.py +1 -0
- forgemcp/toolchain/providers/system.py +52 -0
- forgemcp/toolchain/providers/user.py +93 -0
- forgemcp/toolchain/providers/visual_studio.py +202 -0
- forgemcp/toolchain/service.py +226 -0
- forgemcp/toolchain/spec.py +73 -0
- forgemcp/toolchain/tools/__init__.py +1 -0
- forgemcp/toolchain/tools/clang.py +61 -0
- forgemcp/toolchain/tools/clang_cl.py +61 -0
- forgemcp/toolchain/tools/clangd.py +280 -0
- forgemcp/toolchain/tools/clangxx.py +61 -0
- forgemcp/toolchain/tools/cmake.py +280 -0
- forgemcp/toolchain/tools/cppvsdbg.py +27 -0
- forgemcp/toolchain/tools/ctest.py +202 -0
- forgemcp/toolchain/tools/gcc.py +61 -0
- forgemcp/toolchain/tools/gdb.py +61 -0
- forgemcp/toolchain/tools/git.py +61 -0
- forgemcp/toolchain/tools/gxx.py +61 -0
- forgemcp/toolchain/tools/link.py +27 -0
- forgemcp/toolchain/tools/lld.py +61 -0
- forgemcp/toolchain/tools/lld_link.py +61 -0
- forgemcp/toolchain/tools/lldb_dap.py +27 -0
- forgemcp/toolchain/tools/make.py +61 -0
- forgemcp/toolchain/tools/msbuild.py +61 -0
- forgemcp/toolchain/tools/msvc.py +27 -0
- forgemcp/toolchain/tools/ninja.py +61 -0
- forgemcp/workspace/__init__.py +5 -0
- forgemcp/workspace/diff.py +181 -0
- forgemcp/workspace/errors.py +5 -0
- forgemcp/workspace/metadata.py +87 -0
- forgemcp/workspace/path.py +71 -0
- forgemcp/workspace/providers.py +25 -0
- forgemcp/workspace/service.py +1766 -0
|
@@ -0,0 +1,1279 @@
|
|
|
1
|
+
"""Read-only multi-configuration analysis and immutable workspace enrichment."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
from collections.abc import AsyncGenerator, Awaitable, Callable, Sequence
|
|
11
|
+
from contextlib import AsyncExitStack, asynccontextmanager, suppress
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Literal, cast
|
|
15
|
+
|
|
16
|
+
from mcp.server import MCPServer
|
|
17
|
+
from mcp.server.apps import Apps
|
|
18
|
+
from mcp.server.mcpserver import Context
|
|
19
|
+
from mcp.server.mcpserver.exceptions import ToolError
|
|
20
|
+
from mcp.types import ToolAnnotations
|
|
21
|
+
from pydantic import BaseModel, JsonValue, ValidationError
|
|
22
|
+
|
|
23
|
+
from forgemcp.assets import IconFile, Widget
|
|
24
|
+
from forgemcp.cmake.service import CMakeService, CompilationContext
|
|
25
|
+
from forgemcp.completion import Complete
|
|
26
|
+
from forgemcp.process.errors import ProcessError
|
|
27
|
+
from forgemcp.progress import Progress, progress
|
|
28
|
+
from forgemcp.toolchain.errors import ToolchainError
|
|
29
|
+
from forgemcp.toolchain.service import ToolchainService
|
|
30
|
+
from forgemcp.toolchain.tools import clangd
|
|
31
|
+
from forgemcp.workspace.errors import WorkspaceError
|
|
32
|
+
from forgemcp.workspace.path import WorkspacePath
|
|
33
|
+
from forgemcp.workspace.service import (
|
|
34
|
+
FileContent,
|
|
35
|
+
FileEditResult,
|
|
36
|
+
FileWriteResult,
|
|
37
|
+
PathOperationResult,
|
|
38
|
+
ReadConfirmation,
|
|
39
|
+
ResultProvider,
|
|
40
|
+
WorkspaceService,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
from .errors import (
|
|
44
|
+
ClangdError,
|
|
45
|
+
ClangdProtocolError,
|
|
46
|
+
ClangdSessionError,
|
|
47
|
+
ClangdTimeoutError,
|
|
48
|
+
)
|
|
49
|
+
from .models import (
|
|
50
|
+
Diagnostic,
|
|
51
|
+
DocumentSymbol,
|
|
52
|
+
HighlightSpan,
|
|
53
|
+
Hover,
|
|
54
|
+
HoverText,
|
|
55
|
+
Location,
|
|
56
|
+
NavigationLocation,
|
|
57
|
+
Position,
|
|
58
|
+
SourceExcerpt,
|
|
59
|
+
SourceRange,
|
|
60
|
+
WorkspaceSymbol,
|
|
61
|
+
)
|
|
62
|
+
from .session import (
|
|
63
|
+
ClangdSession,
|
|
64
|
+
integer,
|
|
65
|
+
list_value,
|
|
66
|
+
lsp_position,
|
|
67
|
+
object_value,
|
|
68
|
+
source_range,
|
|
69
|
+
string,
|
|
70
|
+
validate_timeout,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class DiagnosticsResult(BaseModel):
|
|
75
|
+
configurations: list[str]
|
|
76
|
+
path: WorkspacePath
|
|
77
|
+
diagnostics: list[Diagnostic]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class HoverResult(BaseModel):
|
|
81
|
+
configurations: list[str]
|
|
82
|
+
path: WorkspacePath
|
|
83
|
+
position: Position
|
|
84
|
+
hover: Hover | None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class DefinitionResult(BaseModel):
|
|
88
|
+
configurations: list[str]
|
|
89
|
+
locations: list[NavigationLocation]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class ReferencesResult(BaseModel):
|
|
93
|
+
configurations: list[str]
|
|
94
|
+
locations: list[NavigationLocation]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class DocumentSymbolsResult(BaseModel):
|
|
98
|
+
configurations: list[str]
|
|
99
|
+
path: WorkspacePath
|
|
100
|
+
symbols: list[DocumentSymbol]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class WorkspaceSymbolsResult(BaseModel):
|
|
104
|
+
configurations: list[str]
|
|
105
|
+
symbols: list[WorkspaceSymbol]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class HighlightingResult(BaseModel):
|
|
109
|
+
configurations: list[str]
|
|
110
|
+
path: WorkspacePath
|
|
111
|
+
spans: list[HighlightSpan]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class FileAnalysis(BaseModel):
|
|
115
|
+
path: WorkspacePath
|
|
116
|
+
diagnostics: list[DiagnosticsResult]
|
|
117
|
+
highlighting: list[HighlightingResult]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class ClangdResource(BaseModel):
|
|
121
|
+
version: Literal[1] = 1
|
|
122
|
+
files: list[FileAnalysis]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass
|
|
126
|
+
class RunningSession:
|
|
127
|
+
session: ClangdSession
|
|
128
|
+
lifetime: AsyncExitStack
|
|
129
|
+
fingerprint: str
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass(frozen=True)
|
|
133
|
+
class WorkspaceContext:
|
|
134
|
+
paths: tuple[WorkspacePath, ...]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass(frozen=True)
|
|
138
|
+
class FileChange:
|
|
139
|
+
path: WorkspacePath
|
|
140
|
+
kind: Literal[1, 2, 3]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def group_results[T: BaseModel](results: list[T]) -> list[T]:
|
|
144
|
+
"""Coalesce equal complete answers, retaining configuration provenance."""
|
|
145
|
+
grouped: dict[str, T] = {}
|
|
146
|
+
for result in results:
|
|
147
|
+
key = json.dumps(
|
|
148
|
+
result.model_dump(mode="json", exclude={"configurations"}),
|
|
149
|
+
sort_keys=True,
|
|
150
|
+
ensure_ascii=False,
|
|
151
|
+
)
|
|
152
|
+
if key in grouped:
|
|
153
|
+
getattr(grouped[key], "configurations").extend(getattr(result, "configurations"))
|
|
154
|
+
else:
|
|
155
|
+
grouped[key] = result.model_copy(deep=True)
|
|
156
|
+
return list(grouped.values())
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class ClangdService(ResultProvider[WorkspaceContext]):
|
|
160
|
+
"""Clangd: read-only semantic C/C++ analysis and navigation across CMake contexts.
|
|
161
|
+
|
|
162
|
+
Each context requires a compilation database and clangd in its toolset.
|
|
163
|
+
Omitted or empty configuration selections use all available contexts; equal
|
|
164
|
+
answers are grouped by configuration IDs. Positions use one-based lines and
|
|
165
|
+
zero-based Unicode code-point characters.
|
|
166
|
+
|
|
167
|
+
Results and linked analysis resources are immutable snapshots. Workspace
|
|
168
|
+
mutations synchronize retained sessions; external edits are not watched.
|
|
169
|
+
Diagnostics outside project/storage are excluded; symbol locations may use
|
|
170
|
+
root/... without granting file access.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
WIDGET = Widget("assets/clangd-result.html")
|
|
174
|
+
ICON = IconFile("icons/clangd.svg")
|
|
175
|
+
SOURCE_EXTENSIONS = frozenset(
|
|
176
|
+
(
|
|
177
|
+
".c",
|
|
178
|
+
".h",
|
|
179
|
+
".cc",
|
|
180
|
+
".hh",
|
|
181
|
+
".cpp",
|
|
182
|
+
".hpp",
|
|
183
|
+
".cxx",
|
|
184
|
+
".hxx",
|
|
185
|
+
".c++",
|
|
186
|
+
".h++",
|
|
187
|
+
".ipp",
|
|
188
|
+
".tpp",
|
|
189
|
+
),
|
|
190
|
+
)
|
|
191
|
+
ANALYSIS_TIMEOUT = 30.0
|
|
192
|
+
PROVIDER_TOOLS = (
|
|
193
|
+
"workspace_read_file",
|
|
194
|
+
"workspace_write_file",
|
|
195
|
+
"workspace_edit_file",
|
|
196
|
+
"workspace_move",
|
|
197
|
+
"workspace_delete",
|
|
198
|
+
"workspace_mkdir",
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
def __init__(
|
|
202
|
+
self,
|
|
203
|
+
workspace: WorkspaceService,
|
|
204
|
+
toolchains: ToolchainService,
|
|
205
|
+
cmake: CMakeService,
|
|
206
|
+
*,
|
|
207
|
+
progress_interval: float = 1.0,
|
|
208
|
+
) -> None:
|
|
209
|
+
self.workspace = workspace
|
|
210
|
+
self.toolchains = toolchains
|
|
211
|
+
self.cmake = cmake
|
|
212
|
+
self.progress_interval = progress_interval
|
|
213
|
+
self.sessions: dict[str, RunningSession] = {}
|
|
214
|
+
self.lock = asyncio.Lock()
|
|
215
|
+
self.closed = False
|
|
216
|
+
self.contexts: dict[str, CompilationContext] = {}
|
|
217
|
+
self.configuration_task: asyncio.Task[None] | None = None
|
|
218
|
+
self.subscription_failure: ClangdError | None = None
|
|
219
|
+
|
|
220
|
+
async def initialize(self) -> None:
|
|
221
|
+
"""Subscribe to CMake and start every currently available clangd session."""
|
|
222
|
+
if self.closed:
|
|
223
|
+
raise ClangdSessionError("The clangd service is closed.")
|
|
224
|
+
if self.configuration_task is not None:
|
|
225
|
+
return
|
|
226
|
+
updates = self.cmake.configuration_updates()
|
|
227
|
+
try:
|
|
228
|
+
contexts = await anext(updates)
|
|
229
|
+
async with self.lock:
|
|
230
|
+
await self.update_configurations(contexts)
|
|
231
|
+
self.configuration_task = asyncio.create_task(
|
|
232
|
+
self.watch_configurations(updates),
|
|
233
|
+
)
|
|
234
|
+
except BaseException:
|
|
235
|
+
await updates.aclose()
|
|
236
|
+
raise
|
|
237
|
+
|
|
238
|
+
async def watch_configurations(
|
|
239
|
+
self,
|
|
240
|
+
updates: AsyncGenerator[list[CompilationContext], None],
|
|
241
|
+
) -> None:
|
|
242
|
+
try:
|
|
243
|
+
async for contexts in updates:
|
|
244
|
+
async with self.lock:
|
|
245
|
+
if self.closed:
|
|
246
|
+
return
|
|
247
|
+
await self.update_configurations(contexts)
|
|
248
|
+
except asyncio.CancelledError:
|
|
249
|
+
raise
|
|
250
|
+
except Exception as error:
|
|
251
|
+
self.subscription_failure = ClangdSessionError("CMake configuration subscription failed.")
|
|
252
|
+
self.subscription_failure.__cause__ = error
|
|
253
|
+
logging.getLogger(__name__).warning("Clangd configuration subscription failed")
|
|
254
|
+
finally:
|
|
255
|
+
await updates.aclose()
|
|
256
|
+
|
|
257
|
+
async def update_configurations(self, contexts: Sequence[CompilationContext]) -> None:
|
|
258
|
+
"""Apply a CMake snapshot while holding the analysis lock."""
|
|
259
|
+
available = {}
|
|
260
|
+
for context in contexts:
|
|
261
|
+
try:
|
|
262
|
+
tool = self.toolchains.get_tool(context.toolset_id, "clangd")
|
|
263
|
+
database = self.workspace.resolve_workspace_path(context.compilation_database)
|
|
264
|
+
if tool is not None and database.is_file():
|
|
265
|
+
available[context.id] = context.model_copy(deep=True)
|
|
266
|
+
except (ToolchainError, WorkspaceError, OSError):
|
|
267
|
+
continue
|
|
268
|
+
self.contexts = available
|
|
269
|
+
for identifier in tuple(self.sessions):
|
|
270
|
+
if identifier not in available:
|
|
271
|
+
await self.close_session(identifier)
|
|
272
|
+
for context in available.values():
|
|
273
|
+
try:
|
|
274
|
+
running = self.sessions.get(context.id)
|
|
275
|
+
if running is not None and (
|
|
276
|
+
running.session.configuration != context
|
|
277
|
+
or running.fingerprint != self.fingerprint(context)
|
|
278
|
+
or running.session.failure is not None
|
|
279
|
+
):
|
|
280
|
+
await self.close_session(context.id)
|
|
281
|
+
async with asyncio.timeout(self.ANALYSIS_TIMEOUT):
|
|
282
|
+
await self.session(context, None, self.ANALYSIS_TIMEOUT)
|
|
283
|
+
except (ClangdError, ToolchainError, ProcessError, WorkspaceError):
|
|
284
|
+
logging.getLogger(__name__).warning(
|
|
285
|
+
"Cannot initialize clangd configuration: %s",
|
|
286
|
+
context.id,
|
|
287
|
+
)
|
|
288
|
+
except TimeoutError:
|
|
289
|
+
logging.getLogger(__name__).warning(
|
|
290
|
+
"Clangd initialization timed out: %s",
|
|
291
|
+
context.id,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
def check_ready(self) -> None:
|
|
295
|
+
if self.closed:
|
|
296
|
+
raise ClangdSessionError("The clangd service is closed.")
|
|
297
|
+
if self.configuration_task is None:
|
|
298
|
+
raise ClangdSessionError("The clangd service is not initialized.")
|
|
299
|
+
if self.subscription_failure is not None:
|
|
300
|
+
raise self.subscription_failure
|
|
301
|
+
|
|
302
|
+
async def configurations(self) -> list[CompilationContext]:
|
|
303
|
+
self.check_ready()
|
|
304
|
+
return [context.model_copy(deep=True) for context in self.contexts.values()]
|
|
305
|
+
|
|
306
|
+
async def selection(self, configurations: Sequence[str]) -> list[CompilationContext]:
|
|
307
|
+
contexts = await self.configurations()
|
|
308
|
+
unknown = set(configurations) - self.contexts.keys()
|
|
309
|
+
if unknown:
|
|
310
|
+
raise ClangdError(f"Unavailable clangd configurations: {', '.join(sorted(unknown))}.")
|
|
311
|
+
selected = [
|
|
312
|
+
context
|
|
313
|
+
for context in contexts
|
|
314
|
+
if not configurations or context.id in configurations
|
|
315
|
+
]
|
|
316
|
+
if not selected:
|
|
317
|
+
raise ClangdError("No CMake configuration has both a compilation database and clangd.")
|
|
318
|
+
return selected
|
|
319
|
+
|
|
320
|
+
@asynccontextmanager
|
|
321
|
+
async def operation(self, timeout: float) -> AsyncGenerator[None]:
|
|
322
|
+
"""Include queuing, synchronization, and selected contexts in one deadline."""
|
|
323
|
+
validate_timeout(timeout)
|
|
324
|
+
try:
|
|
325
|
+
async with asyncio.timeout(timeout):
|
|
326
|
+
async with self.lock:
|
|
327
|
+
self.check_ready()
|
|
328
|
+
yield
|
|
329
|
+
except TimeoutError as error:
|
|
330
|
+
raise ClangdTimeoutError("Language analysis exceeded its timeout.") from error
|
|
331
|
+
except WorkspaceError as error:
|
|
332
|
+
raise ClangdError(str(error)) from error
|
|
333
|
+
|
|
334
|
+
async def close_session(self, identifier: str) -> None:
|
|
335
|
+
running = self.sessions.pop(identifier, None)
|
|
336
|
+
if running is not None:
|
|
337
|
+
try:
|
|
338
|
+
await running.session.shutdown()
|
|
339
|
+
finally:
|
|
340
|
+
await running.lifetime.aclose()
|
|
341
|
+
|
|
342
|
+
async def close_sessions(self) -> None:
|
|
343
|
+
results = await asyncio.gather(
|
|
344
|
+
*(self.close_session(identifier) for identifier in tuple(self.sessions)),
|
|
345
|
+
return_exceptions=True,
|
|
346
|
+
)
|
|
347
|
+
for result in results:
|
|
348
|
+
if isinstance(result, BaseException):
|
|
349
|
+
raise result
|
|
350
|
+
|
|
351
|
+
async def close(self) -> None:
|
|
352
|
+
self.closed = True
|
|
353
|
+
if self.configuration_task is not None:
|
|
354
|
+
self.configuration_task.cancel()
|
|
355
|
+
with suppress(asyncio.CancelledError):
|
|
356
|
+
await self.configuration_task
|
|
357
|
+
async with self.lock:
|
|
358
|
+
await self.close_sessions()
|
|
359
|
+
self.contexts.clear()
|
|
360
|
+
|
|
361
|
+
def fingerprint(self, context: CompilationContext) -> str:
|
|
362
|
+
tool = self.toolchains.get_tool(context.toolset_id, "clangd")
|
|
363
|
+
if tool is None:
|
|
364
|
+
raise ClangdError(f"Configuration {context.id} has no clangd.")
|
|
365
|
+
database = self.workspace.resolve_workspace_path(context.compilation_database)
|
|
366
|
+
try:
|
|
367
|
+
digest = hashlib.sha256(database.read_bytes()).hexdigest()
|
|
368
|
+
except OSError as error:
|
|
369
|
+
raise ClangdError("Cannot read the compilation database.") from error
|
|
370
|
+
return f"{digest}|{context.toolset_id}|{tool.path}|{database}"
|
|
371
|
+
|
|
372
|
+
async def session(
|
|
373
|
+
self,
|
|
374
|
+
context: CompilationContext,
|
|
375
|
+
on_progress: Progress | None,
|
|
376
|
+
timeout: float,
|
|
377
|
+
) -> ClangdSession:
|
|
378
|
+
running = self.sessions.get(context.id)
|
|
379
|
+
if running is not None and running.session.failure is not None:
|
|
380
|
+
await self.close_session(context.id)
|
|
381
|
+
running = None
|
|
382
|
+
if running is not None:
|
|
383
|
+
return running.session
|
|
384
|
+
tool = self.toolchains.get_tool(context.toolset_id, "clangd")
|
|
385
|
+
if tool is None:
|
|
386
|
+
raise ClangdError(f"Configuration {context.id} has no clangd.")
|
|
387
|
+
methods = cast(clangd.Methods, tool.methods)
|
|
388
|
+
database = self.workspace.resolve_workspace_path(context.compilation_database)
|
|
389
|
+
fingerprint = self.fingerprint(context)
|
|
390
|
+
lifetime = AsyncExitStack()
|
|
391
|
+
session = None
|
|
392
|
+
try:
|
|
393
|
+
connection = await lifetime.enter_async_context(
|
|
394
|
+
methods["connect"](self.workspace.root, database.parent),
|
|
395
|
+
)
|
|
396
|
+
session = ClangdSession(connection, self.workspace, context)
|
|
397
|
+
await session.initialize(on_progress=on_progress, timeout=timeout)
|
|
398
|
+
except BaseException:
|
|
399
|
+
try:
|
|
400
|
+
if session is not None:
|
|
401
|
+
await session.shutdown()
|
|
402
|
+
finally:
|
|
403
|
+
await lifetime.aclose()
|
|
404
|
+
raise
|
|
405
|
+
self.sessions[context.id] = RunningSession(session, lifetime, fingerprint)
|
|
406
|
+
return session
|
|
407
|
+
|
|
408
|
+
def text(self, path: WorkspacePath) -> str:
|
|
409
|
+
if path.area == "root":
|
|
410
|
+
raise ClangdError(
|
|
411
|
+
"Analysis inputs must be project/storage files; external locations are read-only results.",
|
|
412
|
+
)
|
|
413
|
+
try:
|
|
414
|
+
text = self.workspace.resolve_workspace_path(path).read_bytes().decode("utf-8")
|
|
415
|
+
except (OSError, UnicodeError) as error:
|
|
416
|
+
raise ClangdError(f"{path}: cannot read UTF-8 text.") from error
|
|
417
|
+
if "\0" in text:
|
|
418
|
+
raise ClangdError(f"{path}: expected UTF-8 text, found a binary file.")
|
|
419
|
+
return text
|
|
420
|
+
|
|
421
|
+
@staticmethod
|
|
422
|
+
def require_capability(session: ClangdSession, name: str) -> None:
|
|
423
|
+
value = session.capabilities.get(name)
|
|
424
|
+
if value is None or value is False:
|
|
425
|
+
raise ClangdError(f"clangd does not support {name}.")
|
|
426
|
+
|
|
427
|
+
async def run_context[T](
|
|
428
|
+
self,
|
|
429
|
+
context: CompilationContext,
|
|
430
|
+
operation: Callable[[ClangdSession], Awaitable[T]],
|
|
431
|
+
on_progress: Progress | None,
|
|
432
|
+
timeout: float,
|
|
433
|
+
) -> T:
|
|
434
|
+
try:
|
|
435
|
+
session = await self.session(context, on_progress, timeout)
|
|
436
|
+
return await operation(session)
|
|
437
|
+
except asyncio.CancelledError:
|
|
438
|
+
await self.close_session(context.id)
|
|
439
|
+
raise
|
|
440
|
+
except (ClangdError, ToolchainError, ProcessError, WorkspaceError) as error:
|
|
441
|
+
await self.close_session(context.id)
|
|
442
|
+
raise ClangdError(f"{context.id}: {error}") from error
|
|
443
|
+
except (ValidationError, KeyError, TypeError, ValueError) as error:
|
|
444
|
+
await self.close_session(context.id)
|
|
445
|
+
raise ClangdProtocolError(f"{context.id}: invalid language-server result.") from error
|
|
446
|
+
|
|
447
|
+
async def collect[T: BaseModel](
|
|
448
|
+
self,
|
|
449
|
+
contexts: Sequence[CompilationContext],
|
|
450
|
+
operation: Callable[[ClangdSession], Awaitable[T]],
|
|
451
|
+
*,
|
|
452
|
+
on_progress: Progress | None,
|
|
453
|
+
timeout: float,
|
|
454
|
+
) -> list[T]:
|
|
455
|
+
"""Run contexts independently, wait for cleanup, then fail without partial output."""
|
|
456
|
+
results = await asyncio.gather(
|
|
457
|
+
*(
|
|
458
|
+
self.run_context(context, operation, on_progress, timeout)
|
|
459
|
+
for context in contexts
|
|
460
|
+
),
|
|
461
|
+
return_exceptions=True,
|
|
462
|
+
)
|
|
463
|
+
for result in results:
|
|
464
|
+
if isinstance(result, BaseException):
|
|
465
|
+
raise result
|
|
466
|
+
return group_results(cast(list[T], results))
|
|
467
|
+
|
|
468
|
+
async def file_operation[T: BaseModel](
|
|
469
|
+
self,
|
|
470
|
+
path: WorkspacePath,
|
|
471
|
+
configurations: Sequence[str],
|
|
472
|
+
operation: Callable[[ClangdSession, int], Awaitable[T]],
|
|
473
|
+
*,
|
|
474
|
+
on_progress: Progress | None,
|
|
475
|
+
timeout: float,
|
|
476
|
+
) -> list[T]:
|
|
477
|
+
async with self.operation(timeout):
|
|
478
|
+
contexts = await self.selection(configurations)
|
|
479
|
+
text = self.text(path)
|
|
480
|
+
|
|
481
|
+
async def run(session: ClangdSession) -> T:
|
|
482
|
+
version = await session.synchronize(
|
|
483
|
+
path,
|
|
484
|
+
text,
|
|
485
|
+
on_progress=on_progress,
|
|
486
|
+
)
|
|
487
|
+
return await operation(session, version)
|
|
488
|
+
|
|
489
|
+
result = await self.collect(
|
|
490
|
+
contexts,
|
|
491
|
+
run,
|
|
492
|
+
on_progress=on_progress,
|
|
493
|
+
timeout=timeout,
|
|
494
|
+
)
|
|
495
|
+
return result
|
|
496
|
+
|
|
497
|
+
def position_params(
|
|
498
|
+
self,
|
|
499
|
+
session: ClangdSession,
|
|
500
|
+
path: WorkspacePath,
|
|
501
|
+
position: Position,
|
|
502
|
+
) -> dict[str, JsonValue]:
|
|
503
|
+
uri = session.uri(path)
|
|
504
|
+
lines = session.documents[uri].text.split("\n")
|
|
505
|
+
if position.line > len(lines) or position.character > len(lines[position.line - 1].removesuffix("\r")):
|
|
506
|
+
raise ClangdError("Position is outside the document.")
|
|
507
|
+
return {"textDocument": {"uri": uri}, "position": lsp_position(position)}
|
|
508
|
+
|
|
509
|
+
async def locations(
|
|
510
|
+
self,
|
|
511
|
+
session: ClangdSession,
|
|
512
|
+
method: str,
|
|
513
|
+
params: dict[str, JsonValue],
|
|
514
|
+
on_progress: Progress | None,
|
|
515
|
+
timeout: float,
|
|
516
|
+
) -> list[NavigationLocation]:
|
|
517
|
+
raw = await session.request(method, params, on_progress=on_progress, timeout=timeout)
|
|
518
|
+
if raw is None:
|
|
519
|
+
return []
|
|
520
|
+
values = raw if isinstance(raw, list) else [raw]
|
|
521
|
+
locations = [session.location(value) for value in values]
|
|
522
|
+
return sorted(
|
|
523
|
+
self.navigation_locations(locations),
|
|
524
|
+
key=lambda item: (
|
|
525
|
+
str(item.path),
|
|
526
|
+
item.range.start.line,
|
|
527
|
+
item.range.start.character,
|
|
528
|
+
item.range.end.line,
|
|
529
|
+
item.range.end.character,
|
|
530
|
+
),
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
def navigation_locations(
|
|
534
|
+
self,
|
|
535
|
+
locations: Sequence[Location],
|
|
536
|
+
) -> list[NavigationLocation]:
|
|
537
|
+
"""Capture seven source lines per target; never read external locations."""
|
|
538
|
+
sources: dict[WorkspacePath, list[str] | None] = {}
|
|
539
|
+
result = []
|
|
540
|
+
for location in locations:
|
|
541
|
+
preview = None
|
|
542
|
+
if location.path.area != "root":
|
|
543
|
+
if location.path not in sources:
|
|
544
|
+
try:
|
|
545
|
+
sources[location.path] = self.text(location.path).splitlines(keepends=True)
|
|
546
|
+
except (ClangdError, WorkspaceError):
|
|
547
|
+
sources[location.path] = None
|
|
548
|
+
lines = sources[location.path]
|
|
549
|
+
if lines and location.range.start.line <= len(lines):
|
|
550
|
+
start = max(1, location.range.start.line - 3)
|
|
551
|
+
end = min(len(lines), location.range.start.line + 3)
|
|
552
|
+
preview = SourceExcerpt(
|
|
553
|
+
start_line=start,
|
|
554
|
+
text="".join(lines[start - 1:end]),
|
|
555
|
+
)
|
|
556
|
+
result.append(
|
|
557
|
+
NavigationLocation(
|
|
558
|
+
path=location.path,
|
|
559
|
+
range=location.range,
|
|
560
|
+
preview=preview,
|
|
561
|
+
),
|
|
562
|
+
)
|
|
563
|
+
return result
|
|
564
|
+
|
|
565
|
+
def document_symbol(self, value: JsonValue) -> DocumentSymbol:
|
|
566
|
+
data = object_value(value)
|
|
567
|
+
return DocumentSymbol(
|
|
568
|
+
name=string(data.get("name")),
|
|
569
|
+
kind=integer(data.get("kind")),
|
|
570
|
+
range=source_range(data.get("range")),
|
|
571
|
+
selection_range=source_range(data.get("selectionRange")),
|
|
572
|
+
detail=data.get("detail"),
|
|
573
|
+
tags=data.get("tags", [1] if data.get("deprecated") else []),
|
|
574
|
+
children=[self.document_symbol(item) for item in list_value(data.get("children", []))],
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
async def highlighting(
|
|
578
|
+
self,
|
|
579
|
+
session: ClangdSession,
|
|
580
|
+
path: WorkspacePath,
|
|
581
|
+
*,
|
|
582
|
+
on_progress: Progress | None,
|
|
583
|
+
timeout: float,
|
|
584
|
+
) -> HighlightingResult:
|
|
585
|
+
self.require_capability(session, "semanticTokensProvider")
|
|
586
|
+
provider = object_value(session.capabilities["semanticTokensProvider"])
|
|
587
|
+
if provider.get("full") is None or provider.get("full") is False:
|
|
588
|
+
raise ClangdError("clangd does not support full semantic tokens.")
|
|
589
|
+
legend = object_value(provider.get("legend"))
|
|
590
|
+
kinds = [string(item) for item in list_value(legend.get("tokenTypes"))]
|
|
591
|
+
modifiers = [string(item) for item in list_value(legend.get("tokenModifiers"))]
|
|
592
|
+
raw = await session.request(
|
|
593
|
+
"textDocument/semanticTokens/full",
|
|
594
|
+
{"textDocument": {"uri": session.uri(path)}},
|
|
595
|
+
on_progress=on_progress,
|
|
596
|
+
timeout=timeout,
|
|
597
|
+
)
|
|
598
|
+
data = [] if raw is None else list_value(object_value(raw).get("data"))
|
|
599
|
+
if len(data) % 5:
|
|
600
|
+
raise ClangdProtocolError("Invalid semantic-token array length.")
|
|
601
|
+
line = 1
|
|
602
|
+
character = 0
|
|
603
|
+
spans = []
|
|
604
|
+
lines = session.documents[session.uri(path)].text.split("\n")
|
|
605
|
+
for index in range(0, len(data), 5):
|
|
606
|
+
delta, offset, length, kind, flags = [integer(item) for item in data[index:index + 5]]
|
|
607
|
+
line += delta
|
|
608
|
+
character = offset if delta else character + offset
|
|
609
|
+
if (
|
|
610
|
+
kind >= len(kinds)
|
|
611
|
+
or flags >> len(modifiers)
|
|
612
|
+
or length == 0
|
|
613
|
+
or line > len(lines)
|
|
614
|
+
or character + length > len(lines[line - 1])
|
|
615
|
+
):
|
|
616
|
+
raise ClangdProtocolError("Invalid semantic-token coordinates or legend index.")
|
|
617
|
+
spans.append(
|
|
618
|
+
HighlightSpan(
|
|
619
|
+
range=SourceRange(
|
|
620
|
+
start=Position(line=line, character=character),
|
|
621
|
+
end=Position(line=line, character=character + length),
|
|
622
|
+
),
|
|
623
|
+
kind=kinds[kind],
|
|
624
|
+
modifiers=[name for bit, name in enumerate(modifiers) if flags & (1 << bit)],
|
|
625
|
+
),
|
|
626
|
+
)
|
|
627
|
+
return HighlightingResult(
|
|
628
|
+
configurations=[session.configuration.id],
|
|
629
|
+
path=path,
|
|
630
|
+
spans=spans,
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
async def begin_workspace(
|
|
634
|
+
self,
|
|
635
|
+
path: WorkspacePath,
|
|
636
|
+
*,
|
|
637
|
+
subtree: bool = False,
|
|
638
|
+
) -> WorkspaceContext | None:
|
|
639
|
+
if path.area == "root":
|
|
640
|
+
return None
|
|
641
|
+
await self.lock.acquire()
|
|
642
|
+
try:
|
|
643
|
+
self.check_ready()
|
|
644
|
+
paths = [path]
|
|
645
|
+
native = self.workspace.resolve_workspace_path(path)
|
|
646
|
+
if subtree and native.is_dir():
|
|
647
|
+
self.workspace.check_path_links(native, path.area)
|
|
648
|
+
|
|
649
|
+
def raise_walk_error(error: OSError) -> None:
|
|
650
|
+
raise error
|
|
651
|
+
|
|
652
|
+
for directory, directories, files in os.walk(
|
|
653
|
+
native,
|
|
654
|
+
followlinks=False,
|
|
655
|
+
onerror=raise_walk_error,
|
|
656
|
+
):
|
|
657
|
+
parent = Path(directory)
|
|
658
|
+
directories[:] = [
|
|
659
|
+
name
|
|
660
|
+
for name in directories
|
|
661
|
+
if not (parent / name).is_symlink() and not (parent / name).is_junction()
|
|
662
|
+
]
|
|
663
|
+
paths.extend(
|
|
664
|
+
self.workspace.workspace_path(parent / name)
|
|
665
|
+
for name in files
|
|
666
|
+
if not (parent / name).is_symlink() and (parent / name).is_file()
|
|
667
|
+
)
|
|
668
|
+
return WorkspaceContext(tuple(paths))
|
|
669
|
+
except BaseException:
|
|
670
|
+
try:
|
|
671
|
+
await self.close_sessions()
|
|
672
|
+
finally:
|
|
673
|
+
self.lock.release()
|
|
674
|
+
raise
|
|
675
|
+
|
|
676
|
+
async def before_workspace_read_file(
|
|
677
|
+
self,
|
|
678
|
+
call_id: str,
|
|
679
|
+
path: WorkspacePath,
|
|
680
|
+
confirm: ReadConfirmation,
|
|
681
|
+
start_line: int,
|
|
682
|
+
end_line: int | None,
|
|
683
|
+
) -> WorkspaceContext | None:
|
|
684
|
+
return await self.begin_workspace(path)
|
|
685
|
+
|
|
686
|
+
async def after_workspace_read_file(
|
|
687
|
+
self,
|
|
688
|
+
call_id: str,
|
|
689
|
+
context: WorkspaceContext,
|
|
690
|
+
result: FileContent,
|
|
691
|
+
) -> str | None:
|
|
692
|
+
return await self.finish_workspace(call_id, context)
|
|
693
|
+
|
|
694
|
+
async def before_workspace_write_file(
|
|
695
|
+
self,
|
|
696
|
+
call_id: str,
|
|
697
|
+
path: WorkspacePath,
|
|
698
|
+
text: str,
|
|
699
|
+
) -> WorkspaceContext | None:
|
|
700
|
+
return await self.begin_workspace(path)
|
|
701
|
+
|
|
702
|
+
async def after_workspace_write_file(
|
|
703
|
+
self,
|
|
704
|
+
call_id: str,
|
|
705
|
+
context: WorkspaceContext,
|
|
706
|
+
result: FileWriteResult,
|
|
707
|
+
) -> str | None:
|
|
708
|
+
return await self.finish_workspace(
|
|
709
|
+
call_id,
|
|
710
|
+
context,
|
|
711
|
+
changes=[FileChange(result.path, 1 if result.action == "created" else 2)],
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
async def before_workspace_edit_file(
|
|
715
|
+
self,
|
|
716
|
+
call_id: str,
|
|
717
|
+
path: WorkspacePath,
|
|
718
|
+
old_text: str,
|
|
719
|
+
new_text: str,
|
|
720
|
+
replace_all: bool,
|
|
721
|
+
) -> WorkspaceContext | None:
|
|
722
|
+
return await self.begin_workspace(path)
|
|
723
|
+
|
|
724
|
+
async def after_workspace_edit_file(
|
|
725
|
+
self,
|
|
726
|
+
call_id: str,
|
|
727
|
+
context: WorkspaceContext,
|
|
728
|
+
result: FileEditResult,
|
|
729
|
+
) -> str | None:
|
|
730
|
+
return await self.finish_workspace(
|
|
731
|
+
call_id,
|
|
732
|
+
context,
|
|
733
|
+
changes=[FileChange(result.path, 2)],
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
async def before_workspace_move(
|
|
737
|
+
self,
|
|
738
|
+
call_id: str,
|
|
739
|
+
source: WorkspacePath,
|
|
740
|
+
destination: WorkspacePath,
|
|
741
|
+
) -> WorkspaceContext | None:
|
|
742
|
+
return await self.begin_workspace(source, subtree=True)
|
|
743
|
+
|
|
744
|
+
async def after_workspace_move(
|
|
745
|
+
self,
|
|
746
|
+
call_id: str,
|
|
747
|
+
context: WorkspaceContext,
|
|
748
|
+
result: PathOperationResult,
|
|
749
|
+
) -> str | None:
|
|
750
|
+
try:
|
|
751
|
+
origin = self.workspace.resolve_workspace_path(context.paths[0])
|
|
752
|
+
destination = self.workspace.resolve_workspace_path(result.path)
|
|
753
|
+
changes = []
|
|
754
|
+
for old in context.paths:
|
|
755
|
+
relative = self.workspace.resolve_workspace_path(old).relative_to(origin)
|
|
756
|
+
new = self.workspace.workspace_path(destination / relative)
|
|
757
|
+
changes.extend([FileChange(old, 3), FileChange(new, 1)])
|
|
758
|
+
except BaseException:
|
|
759
|
+
try:
|
|
760
|
+
await self.close_sessions()
|
|
761
|
+
finally:
|
|
762
|
+
self.lock.release()
|
|
763
|
+
raise
|
|
764
|
+
return await self.finish_workspace(
|
|
765
|
+
call_id,
|
|
766
|
+
context,
|
|
767
|
+
changes=changes,
|
|
768
|
+
paths=(),
|
|
769
|
+
)
|
|
770
|
+
|
|
771
|
+
async def before_workspace_delete(
|
|
772
|
+
self,
|
|
773
|
+
call_id: str,
|
|
774
|
+
path: WorkspacePath,
|
|
775
|
+
) -> WorkspaceContext | None:
|
|
776
|
+
return await self.begin_workspace(path)
|
|
777
|
+
|
|
778
|
+
async def after_workspace_delete(
|
|
779
|
+
self,
|
|
780
|
+
call_id: str,
|
|
781
|
+
context: WorkspaceContext,
|
|
782
|
+
result: PathOperationResult,
|
|
783
|
+
) -> str | None:
|
|
784
|
+
return await self.finish_workspace(
|
|
785
|
+
call_id,
|
|
786
|
+
context,
|
|
787
|
+
changes=[FileChange(result.path, 3)],
|
|
788
|
+
paths=(),
|
|
789
|
+
)
|
|
790
|
+
|
|
791
|
+
async def before_workspace_mkdir(
|
|
792
|
+
self,
|
|
793
|
+
call_id: str,
|
|
794
|
+
path: WorkspacePath,
|
|
795
|
+
) -> WorkspaceContext | None:
|
|
796
|
+
return await self.begin_workspace(path)
|
|
797
|
+
|
|
798
|
+
async def after_workspace_mkdir(
|
|
799
|
+
self,
|
|
800
|
+
call_id: str,
|
|
801
|
+
context: WorkspaceContext,
|
|
802
|
+
result: PathOperationResult,
|
|
803
|
+
) -> str | None:
|
|
804
|
+
return await self.finish_workspace(
|
|
805
|
+
call_id,
|
|
806
|
+
context,
|
|
807
|
+
changes=[FileChange(result.path, 1)] if result.action == "created" else (),
|
|
808
|
+
paths=(),
|
|
809
|
+
)
|
|
810
|
+
|
|
811
|
+
async def error(self, call_id: str, context: WorkspaceContext) -> None:
|
|
812
|
+
self.lock.release()
|
|
813
|
+
|
|
814
|
+
async def finish_workspace(
|
|
815
|
+
self,
|
|
816
|
+
call_id: str,
|
|
817
|
+
context: WorkspaceContext,
|
|
818
|
+
*,
|
|
819
|
+
changes: Sequence[FileChange] = (),
|
|
820
|
+
paths: Sequence[WorkspacePath] | None = None,
|
|
821
|
+
) -> str | None:
|
|
822
|
+
"""Complete a provider call and release the lock acquired by before."""
|
|
823
|
+
try:
|
|
824
|
+
async with asyncio.timeout(self.ANALYSIS_TIMEOUT):
|
|
825
|
+
if changes:
|
|
826
|
+
await self.synchronize_changes(changes)
|
|
827
|
+
files = await self.analyze_files(context.paths if paths is None else paths)
|
|
828
|
+
if not files:
|
|
829
|
+
return None
|
|
830
|
+
return self.workspace.save_result_resource(
|
|
831
|
+
call_id,
|
|
832
|
+
"clangd",
|
|
833
|
+
"application/json",
|
|
834
|
+
ClangdResource(files=files).model_dump_json(),
|
|
835
|
+
)
|
|
836
|
+
except TimeoutError as error:
|
|
837
|
+
await self.close_sessions()
|
|
838
|
+
raise ClangdTimeoutError("Workspace analysis exceeded its timeout.") from error
|
|
839
|
+
except BaseException:
|
|
840
|
+
await self.close_sessions()
|
|
841
|
+
raise
|
|
842
|
+
finally:
|
|
843
|
+
self.lock.release()
|
|
844
|
+
|
|
845
|
+
async def synchronize_changes(self, changes: Sequence[FileChange]) -> None:
|
|
846
|
+
databases = {context.compilation_database for context in self.contexts.values()}
|
|
847
|
+
if any(change.path in databases for change in changes):
|
|
848
|
+
await self.cmake.publish_configurations(refresh=True)
|
|
849
|
+
await self.update_configurations(await self.cmake.compilation_contexts())
|
|
850
|
+
notifications: list[JsonValue] = [
|
|
851
|
+
{
|
|
852
|
+
"uri": self.workspace.resolve_workspace_path(change.path).as_uri(),
|
|
853
|
+
"type": change.kind,
|
|
854
|
+
}
|
|
855
|
+
for change in changes
|
|
856
|
+
]
|
|
857
|
+
for identifier, running in tuple(self.sessions.items()):
|
|
858
|
+
try:
|
|
859
|
+
await running.session.notify(
|
|
860
|
+
"workspace/didChangeWatchedFiles",
|
|
861
|
+
{"changes": notifications},
|
|
862
|
+
)
|
|
863
|
+
for document in tuple(running.session.documents.values()):
|
|
864
|
+
# Reopen after disk changes to rebuild dependent preambles
|
|
865
|
+
# and get versioned diagnostics even for unchanged text.
|
|
866
|
+
await running.session.forget(document.path)
|
|
867
|
+
try:
|
|
868
|
+
text = self.text(document.path)
|
|
869
|
+
except (WorkspaceError, ClangdError):
|
|
870
|
+
continue
|
|
871
|
+
else:
|
|
872
|
+
await running.session.synchronize(document.path, text)
|
|
873
|
+
except BaseException:
|
|
874
|
+
await self.close_session(identifier)
|
|
875
|
+
raise
|
|
876
|
+
|
|
877
|
+
async def analyze_files(self, paths: Sequence[WorkspacePath]) -> list[FileAnalysis]:
|
|
878
|
+
files = []
|
|
879
|
+
for path in dict.fromkeys(paths):
|
|
880
|
+
if path.area == "root" or not self.contexts:
|
|
881
|
+
continue
|
|
882
|
+
native = self.workspace.resolve_workspace_path(path)
|
|
883
|
+
if native.suffix.lower() not in self.SOURCE_EXTENSIONS or not native.is_file():
|
|
884
|
+
continue
|
|
885
|
+
text = self.text(path)
|
|
886
|
+
|
|
887
|
+
async def run(session: ClangdSession) -> FileAnalysis:
|
|
888
|
+
version = await session.synchronize(path, text)
|
|
889
|
+
diagnostics = await session.diagnostics(
|
|
890
|
+
path,
|
|
891
|
+
version,
|
|
892
|
+
timeout=self.ANALYSIS_TIMEOUT,
|
|
893
|
+
)
|
|
894
|
+
highlighting = await self.highlighting(
|
|
895
|
+
session,
|
|
896
|
+
path,
|
|
897
|
+
on_progress=None,
|
|
898
|
+
timeout=self.ANALYSIS_TIMEOUT,
|
|
899
|
+
)
|
|
900
|
+
return FileAnalysis(
|
|
901
|
+
path=path,
|
|
902
|
+
diagnostics=[
|
|
903
|
+
DiagnosticsResult(
|
|
904
|
+
configurations=[session.configuration.id],
|
|
905
|
+
path=path,
|
|
906
|
+
diagnostics=diagnostics,
|
|
907
|
+
),
|
|
908
|
+
],
|
|
909
|
+
highlighting=[highlighting],
|
|
910
|
+
)
|
|
911
|
+
|
|
912
|
+
outcomes = await asyncio.gather(
|
|
913
|
+
*(
|
|
914
|
+
self.run_context(context, run, None, self.ANALYSIS_TIMEOUT)
|
|
915
|
+
for context in self.contexts.values()
|
|
916
|
+
),
|
|
917
|
+
return_exceptions=True,
|
|
918
|
+
)
|
|
919
|
+
for outcome in outcomes:
|
|
920
|
+
if isinstance(outcome, BaseException):
|
|
921
|
+
raise outcome
|
|
922
|
+
answers = cast(list[FileAnalysis], outcomes)
|
|
923
|
+
files.append(
|
|
924
|
+
FileAnalysis(
|
|
925
|
+
path=path,
|
|
926
|
+
diagnostics=group_results(
|
|
927
|
+
[item for answer in answers for item in answer.diagnostics],
|
|
928
|
+
),
|
|
929
|
+
highlighting=group_results(
|
|
930
|
+
[item for answer in answers for item in answer.highlighting],
|
|
931
|
+
),
|
|
932
|
+
),
|
|
933
|
+
)
|
|
934
|
+
return files
|
|
935
|
+
|
|
936
|
+
def register(self, mcp: MCPServer, apps: Apps, complete: Complete) -> None:
|
|
937
|
+
"""Expose read-only analysis through one result-only MCP App."""
|
|
938
|
+
read_only = ToolAnnotations(read_only_hint=True, destructive_hint=False)
|
|
939
|
+
|
|
940
|
+
def analysis_progress(ctx: Context) -> Progress:
|
|
941
|
+
report = progress(ctx, interval=self.progress_interval)
|
|
942
|
+
updates = -1
|
|
943
|
+
|
|
944
|
+
async def status(message: str) -> None:
|
|
945
|
+
nonlocal updates
|
|
946
|
+
updates += 1
|
|
947
|
+
await report(updates, message=message)
|
|
948
|
+
|
|
949
|
+
return status
|
|
950
|
+
|
|
951
|
+
@apps.tool(
|
|
952
|
+
resource_uri=self.WIDGET.uri,
|
|
953
|
+
icons=[self.ICON.icon],
|
|
954
|
+
annotations=read_only,
|
|
955
|
+
)
|
|
956
|
+
async def clangd_configurations(ctx: Context) -> list[CompilationContext]:
|
|
957
|
+
"""List CMake contexts with an existing compilation database and clangd."""
|
|
958
|
+
report = progress(ctx, interval=self.progress_interval)
|
|
959
|
+
await report(0, message="Finding clangd configurations")
|
|
960
|
+
try:
|
|
961
|
+
result = await self.configurations()
|
|
962
|
+
except ClangdError as error:
|
|
963
|
+
raise ToolError(str(error)) from error
|
|
964
|
+
await report(1, total=1, message="Completed configuration discovery")
|
|
965
|
+
return result
|
|
966
|
+
|
|
967
|
+
@apps.tool(
|
|
968
|
+
resource_uri=self.WIDGET.uri,
|
|
969
|
+
icons=[self.ICON.icon],
|
|
970
|
+
annotations=read_only,
|
|
971
|
+
)
|
|
972
|
+
async def clangd_diagnostics(
|
|
973
|
+
path: WorkspacePath,
|
|
974
|
+
ctx: Context,
|
|
975
|
+
configurations: list[str] = [],
|
|
976
|
+
timeout: float = 30.0,
|
|
977
|
+
) -> list[DiagnosticsResult]:
|
|
978
|
+
"""Get diagnostics for a managed file in selected configurations. Empty configurations selects all."""
|
|
979
|
+
status = analysis_progress(ctx)
|
|
980
|
+
await status("Starting clangd analysis")
|
|
981
|
+
try:
|
|
982
|
+
async def run(session: ClangdSession, version: int) -> DiagnosticsResult:
|
|
983
|
+
return DiagnosticsResult(
|
|
984
|
+
configurations=[session.configuration.id],
|
|
985
|
+
path=path,
|
|
986
|
+
diagnostics=await session.diagnostics(
|
|
987
|
+
path,
|
|
988
|
+
version,
|
|
989
|
+
on_progress=status,
|
|
990
|
+
timeout=timeout,
|
|
991
|
+
),
|
|
992
|
+
)
|
|
993
|
+
|
|
994
|
+
result = await self.file_operation(
|
|
995
|
+
path,
|
|
996
|
+
configurations,
|
|
997
|
+
run,
|
|
998
|
+
on_progress=status,
|
|
999
|
+
timeout=timeout,
|
|
1000
|
+
)
|
|
1001
|
+
except (ClangdError, WorkspaceError, ToolchainError, ProcessError) as error:
|
|
1002
|
+
raise ToolError(str(error)) from error
|
|
1003
|
+
await status("Completed clangd analysis")
|
|
1004
|
+
return result
|
|
1005
|
+
|
|
1006
|
+
@apps.tool(
|
|
1007
|
+
resource_uri=self.WIDGET.uri,
|
|
1008
|
+
icons=[self.ICON.icon],
|
|
1009
|
+
annotations=read_only,
|
|
1010
|
+
)
|
|
1011
|
+
async def clangd_hover(
|
|
1012
|
+
path: WorkspacePath,
|
|
1013
|
+
ctx: Context,
|
|
1014
|
+
position: Position,
|
|
1015
|
+
configurations: list[str] = [],
|
|
1016
|
+
timeout: float = 30.0,
|
|
1017
|
+
) -> list[HoverResult]:
|
|
1018
|
+
"""Describe the symbol at a one-based line and zero-based code-point position. Empty configurations selects all."""
|
|
1019
|
+
status = analysis_progress(ctx)
|
|
1020
|
+
await status("Starting clangd analysis")
|
|
1021
|
+
try:
|
|
1022
|
+
async def run(session: ClangdSession, version: int) -> HoverResult:
|
|
1023
|
+
self.require_capability(session, "hoverProvider")
|
|
1024
|
+
raw = await session.request(
|
|
1025
|
+
"textDocument/hover",
|
|
1026
|
+
self.position_params(session, path, position),
|
|
1027
|
+
on_progress=status,
|
|
1028
|
+
timeout=timeout,
|
|
1029
|
+
)
|
|
1030
|
+
hover = None
|
|
1031
|
+
if raw is not None:
|
|
1032
|
+
data = object_value(raw)
|
|
1033
|
+
values = data.get("contents")
|
|
1034
|
+
values = values if isinstance(values, list) else [values]
|
|
1035
|
+
contents = []
|
|
1036
|
+
for value in values:
|
|
1037
|
+
if isinstance(value, str):
|
|
1038
|
+
contents.append(HoverText(kind="markdown", text=value))
|
|
1039
|
+
else:
|
|
1040
|
+
item = object_value(value)
|
|
1041
|
+
contents.append(
|
|
1042
|
+
HoverText(
|
|
1043
|
+
kind="code" if "language" in item else item.get("kind"),
|
|
1044
|
+
text=string(item.get("value")),
|
|
1045
|
+
language=item.get("language"),
|
|
1046
|
+
),
|
|
1047
|
+
)
|
|
1048
|
+
hover = Hover(
|
|
1049
|
+
contents=contents,
|
|
1050
|
+
range=source_range(data["range"]) if "range" in data else None,
|
|
1051
|
+
)
|
|
1052
|
+
return HoverResult(
|
|
1053
|
+
configurations=[session.configuration.id],
|
|
1054
|
+
path=path,
|
|
1055
|
+
position=position,
|
|
1056
|
+
hover=hover,
|
|
1057
|
+
)
|
|
1058
|
+
|
|
1059
|
+
result = await self.file_operation(
|
|
1060
|
+
path,
|
|
1061
|
+
configurations,
|
|
1062
|
+
run,
|
|
1063
|
+
on_progress=status,
|
|
1064
|
+
timeout=timeout,
|
|
1065
|
+
)
|
|
1066
|
+
except (ClangdError, WorkspaceError, ToolchainError, ProcessError) as error:
|
|
1067
|
+
raise ToolError(str(error)) from error
|
|
1068
|
+
await status("Completed clangd analysis")
|
|
1069
|
+
return result
|
|
1070
|
+
|
|
1071
|
+
@apps.tool(
|
|
1072
|
+
resource_uri=self.WIDGET.uri,
|
|
1073
|
+
icons=[self.ICON.icon],
|
|
1074
|
+
annotations=read_only,
|
|
1075
|
+
)
|
|
1076
|
+
async def clangd_definition(
|
|
1077
|
+
path: WorkspacePath,
|
|
1078
|
+
ctx: Context,
|
|
1079
|
+
position: Position,
|
|
1080
|
+
configurations: list[str] = [],
|
|
1081
|
+
timeout: float = 30.0,
|
|
1082
|
+
) -> list[DefinitionResult]:
|
|
1083
|
+
"""Find definitions of the symbol at the supplied position.
|
|
1084
|
+
|
|
1085
|
+
External locations use root/. Empty configurations selects all.
|
|
1086
|
+
"""
|
|
1087
|
+
status = analysis_progress(ctx)
|
|
1088
|
+
await status("Starting clangd analysis")
|
|
1089
|
+
try:
|
|
1090
|
+
async def run(session: ClangdSession, version: int) -> DefinitionResult:
|
|
1091
|
+
self.require_capability(session, "definitionProvider")
|
|
1092
|
+
return DefinitionResult(
|
|
1093
|
+
configurations=[session.configuration.id],
|
|
1094
|
+
locations=await self.locations(
|
|
1095
|
+
session,
|
|
1096
|
+
"textDocument/definition",
|
|
1097
|
+
self.position_params(session, path, position),
|
|
1098
|
+
status,
|
|
1099
|
+
timeout,
|
|
1100
|
+
),
|
|
1101
|
+
)
|
|
1102
|
+
|
|
1103
|
+
result = await self.file_operation(
|
|
1104
|
+
path,
|
|
1105
|
+
configurations,
|
|
1106
|
+
run,
|
|
1107
|
+
on_progress=status,
|
|
1108
|
+
timeout=timeout,
|
|
1109
|
+
)
|
|
1110
|
+
except (ClangdError, WorkspaceError, ToolchainError, ProcessError) as error:
|
|
1111
|
+
raise ToolError(str(error)) from error
|
|
1112
|
+
await status("Completed clangd analysis")
|
|
1113
|
+
return result
|
|
1114
|
+
|
|
1115
|
+
@apps.tool(
|
|
1116
|
+
resource_uri=self.WIDGET.uri,
|
|
1117
|
+
icons=[self.ICON.icon],
|
|
1118
|
+
annotations=read_only,
|
|
1119
|
+
)
|
|
1120
|
+
async def clangd_references(
|
|
1121
|
+
path: WorkspacePath,
|
|
1122
|
+
ctx: Context,
|
|
1123
|
+
position: Position,
|
|
1124
|
+
include_declaration: bool = True,
|
|
1125
|
+
configurations: list[str] = [],
|
|
1126
|
+
timeout: float = 30.0,
|
|
1127
|
+
) -> list[ReferencesResult]:
|
|
1128
|
+
"""Find references to the symbol at the supplied position. Empty configurations selects all."""
|
|
1129
|
+
status = analysis_progress(ctx)
|
|
1130
|
+
await status("Starting clangd analysis")
|
|
1131
|
+
try:
|
|
1132
|
+
async def run(session: ClangdSession, version: int) -> ReferencesResult:
|
|
1133
|
+
self.require_capability(session, "referencesProvider")
|
|
1134
|
+
params = self.position_params(session, path, position)
|
|
1135
|
+
params["context"] = {"includeDeclaration": include_declaration}
|
|
1136
|
+
return ReferencesResult(
|
|
1137
|
+
configurations=[session.configuration.id],
|
|
1138
|
+
locations=await self.locations(
|
|
1139
|
+
session,
|
|
1140
|
+
"textDocument/references",
|
|
1141
|
+
params,
|
|
1142
|
+
status,
|
|
1143
|
+
timeout,
|
|
1144
|
+
),
|
|
1145
|
+
)
|
|
1146
|
+
|
|
1147
|
+
result = await self.file_operation(
|
|
1148
|
+
path,
|
|
1149
|
+
configurations,
|
|
1150
|
+
run,
|
|
1151
|
+
on_progress=status,
|
|
1152
|
+
timeout=timeout,
|
|
1153
|
+
)
|
|
1154
|
+
except (ClangdError, WorkspaceError, ToolchainError, ProcessError) as error:
|
|
1155
|
+
raise ToolError(str(error)) from error
|
|
1156
|
+
await status("Completed clangd analysis")
|
|
1157
|
+
return result
|
|
1158
|
+
|
|
1159
|
+
@apps.tool(
|
|
1160
|
+
resource_uri=self.WIDGET.uri,
|
|
1161
|
+
icons=[self.ICON.icon],
|
|
1162
|
+
annotations=read_only,
|
|
1163
|
+
)
|
|
1164
|
+
async def clangd_document_symbols(
|
|
1165
|
+
path: WorkspacePath,
|
|
1166
|
+
ctx: Context,
|
|
1167
|
+
configurations: list[str] = [],
|
|
1168
|
+
timeout: float = 30.0,
|
|
1169
|
+
) -> list[DocumentSymbolsResult]:
|
|
1170
|
+
"""List the symbols declared in a managed file. Empty configurations selects all."""
|
|
1171
|
+
status = analysis_progress(ctx)
|
|
1172
|
+
await status("Starting clangd analysis")
|
|
1173
|
+
try:
|
|
1174
|
+
async def run(session: ClangdSession, version: int) -> DocumentSymbolsResult:
|
|
1175
|
+
self.require_capability(session, "documentSymbolProvider")
|
|
1176
|
+
raw = await session.request(
|
|
1177
|
+
"textDocument/documentSymbol",
|
|
1178
|
+
{"textDocument": {"uri": session.uri(path)}},
|
|
1179
|
+
on_progress=status,
|
|
1180
|
+
timeout=timeout,
|
|
1181
|
+
)
|
|
1182
|
+
symbols = []
|
|
1183
|
+
for value in list_value([] if raw is None else raw):
|
|
1184
|
+
data = object_value(value)
|
|
1185
|
+
if "location" in data:
|
|
1186
|
+
location = session.location(data["location"])
|
|
1187
|
+
if location.path != path:
|
|
1188
|
+
raise ClangdProtocolError("Document symbol points to another file.")
|
|
1189
|
+
symbols.append(
|
|
1190
|
+
DocumentSymbol(
|
|
1191
|
+
name=string(data.get("name")),
|
|
1192
|
+
kind=integer(data.get("kind")),
|
|
1193
|
+
range=location.range,
|
|
1194
|
+
selection_range=location.range,
|
|
1195
|
+
tags=data.get("tags", []),
|
|
1196
|
+
),
|
|
1197
|
+
)
|
|
1198
|
+
else:
|
|
1199
|
+
symbols.append(self.document_symbol(value))
|
|
1200
|
+
return DocumentSymbolsResult(
|
|
1201
|
+
configurations=[session.configuration.id],
|
|
1202
|
+
path=path,
|
|
1203
|
+
symbols=symbols,
|
|
1204
|
+
)
|
|
1205
|
+
|
|
1206
|
+
result = await self.file_operation(
|
|
1207
|
+
path,
|
|
1208
|
+
configurations,
|
|
1209
|
+
run,
|
|
1210
|
+
on_progress=status,
|
|
1211
|
+
timeout=timeout,
|
|
1212
|
+
)
|
|
1213
|
+
except (ClangdError, WorkspaceError, ToolchainError, ProcessError) as error:
|
|
1214
|
+
raise ToolError(str(error)) from error
|
|
1215
|
+
await status("Completed clangd analysis")
|
|
1216
|
+
return result
|
|
1217
|
+
|
|
1218
|
+
@apps.tool(
|
|
1219
|
+
resource_uri=self.WIDGET.uri,
|
|
1220
|
+
icons=[self.ICON.icon],
|
|
1221
|
+
annotations=read_only,
|
|
1222
|
+
)
|
|
1223
|
+
async def clangd_workspace_symbols(
|
|
1224
|
+
query: str,
|
|
1225
|
+
ctx: Context,
|
|
1226
|
+
configurations: list[str] = [],
|
|
1227
|
+
timeout: float = 30.0,
|
|
1228
|
+
) -> list[WorkspaceSymbolsResult]:
|
|
1229
|
+
"""Find workspace symbols by name query. Empty configurations selects all."""
|
|
1230
|
+
status = analysis_progress(ctx)
|
|
1231
|
+
await status("Starting clangd analysis")
|
|
1232
|
+
try:
|
|
1233
|
+
validate_timeout(timeout)
|
|
1234
|
+
|
|
1235
|
+
async def run(session: ClangdSession) -> WorkspaceSymbolsResult:
|
|
1236
|
+
self.require_capability(session, "workspaceSymbolProvider")
|
|
1237
|
+
raw = await session.request(
|
|
1238
|
+
"workspace/symbol",
|
|
1239
|
+
{"query": query},
|
|
1240
|
+
on_progress=status,
|
|
1241
|
+
timeout=timeout,
|
|
1242
|
+
)
|
|
1243
|
+
data = [
|
|
1244
|
+
object_value(value)
|
|
1245
|
+
for value in list_value([] if raw is None else raw)
|
|
1246
|
+
]
|
|
1247
|
+
locations = self.navigation_locations(
|
|
1248
|
+
[session.location(item.get("location")) for item in data],
|
|
1249
|
+
)
|
|
1250
|
+
symbols = []
|
|
1251
|
+
for item, location in zip(data, locations, strict=True):
|
|
1252
|
+
symbols.append(
|
|
1253
|
+
WorkspaceSymbol(
|
|
1254
|
+
name=string(item.get("name")),
|
|
1255
|
+
kind=integer(item.get("kind")),
|
|
1256
|
+
location=location,
|
|
1257
|
+
container_name=item.get("containerName"),
|
|
1258
|
+
tags=item.get("tags", []),
|
|
1259
|
+
),
|
|
1260
|
+
)
|
|
1261
|
+
return WorkspaceSymbolsResult(
|
|
1262
|
+
configurations=[session.configuration.id],
|
|
1263
|
+
symbols=symbols,
|
|
1264
|
+
)
|
|
1265
|
+
|
|
1266
|
+
async with self.operation(timeout):
|
|
1267
|
+
contexts = await self.selection(configurations)
|
|
1268
|
+
result = await self.collect(
|
|
1269
|
+
contexts,
|
|
1270
|
+
run,
|
|
1271
|
+
on_progress=status,
|
|
1272
|
+
timeout=timeout,
|
|
1273
|
+
)
|
|
1274
|
+
except (ClangdError, WorkspaceError, ToolchainError, ProcessError) as error:
|
|
1275
|
+
raise ToolError(str(error)) from error
|
|
1276
|
+
await status("Completed clangd analysis")
|
|
1277
|
+
return result
|
|
1278
|
+
|
|
1279
|
+
apps.add_html_resource(self.WIDGET.uri, self.WIDGET.content)
|