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,520 @@
|
|
|
1
|
+
"""One language-server session: requests, capabilities, and document versions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
from contextlib import suppress
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from math import isfinite
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from urllib.parse import unquote, urlsplit
|
|
12
|
+
from urllib.request import url2pathname
|
|
13
|
+
|
|
14
|
+
from pydantic import JsonValue, ValidationError
|
|
15
|
+
|
|
16
|
+
from forgemcp.cmake.service import CompilationContext
|
|
17
|
+
from forgemcp.progress import Progress
|
|
18
|
+
from forgemcp.toolchain.errors import ToolchainError
|
|
19
|
+
from forgemcp.toolchain.tools.clangd import (
|
|
20
|
+
Connection,
|
|
21
|
+
LspErrorResponse,
|
|
22
|
+
LspNotification,
|
|
23
|
+
LspRequest,
|
|
24
|
+
LspResponse,
|
|
25
|
+
)
|
|
26
|
+
from forgemcp.workspace.path import WorkspacePath
|
|
27
|
+
from forgemcp.workspace.service import WorkspaceService
|
|
28
|
+
|
|
29
|
+
from .errors import (
|
|
30
|
+
ClangdError,
|
|
31
|
+
ClangdProtocolError,
|
|
32
|
+
ClangdRequestError,
|
|
33
|
+
ClangdSessionError,
|
|
34
|
+
ClangdStaleResultError,
|
|
35
|
+
ClangdTimeoutError,
|
|
36
|
+
)
|
|
37
|
+
from .models import Diagnostic, Location, Position, RelatedDiagnostic, SourceRange
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class Document:
|
|
42
|
+
path: WorkspacePath
|
|
43
|
+
text: str
|
|
44
|
+
version: int = 0
|
|
45
|
+
diagnostics_version: int | None = None
|
|
46
|
+
diagnostics: list[Diagnostic] = field(default_factory=list)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def object_value(value: JsonValue) -> dict[str, JsonValue]:
|
|
50
|
+
if not isinstance(value, dict):
|
|
51
|
+
raise ClangdProtocolError("Expected an LSP object.")
|
|
52
|
+
return value
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def list_value(value: JsonValue) -> list[JsonValue]:
|
|
56
|
+
if not isinstance(value, list):
|
|
57
|
+
raise ClangdProtocolError("Expected an LSP array.")
|
|
58
|
+
return value
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def integer(value: JsonValue) -> int:
|
|
62
|
+
if type(value) is not int or value < 0:
|
|
63
|
+
raise ClangdProtocolError("Expected a nonnegative LSP integer.")
|
|
64
|
+
return value
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def string(value: JsonValue) -> str:
|
|
68
|
+
if not isinstance(value, str):
|
|
69
|
+
raise ClangdProtocolError("Expected LSP text.")
|
|
70
|
+
return value
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def position(value: JsonValue) -> Position:
|
|
74
|
+
data = object_value(value)
|
|
75
|
+
return Position(
|
|
76
|
+
line=integer(data.get("line")) + 1,
|
|
77
|
+
character=integer(data.get("character")),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def source_range(value: JsonValue) -> SourceRange:
|
|
82
|
+
data = object_value(value)
|
|
83
|
+
try:
|
|
84
|
+
return SourceRange(
|
|
85
|
+
start=position(data.get("start")),
|
|
86
|
+
end=position(data.get("end")),
|
|
87
|
+
)
|
|
88
|
+
except ValidationError as error:
|
|
89
|
+
raise ClangdProtocolError("Invalid source range from clangd.") from error
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def lsp_position(value: Position) -> dict[str, JsonValue]:
|
|
93
|
+
return {"line": value.line - 1, "character": value.character}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def validate_timeout(timeout: float) -> None:
|
|
97
|
+
if not isfinite(timeout) or timeout <= 0:
|
|
98
|
+
raise ClangdError("Analysis timeout must be finite and greater than zero.")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class ClangdSession:
|
|
102
|
+
"""Interpret LSP for one compilation context; never own a subprocess directly."""
|
|
103
|
+
|
|
104
|
+
def __init__(
|
|
105
|
+
self,
|
|
106
|
+
connection: Connection,
|
|
107
|
+
workspace: WorkspaceService,
|
|
108
|
+
configuration: CompilationContext,
|
|
109
|
+
) -> None:
|
|
110
|
+
self.connection = connection
|
|
111
|
+
self.workspace = workspace
|
|
112
|
+
self.configuration = configuration
|
|
113
|
+
self.capabilities: dict[str, JsonValue] = {}
|
|
114
|
+
self.pending: dict[int, asyncio.Future[JsonValue]] = {}
|
|
115
|
+
self.methods: dict[int, str] = {}
|
|
116
|
+
self.documents: dict[str, Document] = {}
|
|
117
|
+
self.versions: dict[str, int] = {}
|
|
118
|
+
self.next_id = 0
|
|
119
|
+
self.reader: asyncio.Task[None] | None = None
|
|
120
|
+
self.condition = asyncio.Condition()
|
|
121
|
+
self.failure: ClangdError | None = None
|
|
122
|
+
self.initialized = False
|
|
123
|
+
self.closing = False
|
|
124
|
+
|
|
125
|
+
async def report(self, callback: Progress | None, message: str) -> None:
|
|
126
|
+
if callback is not None:
|
|
127
|
+
await callback(f"{self.configuration.id}: {message}")
|
|
128
|
+
|
|
129
|
+
def check_alive(self) -> None:
|
|
130
|
+
if self.failure is not None:
|
|
131
|
+
raise self.failure
|
|
132
|
+
if self.closing:
|
|
133
|
+
raise ClangdSessionError("clangd session is closing.")
|
|
134
|
+
|
|
135
|
+
def uri(self, path: WorkspacePath) -> str:
|
|
136
|
+
for uri, document in self.documents.items():
|
|
137
|
+
if document.path == path:
|
|
138
|
+
return uri
|
|
139
|
+
native = path.absolute if path.area == "root" else self.workspace.resolve_workspace_path(path)
|
|
140
|
+
return native.resolve().as_uri()
|
|
141
|
+
|
|
142
|
+
def path(self, uri: str) -> WorkspacePath:
|
|
143
|
+
parts = urlsplit(uri)
|
|
144
|
+
if parts.scheme != "file" or parts.query or parts.fragment:
|
|
145
|
+
raise ClangdProtocolError("clangd returned a non-file location.")
|
|
146
|
+
if parts.netloc and parts.netloc.lower() != "localhost":
|
|
147
|
+
if os.name != "nt":
|
|
148
|
+
raise ClangdProtocolError("A remote file URI is not a local path.")
|
|
149
|
+
native = Path("//" + unquote(parts.netloc) + url2pathname(parts.path))
|
|
150
|
+
else:
|
|
151
|
+
native = Path(url2pathname(parts.path))
|
|
152
|
+
if not native.is_absolute() or "\0" in str(native):
|
|
153
|
+
raise ClangdProtocolError("clangd returned a non-absolute file location.")
|
|
154
|
+
return self.workspace.workspace_path(native.resolve())
|
|
155
|
+
|
|
156
|
+
def location(self, value: JsonValue) -> Location:
|
|
157
|
+
data = object_value(value)
|
|
158
|
+
if "targetUri" in data:
|
|
159
|
+
return Location(
|
|
160
|
+
path=self.path(string(data["targetUri"])),
|
|
161
|
+
range=source_range(data.get("targetSelectionRange", data.get("targetRange"))),
|
|
162
|
+
)
|
|
163
|
+
return Location(
|
|
164
|
+
path=self.path(string(data.get("uri"))),
|
|
165
|
+
range=source_range(data.get("range")),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def diagnostic(self, value: JsonValue) -> Diagnostic:
|
|
169
|
+
data = object_value(value)
|
|
170
|
+
levels = {1: "error", 2: "warning", 3: "information", 4: "hint"}
|
|
171
|
+
severity = data.get("severity")
|
|
172
|
+
if severity is not None and (type(severity) is not int or severity not in levels):
|
|
173
|
+
raise ClangdProtocolError("Invalid diagnostic severity from clangd.")
|
|
174
|
+
related = []
|
|
175
|
+
for item in list_value(data.get("relatedInformation", [])):
|
|
176
|
+
entry = object_value(item)
|
|
177
|
+
location = self.location(entry.get("location"))
|
|
178
|
+
if location.path.area != "root":
|
|
179
|
+
related.append(
|
|
180
|
+
RelatedDiagnostic(
|
|
181
|
+
location=location,
|
|
182
|
+
message=string(entry.get("message")),
|
|
183
|
+
),
|
|
184
|
+
)
|
|
185
|
+
try:
|
|
186
|
+
return Diagnostic(
|
|
187
|
+
range=source_range(data.get("range")),
|
|
188
|
+
message=string(data.get("message")),
|
|
189
|
+
severity=levels.get(severity),
|
|
190
|
+
code=data.get("code"),
|
|
191
|
+
source=data.get("source"),
|
|
192
|
+
tags=data.get("tags", []),
|
|
193
|
+
related=related,
|
|
194
|
+
)
|
|
195
|
+
except ValidationError as error:
|
|
196
|
+
raise ClangdProtocolError("Invalid diagnostic from clangd.") from error
|
|
197
|
+
|
|
198
|
+
async def receive(self) -> None:
|
|
199
|
+
failure: ClangdError = ClangdSessionError("clangd connection closed.")
|
|
200
|
+
try:
|
|
201
|
+
async for message in self.connection.messages():
|
|
202
|
+
if isinstance(message, (LspResponse, LspErrorResponse)):
|
|
203
|
+
future = self.pending.get(message.id)
|
|
204
|
+
if future is None or future.done():
|
|
205
|
+
continue
|
|
206
|
+
if isinstance(message, LspErrorResponse):
|
|
207
|
+
future.set_exception(
|
|
208
|
+
ClangdRequestError(
|
|
209
|
+
self.methods[message.id],
|
|
210
|
+
message.code,
|
|
211
|
+
message.message,
|
|
212
|
+
),
|
|
213
|
+
)
|
|
214
|
+
else:
|
|
215
|
+
future.set_result(message.result)
|
|
216
|
+
elif isinstance(message, LspRequest):
|
|
217
|
+
await self.answer(message)
|
|
218
|
+
elif message.method == "textDocument/publishDiagnostics":
|
|
219
|
+
data = object_value(message.params)
|
|
220
|
+
uri = self.uri(self.path(string(data.get("uri"))))
|
|
221
|
+
document = self.documents.get(uri)
|
|
222
|
+
if document is None or document.path.area == "root":
|
|
223
|
+
continue
|
|
224
|
+
version = data.get("version")
|
|
225
|
+
# Never label an unversioned or older notification as current.
|
|
226
|
+
if type(version) is not int or version != document.version:
|
|
227
|
+
continue
|
|
228
|
+
diagnostics = [
|
|
229
|
+
self.diagnostic(item)
|
|
230
|
+
for item in list_value(data.get("diagnostics"))
|
|
231
|
+
]
|
|
232
|
+
async with self.condition:
|
|
233
|
+
document.diagnostics = diagnostics
|
|
234
|
+
document.diagnostics_version = version
|
|
235
|
+
self.condition.notify_all()
|
|
236
|
+
except asyncio.CancelledError:
|
|
237
|
+
raise
|
|
238
|
+
except ClangdError as error:
|
|
239
|
+
failure = error
|
|
240
|
+
except (ToolchainError, OSError, ValueError) as error:
|
|
241
|
+
failure = ClangdSessionError("clangd connection failed.")
|
|
242
|
+
failure.__cause__ = error
|
|
243
|
+
except Exception as error:
|
|
244
|
+
failure = ClangdSessionError("clangd response processing failed.")
|
|
245
|
+
failure.__cause__ = error
|
|
246
|
+
finally:
|
|
247
|
+
self.failure = failure
|
|
248
|
+
for future in tuple(self.pending.values()):
|
|
249
|
+
if not future.done():
|
|
250
|
+
future.set_exception(failure)
|
|
251
|
+
async with self.condition:
|
|
252
|
+
self.condition.notify_all()
|
|
253
|
+
await self.connection.close()
|
|
254
|
+
|
|
255
|
+
async def answer(self, request: LspRequest) -> None:
|
|
256
|
+
if request.method == "workspace/configuration":
|
|
257
|
+
items = list_value(object_value(request.params).get("items"))
|
|
258
|
+
await self.connection.send(LspResponse(request.id, [None for _ in items]))
|
|
259
|
+
elif request.method == "workspace/workspaceFolders":
|
|
260
|
+
await self.connection.send(
|
|
261
|
+
LspResponse(
|
|
262
|
+
request.id,
|
|
263
|
+
[{"uri": self.workspace.root.as_uri(), "name": self.workspace.root.name}],
|
|
264
|
+
),
|
|
265
|
+
)
|
|
266
|
+
elif request.method == "workspace/applyEdit":
|
|
267
|
+
await self.connection.send(
|
|
268
|
+
LspResponse(
|
|
269
|
+
request.id,
|
|
270
|
+
{"applied": False, "failureReason": "Language analysis is read-only."},
|
|
271
|
+
),
|
|
272
|
+
)
|
|
273
|
+
else:
|
|
274
|
+
await self.connection.send(
|
|
275
|
+
LspErrorResponse(request.id, -32601, "Unsupported client request."),
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
async def initialize(
|
|
279
|
+
self,
|
|
280
|
+
*,
|
|
281
|
+
on_progress: Progress | None = None,
|
|
282
|
+
timeout: float = 30.0,
|
|
283
|
+
) -> None:
|
|
284
|
+
if self.reader is not None:
|
|
285
|
+
raise ClangdSessionError("A clangd session can only be initialized once.")
|
|
286
|
+
self.reader = asyncio.create_task(self.receive())
|
|
287
|
+
result = object_value(
|
|
288
|
+
await self.request(
|
|
289
|
+
"initialize",
|
|
290
|
+
{
|
|
291
|
+
"processId": os.getpid(),
|
|
292
|
+
"rootUri": self.workspace.root.as_uri(),
|
|
293
|
+
"capabilities": {
|
|
294
|
+
"general": {"positionEncodings": ["utf-32"]},
|
|
295
|
+
"offsetEncoding": ["utf-32"],
|
|
296
|
+
"workspace": {
|
|
297
|
+
"configuration": True,
|
|
298
|
+
"workspaceFolders": True,
|
|
299
|
+
"applyEdit": False,
|
|
300
|
+
},
|
|
301
|
+
"textDocument": {
|
|
302
|
+
"publishDiagnostics": {
|
|
303
|
+
"versionSupport": True,
|
|
304
|
+
"relatedInformation": True,
|
|
305
|
+
"tagSupport": {"valueSet": [1, 2]},
|
|
306
|
+
},
|
|
307
|
+
"documentSymbol": {"hierarchicalDocumentSymbolSupport": True},
|
|
308
|
+
"hover": {"contentFormat": ["markdown", "plaintext"]},
|
|
309
|
+
"semanticTokens": {
|
|
310
|
+
"requests": {"full": True},
|
|
311
|
+
"tokenTypes": [
|
|
312
|
+
"namespace",
|
|
313
|
+
"type",
|
|
314
|
+
"class",
|
|
315
|
+
"enum",
|
|
316
|
+
"interface",
|
|
317
|
+
"struct",
|
|
318
|
+
"typeParameter",
|
|
319
|
+
"parameter",
|
|
320
|
+
"variable",
|
|
321
|
+
"property",
|
|
322
|
+
"enumMember",
|
|
323
|
+
"event",
|
|
324
|
+
"function",
|
|
325
|
+
"method",
|
|
326
|
+
"macro",
|
|
327
|
+
"keyword",
|
|
328
|
+
"modifier",
|
|
329
|
+
"comment",
|
|
330
|
+
"string",
|
|
331
|
+
"number",
|
|
332
|
+
"regexp",
|
|
333
|
+
"operator",
|
|
334
|
+
"decorator",
|
|
335
|
+
],
|
|
336
|
+
"tokenModifiers": [
|
|
337
|
+
"declaration",
|
|
338
|
+
"definition",
|
|
339
|
+
"readonly",
|
|
340
|
+
"static",
|
|
341
|
+
"deprecated",
|
|
342
|
+
"abstract",
|
|
343
|
+
"async",
|
|
344
|
+
"modification",
|
|
345
|
+
"documentation",
|
|
346
|
+
"defaultLibrary",
|
|
347
|
+
],
|
|
348
|
+
"formats": ["relative"],
|
|
349
|
+
},
|
|
350
|
+
},
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
on_progress=on_progress,
|
|
354
|
+
timeout=timeout,
|
|
355
|
+
),
|
|
356
|
+
)
|
|
357
|
+
self.capabilities = object_value(result.get("capabilities"))
|
|
358
|
+
encoding = self.capabilities.get(
|
|
359
|
+
"positionEncoding",
|
|
360
|
+
result.get("offsetEncoding", "utf-16"),
|
|
361
|
+
)
|
|
362
|
+
if encoding != "utf-32":
|
|
363
|
+
raise ClangdProtocolError(
|
|
364
|
+
"This clangd must support UTF-32 positions, including external locations.",
|
|
365
|
+
)
|
|
366
|
+
await self.notify("initialized", {})
|
|
367
|
+
self.initialized = True
|
|
368
|
+
|
|
369
|
+
async def request(
|
|
370
|
+
self,
|
|
371
|
+
method: str,
|
|
372
|
+
params: JsonValue,
|
|
373
|
+
*,
|
|
374
|
+
on_progress: Progress | None = None,
|
|
375
|
+
timeout: float = 30.0,
|
|
376
|
+
) -> JsonValue:
|
|
377
|
+
validate_timeout(timeout)
|
|
378
|
+
self.check_alive()
|
|
379
|
+
if self.reader is None:
|
|
380
|
+
raise ClangdSessionError("clangd message reader has not started.")
|
|
381
|
+
self.next_id += 1
|
|
382
|
+
identifier = self.next_id
|
|
383
|
+
future = asyncio.get_running_loop().create_future()
|
|
384
|
+
self.pending[identifier] = future
|
|
385
|
+
self.methods[identifier] = method
|
|
386
|
+
try:
|
|
387
|
+
async with asyncio.timeout(timeout):
|
|
388
|
+
await self.report(on_progress, f"Starting {method}")
|
|
389
|
+
await self.connection.send(LspRequest(identifier, method, params))
|
|
390
|
+
result = await future
|
|
391
|
+
await self.report(on_progress, f"Completed {method}")
|
|
392
|
+
return result
|
|
393
|
+
except (TimeoutError, asyncio.CancelledError) as error:
|
|
394
|
+
with suppress(ToolchainError):
|
|
395
|
+
await self.connection.send(
|
|
396
|
+
LspNotification("$/cancelRequest", {"id": identifier}),
|
|
397
|
+
)
|
|
398
|
+
if isinstance(error, asyncio.CancelledError):
|
|
399
|
+
raise
|
|
400
|
+
raise ClangdTimeoutError(f"{method} exceeded its timeout.") from error
|
|
401
|
+
except ToolchainError as error:
|
|
402
|
+
raise ClangdSessionError("Cannot communicate with clangd.") from error
|
|
403
|
+
finally:
|
|
404
|
+
self.pending.pop(identifier, None)
|
|
405
|
+
self.methods.pop(identifier, None)
|
|
406
|
+
if not future.done():
|
|
407
|
+
future.cancel()
|
|
408
|
+
elif not future.cancelled():
|
|
409
|
+
future.exception()
|
|
410
|
+
|
|
411
|
+
async def notify(self, method: str, params: JsonValue) -> None:
|
|
412
|
+
self.check_alive()
|
|
413
|
+
try:
|
|
414
|
+
await self.connection.send(LspNotification(method, params))
|
|
415
|
+
except ToolchainError as error:
|
|
416
|
+
raise ClangdSessionError("Cannot synchronize clangd.") from error
|
|
417
|
+
|
|
418
|
+
async def synchronize(
|
|
419
|
+
self,
|
|
420
|
+
path: WorkspacePath,
|
|
421
|
+
text: str,
|
|
422
|
+
*,
|
|
423
|
+
on_progress: Progress | None = None,
|
|
424
|
+
) -> int:
|
|
425
|
+
self.check_alive()
|
|
426
|
+
uri = self.uri(path)
|
|
427
|
+
document = self.documents.get(uri)
|
|
428
|
+
if document is None:
|
|
429
|
+
document = Document(path, text, version=self.versions.get(uri, 0) + 1)
|
|
430
|
+
self.documents[uri] = document
|
|
431
|
+
language = "c" if path.relative.endswith(".c") else "cpp"
|
|
432
|
+
await self.notify(
|
|
433
|
+
"textDocument/didOpen",
|
|
434
|
+
{
|
|
435
|
+
"textDocument": {
|
|
436
|
+
"uri": uri,
|
|
437
|
+
"languageId": language,
|
|
438
|
+
"version": document.version,
|
|
439
|
+
"text": text,
|
|
440
|
+
},
|
|
441
|
+
},
|
|
442
|
+
)
|
|
443
|
+
elif document.text != text:
|
|
444
|
+
document.version += 1
|
|
445
|
+
document.text = text
|
|
446
|
+
document.diagnostics_version = None
|
|
447
|
+
document.diagnostics = []
|
|
448
|
+
await self.notify(
|
|
449
|
+
"textDocument/didChange",
|
|
450
|
+
{
|
|
451
|
+
"textDocument": {"uri": uri, "version": document.version},
|
|
452
|
+
"contentChanges": [{"text": text}],
|
|
453
|
+
"wantDiagnostics": True,
|
|
454
|
+
},
|
|
455
|
+
)
|
|
456
|
+
self.versions[uri] = document.version
|
|
457
|
+
async with self.condition:
|
|
458
|
+
self.condition.notify_all()
|
|
459
|
+
await self.report(on_progress, f"Synchronized {path} version {document.version}")
|
|
460
|
+
return document.version
|
|
461
|
+
|
|
462
|
+
async def forget(self, path: WorkspacePath) -> None:
|
|
463
|
+
uri = self.uri(path)
|
|
464
|
+
if uri in self.documents:
|
|
465
|
+
await self.notify("textDocument/didClose", {"textDocument": {"uri": uri}})
|
|
466
|
+
self.documents.pop(uri, None)
|
|
467
|
+
async with self.condition:
|
|
468
|
+
self.condition.notify_all()
|
|
469
|
+
|
|
470
|
+
async def diagnostics(
|
|
471
|
+
self,
|
|
472
|
+
path: WorkspacePath,
|
|
473
|
+
version: int,
|
|
474
|
+
*,
|
|
475
|
+
on_progress: Progress | None = None,
|
|
476
|
+
timeout: float = 30.0,
|
|
477
|
+
) -> list[Diagnostic]:
|
|
478
|
+
validate_timeout(timeout)
|
|
479
|
+
uri = self.uri(path)
|
|
480
|
+
try:
|
|
481
|
+
async with asyncio.timeout(timeout):
|
|
482
|
+
await self.report(
|
|
483
|
+
on_progress,
|
|
484
|
+
f"Waiting for diagnostics of {path} version {version}",
|
|
485
|
+
)
|
|
486
|
+
async with self.condition:
|
|
487
|
+
while True:
|
|
488
|
+
self.check_alive()
|
|
489
|
+
document = self.documents.get(uri)
|
|
490
|
+
if document is None or document.version != version:
|
|
491
|
+
raise ClangdStaleResultError("The requested document version is no longer current.")
|
|
492
|
+
if document.diagnostics_version == version:
|
|
493
|
+
result = [item.model_copy(deep=True) for item in document.diagnostics]
|
|
494
|
+
break
|
|
495
|
+
await self.condition.wait()
|
|
496
|
+
except TimeoutError as error:
|
|
497
|
+
raise ClangdTimeoutError(
|
|
498
|
+
f"No diagnostics for {path} version {version} before the timeout.",
|
|
499
|
+
) from error
|
|
500
|
+
await self.report(on_progress, f"Received diagnostics for {path}")
|
|
501
|
+
return result
|
|
502
|
+
|
|
503
|
+
async def shutdown(self, *, timeout: float = 5.0) -> None:
|
|
504
|
+
if self.closing:
|
|
505
|
+
return
|
|
506
|
+
try:
|
|
507
|
+
if self.initialized and self.failure is None:
|
|
508
|
+
with suppress(ClangdError):
|
|
509
|
+
await self.request("shutdown", None, timeout=timeout)
|
|
510
|
+
await self.notify("exit", None)
|
|
511
|
+
if self.reader is not None:
|
|
512
|
+
with suppress(TimeoutError):
|
|
513
|
+
async with asyncio.timeout(timeout):
|
|
514
|
+
await asyncio.shield(self.reader)
|
|
515
|
+
finally:
|
|
516
|
+
self.closing = True
|
|
517
|
+
if self.reader is not None:
|
|
518
|
+
self.reader.cancel()
|
|
519
|
+
with suppress(asyncio.CancelledError):
|
|
520
|
+
await self.reader
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CMake profiles and build operations."""
|
forgemcp/cmake/errors.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Operator-owned CMake profiles and CLI parsing."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
from forgemcp.workspace.path import WorkspacePath
|
|
8
|
+
|
|
9
|
+
from .errors import CMakeError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ProfileDefinition(BaseModel):
|
|
13
|
+
name: str
|
|
14
|
+
toolset: str = "system"
|
|
15
|
+
configure_presets: list[str] | None = None
|
|
16
|
+
build_presets: list[str] | None = None
|
|
17
|
+
test_presets: list[str] | None = None
|
|
18
|
+
configuration: str | None = None
|
|
19
|
+
generator: str | None = None
|
|
20
|
+
build_directory: WorkspacePath | None = None
|
|
21
|
+
c_compiler: str | None = None
|
|
22
|
+
cxx_compiler: str | None = None
|
|
23
|
+
toolchain_file: WorkspacePath | None = None
|
|
24
|
+
definitions: dict[str, str] = Field(default_factory=dict)
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def uses_presets(self) -> bool:
|
|
28
|
+
return any(
|
|
29
|
+
presets is not None
|
|
30
|
+
for presets in (self.configure_presets, self.build_presets, self.test_presets)
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def parse_profiles(
|
|
35
|
+
groups: Sequence[Sequence[str]],
|
|
36
|
+
default_toolset: str = "system",
|
|
37
|
+
) -> tuple[ProfileDefinition, ...]:
|
|
38
|
+
"""Parse repeated NAME KEY=VALUE groups without reading environment variables."""
|
|
39
|
+
if not default_toolset.strip():
|
|
40
|
+
raise CMakeError("--cmake-toolset must not be empty.")
|
|
41
|
+
profiles = []
|
|
42
|
+
names = set()
|
|
43
|
+
fields = {
|
|
44
|
+
"toolset": "toolset",
|
|
45
|
+
"configuration": "configuration",
|
|
46
|
+
"generator": "generator",
|
|
47
|
+
"build-directory": "build_directory",
|
|
48
|
+
"c-compiler": "c_compiler",
|
|
49
|
+
"cxx-compiler": "cxx_compiler",
|
|
50
|
+
"toolchain-file": "toolchain_file",
|
|
51
|
+
}
|
|
52
|
+
for group in groups:
|
|
53
|
+
if not group or not group[0].strip() or "=" in group[0]:
|
|
54
|
+
raise CMakeError("--cmake-profile requires NAME [KEY=VALUE ...].")
|
|
55
|
+
name = group[0]
|
|
56
|
+
if name in names:
|
|
57
|
+
raise CMakeError(f"Duplicate CMake profile {name!r}.")
|
|
58
|
+
names.add(name)
|
|
59
|
+
values = {"name": name, "toolset": default_toolset}
|
|
60
|
+
definitions = {}
|
|
61
|
+
seen = set()
|
|
62
|
+
for assignment in group[1:]:
|
|
63
|
+
key, separator, value = assignment.partition("=")
|
|
64
|
+
if not separator or not value:
|
|
65
|
+
raise CMakeError(f"Expected KEY=VALUE in profile {name!r}.")
|
|
66
|
+
if key == "define":
|
|
67
|
+
variable, equals, setting = value.partition("=")
|
|
68
|
+
if not equals or not variable or variable in definitions:
|
|
69
|
+
raise CMakeError("define requires a unique CMAKE_VARIABLE=value.")
|
|
70
|
+
definitions[variable] = setting
|
|
71
|
+
elif key in ("configure-preset", "build-preset", "test-preset"):
|
|
72
|
+
field = key.replace("-", "_") + "s"
|
|
73
|
+
values.setdefault(field, []).append(value)
|
|
74
|
+
elif key in fields and key not in seen:
|
|
75
|
+
seen.add(key)
|
|
76
|
+
values[fields[key]] = value
|
|
77
|
+
else:
|
|
78
|
+
raise CMakeError(f"Unknown or repeated profile setting {key!r}.")
|
|
79
|
+
profile = ProfileDefinition(**values, definitions=definitions)
|
|
80
|
+
if profile.uses_presets:
|
|
81
|
+
if any(
|
|
82
|
+
(
|
|
83
|
+
profile.generator,
|
|
84
|
+
profile.c_compiler,
|
|
85
|
+
profile.cxx_compiler,
|
|
86
|
+
profile.toolchain_file,
|
|
87
|
+
profile.definitions,
|
|
88
|
+
)
|
|
89
|
+
):
|
|
90
|
+
raise CMakeError(
|
|
91
|
+
"A preset profile takes configure settings from its preset; "
|
|
92
|
+
"do not also specify generator, compilers, toolchain or definitions."
|
|
93
|
+
)
|
|
94
|
+
profiles.append(profile)
|
|
95
|
+
return tuple(profiles)
|