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,280 @@
|
|
|
1
|
+
"""Typed methods for clangd."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping
|
|
7
|
+
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
|
8
|
+
from dataclasses import asdict, dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Protocol, TypedDict
|
|
11
|
+
|
|
12
|
+
from pydantic import JsonValue
|
|
13
|
+
|
|
14
|
+
from forgemcp.process.errors import ProcessError
|
|
15
|
+
from forgemcp.process.models import ProcessEncoding, ProcessTimeout
|
|
16
|
+
from forgemcp.process.service import ProcessService, ProcessSession
|
|
17
|
+
|
|
18
|
+
from ..errors import ToolCommandError, ToolParserError
|
|
19
|
+
from ..spec import ToolInfo, ToolKind, ToolSpec
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
type LspId = int | str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class LspRequest:
|
|
27
|
+
id: LspId
|
|
28
|
+
method: str
|
|
29
|
+
params: JsonValue = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class LspNotification:
|
|
34
|
+
method: str
|
|
35
|
+
params: JsonValue = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class LspResponse:
|
|
40
|
+
id: LspId
|
|
41
|
+
result: JsonValue
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class LspErrorResponse:
|
|
46
|
+
id: LspId | None
|
|
47
|
+
code: int
|
|
48
|
+
message: str
|
|
49
|
+
data: JsonValue = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
type LspMessage = LspRequest | LspNotification | LspResponse | LspErrorResponse
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def parse_message(value: object) -> LspMessage:
|
|
56
|
+
"""Validate the JSON-RPC envelope without interpreting a language operation."""
|
|
57
|
+
if not isinstance(value, dict) or value.get("jsonrpc") != "2.0":
|
|
58
|
+
raise ToolParserError("clangd returned an invalid JSON-RPC envelope.")
|
|
59
|
+
identifier = value.get("id")
|
|
60
|
+
valid_id = type(identifier) in (int, str)
|
|
61
|
+
if "method" in value:
|
|
62
|
+
if (
|
|
63
|
+
not isinstance(value["method"], str)
|
|
64
|
+
or not value["method"]
|
|
65
|
+
or "result" in value
|
|
66
|
+
or "error" in value
|
|
67
|
+
or ("params" in value and not isinstance(value["params"], (dict, list)))
|
|
68
|
+
):
|
|
69
|
+
raise ToolParserError("clangd returned an invalid request or notification.")
|
|
70
|
+
if "id" not in value:
|
|
71
|
+
return LspNotification(value["method"], value.get("params"))
|
|
72
|
+
if valid_id:
|
|
73
|
+
return LspRequest(identifier, value["method"], value.get("params"))
|
|
74
|
+
elif "result" in value and "error" not in value and valid_id:
|
|
75
|
+
return LspResponse(identifier, value["result"])
|
|
76
|
+
elif "error" in value and "result" not in value and "id" in value:
|
|
77
|
+
error = value["error"]
|
|
78
|
+
if (
|
|
79
|
+
(valid_id or identifier is None)
|
|
80
|
+
and isinstance(error, dict)
|
|
81
|
+
and type(error.get("code")) is int
|
|
82
|
+
and isinstance(error.get("message"), str)
|
|
83
|
+
):
|
|
84
|
+
return LspErrorResponse(
|
|
85
|
+
identifier,
|
|
86
|
+
error["code"],
|
|
87
|
+
error["message"],
|
|
88
|
+
error.get("data"),
|
|
89
|
+
)
|
|
90
|
+
raise ToolParserError("clangd returned an invalid JSON-RPC response or identifier.")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def reject_constant(value: str) -> None:
|
|
94
|
+
raise ValueError("Non-finite JSON number.")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class Connection:
|
|
98
|
+
"""A single-reader framed connection; process ownership stays in this module."""
|
|
99
|
+
|
|
100
|
+
def __init__(self, session: ProcessSession) -> None:
|
|
101
|
+
self._session = session
|
|
102
|
+
self.write_lock = asyncio.Lock()
|
|
103
|
+
self.claimed = False
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def process_id(self) -> int:
|
|
107
|
+
return self._session.process_id
|
|
108
|
+
|
|
109
|
+
async def close(self) -> None:
|
|
110
|
+
"""Close the transport, leaving process cleanup inside the tool module."""
|
|
111
|
+
await self._session.close()
|
|
112
|
+
|
|
113
|
+
async def send(self, message: LspMessage) -> None:
|
|
114
|
+
value = {"jsonrpc": "2.0", **asdict(message)}
|
|
115
|
+
if isinstance(message, LspErrorResponse):
|
|
116
|
+
value = {
|
|
117
|
+
"jsonrpc": "2.0",
|
|
118
|
+
"id": message.id,
|
|
119
|
+
"error": {
|
|
120
|
+
"code": message.code,
|
|
121
|
+
"message": message.message,
|
|
122
|
+
"data": message.data,
|
|
123
|
+
},
|
|
124
|
+
}
|
|
125
|
+
elif isinstance(message, (LspRequest, LspNotification)) and message.params is None:
|
|
126
|
+
value.pop("params")
|
|
127
|
+
parse_message(value)
|
|
128
|
+
try:
|
|
129
|
+
body = json.dumps(
|
|
130
|
+
value,
|
|
131
|
+
ensure_ascii=False,
|
|
132
|
+
allow_nan=False,
|
|
133
|
+
separators=(",", ":"),
|
|
134
|
+
).encode("utf-8")
|
|
135
|
+
header = f"Content-Length: {len(body)}\r\n\r\n".encode("ascii")
|
|
136
|
+
async with self.write_lock:
|
|
137
|
+
await self._session.write_stdin((header + body).decode("latin_1"))
|
|
138
|
+
except (TypeError, ValueError, UnicodeError) as error:
|
|
139
|
+
raise ToolParserError("Cannot serialize an LSP message.") from error
|
|
140
|
+
except ProcessError as error:
|
|
141
|
+
raise ToolCommandError(
|
|
142
|
+
"Cannot send an LSP message to clangd.",
|
|
143
|
+
process_id=self.process_id,
|
|
144
|
+
) from error
|
|
145
|
+
|
|
146
|
+
async def messages(self) -> AsyncGenerator[LspMessage]:
|
|
147
|
+
if self.claimed:
|
|
148
|
+
raise ToolCommandError("clangd messages already have a reader.")
|
|
149
|
+
self.claimed = True
|
|
150
|
+
buffer = bytearray()
|
|
151
|
+
length = None
|
|
152
|
+
try:
|
|
153
|
+
async for chunk in self._session.output():
|
|
154
|
+
if chunk.stream != "stdout":
|
|
155
|
+
continue
|
|
156
|
+
buffer.extend(chunk.text.encode("latin_1"))
|
|
157
|
+
while True:
|
|
158
|
+
if length is None:
|
|
159
|
+
end = buffer.find(b"\r\n\r\n")
|
|
160
|
+
if end < 0:
|
|
161
|
+
break
|
|
162
|
+
headers = bytes(buffer[:end]).decode("ascii")
|
|
163
|
+
del buffer[:end + 4]
|
|
164
|
+
lengths = []
|
|
165
|
+
for header in headers.split("\r\n"):
|
|
166
|
+
key, separator, value = header.partition(":")
|
|
167
|
+
if not separator:
|
|
168
|
+
raise ValueError("Malformed LSP header.")
|
|
169
|
+
if key.strip().lower() == "content-length":
|
|
170
|
+
lengths.append(value.strip())
|
|
171
|
+
if len(lengths) != 1 or not re.fullmatch(r"[0-9]+", lengths[0]):
|
|
172
|
+
raise ValueError("Invalid LSP content length.")
|
|
173
|
+
length = int(lengths[0])
|
|
174
|
+
if len(buffer) < length:
|
|
175
|
+
break
|
|
176
|
+
body = bytes(buffer[:length])
|
|
177
|
+
del buffer[:length]
|
|
178
|
+
length = None
|
|
179
|
+
yield parse_message(
|
|
180
|
+
json.loads(body.decode("utf-8"), parse_constant=reject_constant),
|
|
181
|
+
)
|
|
182
|
+
if buffer or length is not None:
|
|
183
|
+
raise ToolParserError("clangd closed an incomplete LSP message.")
|
|
184
|
+
code = await self._session.wait()
|
|
185
|
+
if code != 0:
|
|
186
|
+
raise ToolCommandError(
|
|
187
|
+
f"clangd exited with code {code}.",
|
|
188
|
+
process_id=self.process_id,
|
|
189
|
+
)
|
|
190
|
+
except (ValueError, UnicodeError, RecursionError) as error:
|
|
191
|
+
raise ToolParserError("clangd returned malformed LSP data.") from error
|
|
192
|
+
except ProcessError as error:
|
|
193
|
+
raise ToolCommandError(
|
|
194
|
+
"clangd process communication failed.",
|
|
195
|
+
process_id=self.process_id,
|
|
196
|
+
) from error
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class Connect(Protocol):
|
|
200
|
+
def __call__(
|
|
201
|
+
self,
|
|
202
|
+
project: Path,
|
|
203
|
+
compilation_database_directory: Path,
|
|
204
|
+
) -> AbstractAsyncContextManager[Connection]: ...
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class Methods(TypedDict):
|
|
208
|
+
version: Callable[[], Awaitable[str]]
|
|
209
|
+
connect: Connect
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def create_spec(
|
|
213
|
+
path: Path,
|
|
214
|
+
processes: ProcessService,
|
|
215
|
+
environment: Mapping[str, str] | None = None,
|
|
216
|
+
inherit_environment: bool = True,
|
|
217
|
+
) -> ToolSpec:
|
|
218
|
+
path = path.resolve()
|
|
219
|
+
|
|
220
|
+
async def version() -> str:
|
|
221
|
+
output = {"stdout": "", "stderr": ""}
|
|
222
|
+
try:
|
|
223
|
+
async with await processes.launch(
|
|
224
|
+
path,
|
|
225
|
+
('--version',),
|
|
226
|
+
env=environment,
|
|
227
|
+
inherit_environment=inherit_environment,
|
|
228
|
+
timeout=ProcessTimeout(total=15),
|
|
229
|
+
) as session:
|
|
230
|
+
await session.close_stdin()
|
|
231
|
+
async for chunk in session.output():
|
|
232
|
+
# Only the short version banner is needed; drain the rest.
|
|
233
|
+
remaining = 4096 - len(output[chunk.stream])
|
|
234
|
+
output[chunk.stream] += chunk.text[:remaining]
|
|
235
|
+
code = await session.wait()
|
|
236
|
+
if code != 0:
|
|
237
|
+
raise ToolCommandError(f"{INFO.name}.version failed (exit {code}).")
|
|
238
|
+
except ProcessError as error:
|
|
239
|
+
raise ToolCommandError(f"Cannot read {INFO.name} version.") from error
|
|
240
|
+
for text in output.values():
|
|
241
|
+
if match := re.search(
|
|
242
|
+
'clangd version ([0-9][^\\s]*)',
|
|
243
|
+
text,
|
|
244
|
+
re.IGNORECASE | re.MULTILINE,
|
|
245
|
+
):
|
|
246
|
+
return match.group(1)
|
|
247
|
+
raise ToolParserError(f"Cannot parse {INFO.name} version.")
|
|
248
|
+
|
|
249
|
+
@asynccontextmanager
|
|
250
|
+
async def connect(
|
|
251
|
+
project: Path,
|
|
252
|
+
compilation_database_directory: Path,
|
|
253
|
+
) -> AsyncGenerator[Connection]:
|
|
254
|
+
if not (compilation_database_directory / "compile_commands.json").is_file():
|
|
255
|
+
raise ToolCommandError("clangd requires an existing compilation database.")
|
|
256
|
+
try:
|
|
257
|
+
async with await processes.launch(
|
|
258
|
+
path,
|
|
259
|
+
(
|
|
260
|
+
f"--compile-commands-dir={compilation_database_directory}",
|
|
261
|
+
"--background-index",
|
|
262
|
+
),
|
|
263
|
+
cwd=project,
|
|
264
|
+
env=environment,
|
|
265
|
+
inherit_environment=inherit_environment,
|
|
266
|
+
encoding=ProcessEncoding(driver="latin_1", errors="strict"),
|
|
267
|
+
timeout=ProcessTimeout(),
|
|
268
|
+
) as session:
|
|
269
|
+
yield Connection(session)
|
|
270
|
+
except ProcessError as error:
|
|
271
|
+
raise ToolCommandError("Cannot run clangd.") from error
|
|
272
|
+
|
|
273
|
+
methods: Methods = {
|
|
274
|
+
"version": version,
|
|
275
|
+
"connect": connect,
|
|
276
|
+
}
|
|
277
|
+
return ToolSpec(INFO.name, INFO.kind, path, methods)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
INFO = ToolInfo(name='clangd', kind=ToolKind.LANGUAGE_SERVER, create_spec=create_spec)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Typed methods for clang++."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TypedDict
|
|
7
|
+
|
|
8
|
+
from forgemcp.process.errors import ProcessError
|
|
9
|
+
from forgemcp.process.models import ProcessTimeout
|
|
10
|
+
from forgemcp.process.service import ProcessService
|
|
11
|
+
|
|
12
|
+
from ..errors import ToolCommandError, ToolParserError
|
|
13
|
+
from ..spec import ToolInfo, ToolKind, ToolSpec
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Methods(TypedDict):
|
|
17
|
+
version: Callable[[], Awaitable[str]]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def create_spec(
|
|
21
|
+
path: Path,
|
|
22
|
+
processes: ProcessService,
|
|
23
|
+
environment: Mapping[str, str] | None = None,
|
|
24
|
+
inherit_environment: bool = True,
|
|
25
|
+
) -> ToolSpec:
|
|
26
|
+
path = path.resolve()
|
|
27
|
+
|
|
28
|
+
async def version() -> str:
|
|
29
|
+
output = {"stdout": "", "stderr": ""}
|
|
30
|
+
try:
|
|
31
|
+
async with await processes.launch(
|
|
32
|
+
path,
|
|
33
|
+
('--version',),
|
|
34
|
+
env=environment,
|
|
35
|
+
inherit_environment=inherit_environment,
|
|
36
|
+
timeout=ProcessTimeout(total=15),
|
|
37
|
+
) as session:
|
|
38
|
+
await session.close_stdin()
|
|
39
|
+
async for chunk in session.output():
|
|
40
|
+
# Only the short version banner is needed; drain the rest.
|
|
41
|
+
remaining = 4096 - len(output[chunk.stream])
|
|
42
|
+
output[chunk.stream] += chunk.text[:remaining]
|
|
43
|
+
code = await session.wait()
|
|
44
|
+
if code != 0:
|
|
45
|
+
raise ToolCommandError(f"{INFO.name}.version failed (exit {code}).")
|
|
46
|
+
except ProcessError as error:
|
|
47
|
+
raise ToolCommandError(f"Cannot read {INFO.name} version.") from error
|
|
48
|
+
for text in output.values():
|
|
49
|
+
if match := re.search(
|
|
50
|
+
'clang version ([0-9][^\\s]*)',
|
|
51
|
+
text,
|
|
52
|
+
re.IGNORECASE | re.MULTILINE,
|
|
53
|
+
):
|
|
54
|
+
return match.group(1)
|
|
55
|
+
raise ToolParserError(f"Cannot parse {INFO.name} version.")
|
|
56
|
+
|
|
57
|
+
methods: Methods = {"version": version}
|
|
58
|
+
return ToolSpec(INFO.name, INFO.kind, path, methods)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
INFO = ToolInfo(name='clang++', kind=ToolKind.COMPILER, create_spec=create_spec)
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""Typed methods for cmake."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Protocol, TypedDict
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
from forgemcp.process.errors import ProcessError
|
|
11
|
+
from forgemcp.process.models import ProcessTimeout
|
|
12
|
+
from forgemcp.process.service import ProcessService
|
|
13
|
+
|
|
14
|
+
from ..errors import ToolCommandError, ToolParserError
|
|
15
|
+
from ..spec import ToolInfo, ToolKind, ToolSpec
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
type Progress = Callable[[str], Awaitable[None]]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PresetsResult(BaseModel):
|
|
22
|
+
configure: list[str] = Field(default_factory=list)
|
|
23
|
+
build: list[str] = Field(default_factory=list)
|
|
24
|
+
test: list[str] = Field(default_factory=list)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ConfigureResult(BaseModel):
|
|
28
|
+
return_code: int = 0
|
|
29
|
+
process_id: int | None = None
|
|
30
|
+
configured: bool = False
|
|
31
|
+
generated: bool = False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class BuildResult(BaseModel):
|
|
35
|
+
return_code: int = 0
|
|
36
|
+
process_id: int | None = None
|
|
37
|
+
completed_steps: int | None = None
|
|
38
|
+
total_steps: int | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Presets(Protocol):
|
|
42
|
+
async def __call__(self, source: Path) -> PresetsResult: ...
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Configure(Protocol):
|
|
46
|
+
async def __call__(
|
|
47
|
+
self,
|
|
48
|
+
source: Path,
|
|
49
|
+
build_directory: Path | None,
|
|
50
|
+
*,
|
|
51
|
+
preset: str | None = None,
|
|
52
|
+
generator: str | None = None,
|
|
53
|
+
definitions: Mapping[str, str] | None = None,
|
|
54
|
+
timeout: ProcessTimeout = ProcessTimeout(total=600),
|
|
55
|
+
on_progress: Progress | None = None,
|
|
56
|
+
) -> ConfigureResult: ...
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Build(Protocol):
|
|
60
|
+
async def __call__(
|
|
61
|
+
self,
|
|
62
|
+
source: Path,
|
|
63
|
+
build_directory: Path | None,
|
|
64
|
+
*,
|
|
65
|
+
preset: str | None = None,
|
|
66
|
+
configuration: str | None = None,
|
|
67
|
+
targets: Sequence[str] | None = None,
|
|
68
|
+
parallel: int | None = None,
|
|
69
|
+
timeout: ProcessTimeout = ProcessTimeout(total=600),
|
|
70
|
+
on_progress: Progress | None = None,
|
|
71
|
+
) -> BuildResult: ...
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Methods(TypedDict):
|
|
75
|
+
version: Callable[[], Awaitable[str]]
|
|
76
|
+
presets: Presets
|
|
77
|
+
configure: Configure
|
|
78
|
+
build: Build
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def parse_cache(text: str) -> dict[str, str]:
|
|
82
|
+
entries = {}
|
|
83
|
+
for line in text.splitlines():
|
|
84
|
+
if line.startswith(("//", "#")):
|
|
85
|
+
continue
|
|
86
|
+
key_type, separator, value = line.partition("=")
|
|
87
|
+
key, colon, _ = key_type.partition(":")
|
|
88
|
+
if separator and colon:
|
|
89
|
+
entries[key] = value
|
|
90
|
+
return entries
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def create_spec(
|
|
94
|
+
path: Path,
|
|
95
|
+
processes: ProcessService,
|
|
96
|
+
environment: Mapping[str, str] | None = None,
|
|
97
|
+
inherit_environment: bool = True,
|
|
98
|
+
) -> ToolSpec:
|
|
99
|
+
path = path.resolve()
|
|
100
|
+
|
|
101
|
+
async def version() -> str:
|
|
102
|
+
output = {"stdout": "", "stderr": ""}
|
|
103
|
+
try:
|
|
104
|
+
async with await processes.launch(
|
|
105
|
+
path,
|
|
106
|
+
('--version',),
|
|
107
|
+
env=environment,
|
|
108
|
+
inherit_environment=inherit_environment,
|
|
109
|
+
timeout=ProcessTimeout(total=15),
|
|
110
|
+
) as session:
|
|
111
|
+
await session.close_stdin()
|
|
112
|
+
async for chunk in session.output():
|
|
113
|
+
# Only the short version banner is needed; drain the rest.
|
|
114
|
+
remaining = 4096 - len(output[chunk.stream])
|
|
115
|
+
output[chunk.stream] += chunk.text[:remaining]
|
|
116
|
+
code = await session.wait()
|
|
117
|
+
if code != 0:
|
|
118
|
+
raise ToolCommandError(f"{INFO.name}.version failed (exit {code}).")
|
|
119
|
+
except ProcessError as error:
|
|
120
|
+
raise ToolCommandError(f"Cannot read {INFO.name} version.") from error
|
|
121
|
+
for text in output.values():
|
|
122
|
+
if match := re.search(
|
|
123
|
+
'cmake version ([0-9][0-9A-Za-z.+~-]*)',
|
|
124
|
+
text,
|
|
125
|
+
re.IGNORECASE | re.MULTILINE,
|
|
126
|
+
):
|
|
127
|
+
return match.group(1)
|
|
128
|
+
raise ToolParserError(f"Cannot parse {INFO.name} version.")
|
|
129
|
+
|
|
130
|
+
async def execute(
|
|
131
|
+
source: Path,
|
|
132
|
+
arguments: Sequence[str],
|
|
133
|
+
timeout: ProcessTimeout,
|
|
134
|
+
on_line: Progress,
|
|
135
|
+
) -> tuple[int, int]:
|
|
136
|
+
buffers = {"stdout": "", "stderr": ""}
|
|
137
|
+
session = None
|
|
138
|
+
try:
|
|
139
|
+
async with await processes.launch(
|
|
140
|
+
path,
|
|
141
|
+
arguments,
|
|
142
|
+
cwd=source,
|
|
143
|
+
env=environment,
|
|
144
|
+
inherit_environment=inherit_environment,
|
|
145
|
+
timeout=timeout,
|
|
146
|
+
) as session:
|
|
147
|
+
await session.close_stdin()
|
|
148
|
+
async for chunk in session.output():
|
|
149
|
+
buffers[chunk.stream] += chunk.text.replace("\r", "\n")
|
|
150
|
+
while "\n" in buffers[chunk.stream]:
|
|
151
|
+
line, _, buffers[chunk.stream] = buffers[chunk.stream].partition("\n")
|
|
152
|
+
if line.strip():
|
|
153
|
+
await on_line(line)
|
|
154
|
+
for line in buffers.values():
|
|
155
|
+
if line.strip():
|
|
156
|
+
await on_line(line)
|
|
157
|
+
return await session.wait(), session.process_id
|
|
158
|
+
except ProcessError as error:
|
|
159
|
+
raise ToolCommandError(
|
|
160
|
+
str(error),
|
|
161
|
+
process_id=session.process_id if session is not None else None,
|
|
162
|
+
) from error
|
|
163
|
+
|
|
164
|
+
async def presets(source: Path) -> PresetsResult:
|
|
165
|
+
result = PresetsResult()
|
|
166
|
+
kind = None
|
|
167
|
+
|
|
168
|
+
async def parse(line: str) -> None:
|
|
169
|
+
nonlocal kind
|
|
170
|
+
heading = re.fullmatch(r"Available (\w+) presets:", line.strip())
|
|
171
|
+
if heading:
|
|
172
|
+
kind = heading.group(1)
|
|
173
|
+
elif kind in ("configure", "build", "test"):
|
|
174
|
+
match = re.fullmatch(r'\s+"(.*?)"(?:\s+-.*)?', line)
|
|
175
|
+
if match is not None:
|
|
176
|
+
getattr(result, kind).append(match.group(1))
|
|
177
|
+
|
|
178
|
+
code, process_id = await execute(
|
|
179
|
+
source,
|
|
180
|
+
("--list-presets=all",),
|
|
181
|
+
ProcessTimeout(total=30),
|
|
182
|
+
parse,
|
|
183
|
+
)
|
|
184
|
+
if code != 0:
|
|
185
|
+
raise ToolCommandError(f"CMake could not list presets (exit {code}, process {process_id}).")
|
|
186
|
+
return result
|
|
187
|
+
|
|
188
|
+
async def configure(
|
|
189
|
+
source: Path,
|
|
190
|
+
build_directory: Path | None,
|
|
191
|
+
*,
|
|
192
|
+
preset: str | None = None,
|
|
193
|
+
generator: str | None = None,
|
|
194
|
+
definitions: Mapping[str, str] | None = None,
|
|
195
|
+
timeout: ProcessTimeout = ProcessTimeout(total=600),
|
|
196
|
+
on_progress: Progress | None = None,
|
|
197
|
+
) -> ConfigureResult:
|
|
198
|
+
arguments = ["--preset", preset] if preset is not None else ["-S", str(source)]
|
|
199
|
+
if preset is None and build_directory is None:
|
|
200
|
+
raise ToolCommandError("A plain configure requires a build directory.")
|
|
201
|
+
if build_directory is not None:
|
|
202
|
+
arguments.extend(("-B", str(build_directory)))
|
|
203
|
+
if generator is not None:
|
|
204
|
+
arguments.extend(("-G", generator))
|
|
205
|
+
for key, value in (definitions or {}).items():
|
|
206
|
+
arguments.append(f"-D{key}={value}")
|
|
207
|
+
result = ConfigureResult()
|
|
208
|
+
|
|
209
|
+
async def parse(line: str) -> None:
|
|
210
|
+
if line.startswith("-- Configuring done"):
|
|
211
|
+
result.configured = True
|
|
212
|
+
if line.startswith("-- Generating done"):
|
|
213
|
+
result.generated = True
|
|
214
|
+
if on_progress is not None and line.startswith(("-- ", "CMake Error", "CMake Warning")):
|
|
215
|
+
await on_progress(line.removeprefix("-- "))
|
|
216
|
+
|
|
217
|
+
result.return_code, result.process_id = await execute(
|
|
218
|
+
source,
|
|
219
|
+
arguments,
|
|
220
|
+
timeout,
|
|
221
|
+
parse,
|
|
222
|
+
)
|
|
223
|
+
return result
|
|
224
|
+
|
|
225
|
+
async def build(
|
|
226
|
+
source: Path,
|
|
227
|
+
build_directory: Path | None,
|
|
228
|
+
*,
|
|
229
|
+
preset: str | None = None,
|
|
230
|
+
configuration: str | None = None,
|
|
231
|
+
targets: Sequence[str] | None = None,
|
|
232
|
+
parallel: int | None = None,
|
|
233
|
+
timeout: ProcessTimeout = ProcessTimeout(total=600),
|
|
234
|
+
on_progress: Progress | None = None,
|
|
235
|
+
) -> BuildResult:
|
|
236
|
+
arguments = ["--build"]
|
|
237
|
+
if preset is None:
|
|
238
|
+
if build_directory is None:
|
|
239
|
+
raise ToolCommandError("A plain build requires a build directory.")
|
|
240
|
+
arguments.append(str(build_directory))
|
|
241
|
+
else:
|
|
242
|
+
arguments.extend(("--preset", preset))
|
|
243
|
+
if configuration is not None:
|
|
244
|
+
arguments.extend(("--config", configuration))
|
|
245
|
+
if targets is not None:
|
|
246
|
+
arguments.extend(("--target", *targets))
|
|
247
|
+
if parallel is not None:
|
|
248
|
+
arguments.extend(("--parallel", str(parallel)))
|
|
249
|
+
result = BuildResult()
|
|
250
|
+
|
|
251
|
+
async def parse(line: str) -> None:
|
|
252
|
+
step = re.match(r"\[(\d+)/(\d+)\]\s+", line)
|
|
253
|
+
if step:
|
|
254
|
+
result.completed_steps = int(step.group(1))
|
|
255
|
+
result.total_steps = int(step.group(2))
|
|
256
|
+
if on_progress is not None and (
|
|
257
|
+
step
|
|
258
|
+
or re.match(r"\[\s*\d+%\]", line)
|
|
259
|
+
or line.startswith(("FAILED:", "ninja:", "MSBuild version"))
|
|
260
|
+
):
|
|
261
|
+
await on_progress(line)
|
|
262
|
+
|
|
263
|
+
result.return_code, result.process_id = await execute(
|
|
264
|
+
source,
|
|
265
|
+
arguments,
|
|
266
|
+
timeout,
|
|
267
|
+
parse,
|
|
268
|
+
)
|
|
269
|
+
return result
|
|
270
|
+
|
|
271
|
+
methods: Methods = {
|
|
272
|
+
"version": version,
|
|
273
|
+
"presets": presets,
|
|
274
|
+
"configure": configure,
|
|
275
|
+
"build": build,
|
|
276
|
+
}
|
|
277
|
+
return ToolSpec(INFO.name, INFO.kind, path, methods)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
INFO = ToolInfo(name='cmake', kind=ToolKind.BUILD_SYSTEM, create_spec=create_spec)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Typed methods for cppvsdbg."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import TypedDict
|
|
6
|
+
|
|
7
|
+
from forgemcp.process.service import ProcessService
|
|
8
|
+
|
|
9
|
+
from ..spec import ToolInfo, ToolKind, ToolSpec
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Methods(TypedDict):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def create_spec(
|
|
17
|
+
path: Path,
|
|
18
|
+
processes: ProcessService,
|
|
19
|
+
environment: Mapping[str, str] | None = None,
|
|
20
|
+
inherit_environment: bool = True,
|
|
21
|
+
) -> ToolSpec:
|
|
22
|
+
path = path.resolve()
|
|
23
|
+
methods: Methods = {}
|
|
24
|
+
return ToolSpec(INFO.name, INFO.kind, path, methods)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
INFO = ToolInfo(name='cppvsdbg', kind=ToolKind.DEBUGGER, create_spec=create_spec)
|