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.
Files changed (83) hide show
  1. forge_cpp_mcp-0.2.1.dist-info/METADATA +436 -0
  2. forge_cpp_mcp-0.2.1.dist-info/RECORD +83 -0
  3. forge_cpp_mcp-0.2.1.dist-info/WHEEL +4 -0
  4. forge_cpp_mcp-0.2.1.dist-info/entry_points.txt +2 -0
  5. forge_cpp_mcp-0.2.1.dist-info/licenses/LICENSE +21 -0
  6. forgemcp/__init__.py +5 -0
  7. forgemcp/assets/clangd-result.html +119 -0
  8. forgemcp/assets/cmake-build.html +111 -0
  9. forgemcp/assets/cmake-configure.html +111 -0
  10. forgemcp/assets/cmake-profiles.html +111 -0
  11. forgemcp/assets/cmake-test.html +111 -0
  12. forgemcp/assets/process-details.html +113 -0
  13. forgemcp/assets/process-overview.html +111 -0
  14. forgemcp/assets/toolsets.html +111 -0
  15. forgemcp/assets/workspace-file.html +119 -0
  16. forgemcp/assets/workspace-result.html +119 -0
  17. forgemcp/assets/workspace-search.html +119 -0
  18. forgemcp/assets/workspace-tree.html +119 -0
  19. forgemcp/assets.py +62 -0
  20. forgemcp/clangd/__init__.py +1 -0
  21. forgemcp/clangd/errors.py +29 -0
  22. forgemcp/clangd/models.py +94 -0
  23. forgemcp/clangd/service.py +1279 -0
  24. forgemcp/clangd/session.py +520 -0
  25. forgemcp/cmake/__init__.py +1 -0
  26. forgemcp/cmake/errors.py +5 -0
  27. forgemcp/cmake/profiles.py +95 -0
  28. forgemcp/cmake/service.py +895 -0
  29. forgemcp/completion.py +49 -0
  30. forgemcp/icons/clangd.LICENSE +219 -0
  31. forgemcp/icons/clangd.svg +25 -0
  32. forgemcp/icons/cmake.svg +9 -0
  33. forgemcp/icons/process.svg +4 -0
  34. forgemcp/icons/toolchain.svg +1 -0
  35. forgemcp/icons/workspace-edit.svg +1 -0
  36. forgemcp/icons/workspace-file.svg +1 -0
  37. forgemcp/icons/workspace-search.svg +1 -0
  38. forgemcp/icons/workspace.svg +4 -0
  39. forgemcp/markdown.py +308 -0
  40. forgemcp/process/__init__.py +21 -0
  41. forgemcp/process/errors.py +21 -0
  42. forgemcp/process/models.py +171 -0
  43. forgemcp/process/service.py +830 -0
  44. forgemcp/process/transcript.py +116 -0
  45. forgemcp/progress.py +40 -0
  46. forgemcp/server.py +196 -0
  47. forgemcp/toolchain/__init__.py +1 -0
  48. forgemcp/toolchain/discovery.py +25 -0
  49. forgemcp/toolchain/errors.py +41 -0
  50. forgemcp/toolchain/loader.py +48 -0
  51. forgemcp/toolchain/providers/__init__.py +1 -0
  52. forgemcp/toolchain/providers/system.py +52 -0
  53. forgemcp/toolchain/providers/user.py +93 -0
  54. forgemcp/toolchain/providers/visual_studio.py +202 -0
  55. forgemcp/toolchain/service.py +226 -0
  56. forgemcp/toolchain/spec.py +73 -0
  57. forgemcp/toolchain/tools/__init__.py +1 -0
  58. forgemcp/toolchain/tools/clang.py +61 -0
  59. forgemcp/toolchain/tools/clang_cl.py +61 -0
  60. forgemcp/toolchain/tools/clangd.py +280 -0
  61. forgemcp/toolchain/tools/clangxx.py +61 -0
  62. forgemcp/toolchain/tools/cmake.py +280 -0
  63. forgemcp/toolchain/tools/cppvsdbg.py +27 -0
  64. forgemcp/toolchain/tools/ctest.py +202 -0
  65. forgemcp/toolchain/tools/gcc.py +61 -0
  66. forgemcp/toolchain/tools/gdb.py +61 -0
  67. forgemcp/toolchain/tools/git.py +61 -0
  68. forgemcp/toolchain/tools/gxx.py +61 -0
  69. forgemcp/toolchain/tools/link.py +27 -0
  70. forgemcp/toolchain/tools/lld.py +61 -0
  71. forgemcp/toolchain/tools/lld_link.py +61 -0
  72. forgemcp/toolchain/tools/lldb_dap.py +27 -0
  73. forgemcp/toolchain/tools/make.py +61 -0
  74. forgemcp/toolchain/tools/msbuild.py +61 -0
  75. forgemcp/toolchain/tools/msvc.py +27 -0
  76. forgemcp/toolchain/tools/ninja.py +61 -0
  77. forgemcp/workspace/__init__.py +5 -0
  78. forgemcp/workspace/diff.py +181 -0
  79. forgemcp/workspace/errors.py +5 -0
  80. forgemcp/workspace/metadata.py +87 -0
  81. forgemcp/workspace/path.py +71 -0
  82. forgemcp/workspace/providers.py +25 -0
  83. 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)