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,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)