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,895 @@
|
|
|
1
|
+
"""CMake profile selection, build-tree ownership, and MCP operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import tempfile
|
|
9
|
+
from collections.abc import AsyncGenerator, Sequence
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Annotated, Literal, cast
|
|
12
|
+
from urllib.parse import quote
|
|
13
|
+
|
|
14
|
+
from mcp.server import MCPServer
|
|
15
|
+
from mcp.server.apps import Apps
|
|
16
|
+
from mcp.server.mcpserver import Context
|
|
17
|
+
from mcp.server.mcpserver.exceptions import ToolError
|
|
18
|
+
from mcp.types import ToolAnnotations
|
|
19
|
+
from pydantic import BaseModel, Field
|
|
20
|
+
|
|
21
|
+
from forgemcp.assets import IconFile, Widget
|
|
22
|
+
from forgemcp.completion import Complete
|
|
23
|
+
from forgemcp.process.errors import ProcessError
|
|
24
|
+
from forgemcp.process.models import ProcessTimeout
|
|
25
|
+
from forgemcp.progress import progress
|
|
26
|
+
from forgemcp.toolchain.errors import ToolchainError
|
|
27
|
+
from forgemcp.toolchain.service import ToolchainService
|
|
28
|
+
from forgemcp.toolchain.spec import Toolset
|
|
29
|
+
from forgemcp.toolchain.tools import cmake, ctest
|
|
30
|
+
from forgemcp.workspace.path import WorkspacePath
|
|
31
|
+
|
|
32
|
+
from .errors import CMakeError
|
|
33
|
+
from .profiles import ProfileDefinition
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class CMakeProfile(BaseModel):
|
|
37
|
+
name: str
|
|
38
|
+
toolset_id: str
|
|
39
|
+
mode: Literal["plain", "presets"] = "plain"
|
|
40
|
+
build_directory: WorkspacePath | None = None
|
|
41
|
+
configure_presets: list[str] = Field(default_factory=list)
|
|
42
|
+
build_presets: list[str] = Field(default_factory=list)
|
|
43
|
+
test_presets: list[str] = Field(default_factory=list)
|
|
44
|
+
configuration: str | None = None
|
|
45
|
+
generator: str | None = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class CompilationContext(BaseModel):
|
|
49
|
+
id: str
|
|
50
|
+
toolset_id: str
|
|
51
|
+
build_directory: WorkspacePath
|
|
52
|
+
compilation_database: WorkspacePath
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class CMakeConfigureResult(BaseModel):
|
|
56
|
+
profile: str
|
|
57
|
+
preset: str | None = None
|
|
58
|
+
build_directory: WorkspacePath | None = None
|
|
59
|
+
compilation_database: WorkspacePath | None = None
|
|
60
|
+
error: str | None = Field(
|
|
61
|
+
default=None,
|
|
62
|
+
description="Null on success; failure explanation otherwise.",
|
|
63
|
+
)
|
|
64
|
+
process_id: int | None = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class CMakeBuildResult(BaseModel):
|
|
68
|
+
profile: str
|
|
69
|
+
preset: str | None = None
|
|
70
|
+
configuration: str | None = None
|
|
71
|
+
completed_steps: int | None = None
|
|
72
|
+
total_steps: int | None = None
|
|
73
|
+
error: str | None = Field(
|
|
74
|
+
default=None,
|
|
75
|
+
description="Null on success; failure explanation otherwise.",
|
|
76
|
+
)
|
|
77
|
+
process_id: int | None = None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class CMakeTestResult(BaseModel):
|
|
81
|
+
profile: str
|
|
82
|
+
preset: str | None = None
|
|
83
|
+
configuration: str | None = None
|
|
84
|
+
tests: list[ctest.TestCase] = Field(default_factory=list)
|
|
85
|
+
error: str | None = Field(
|
|
86
|
+
default=None,
|
|
87
|
+
description="Null on success; failure explanation otherwise.",
|
|
88
|
+
)
|
|
89
|
+
process_id: int | None = None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class CMakeService:
|
|
93
|
+
"""CMake: configuration, builds, and CTest runs through named operator profiles.
|
|
94
|
+
|
|
95
|
+
Each profile binds a toolset to manual settings (configuration, generator,
|
|
96
|
+
compilers, build directory, cache definitions) or native presets.
|
|
97
|
+
Configure/build/test presets are selected independently; no associations are
|
|
98
|
+
inferred. Profile settings belong to the operator, not tool arguments.
|
|
99
|
+
|
|
100
|
+
When the operator supplies no profiles, preset files yield one presets profile
|
|
101
|
+
running all available presets; without preset files, Debug and Release use
|
|
102
|
+
separate storage/build/cmake-<name> directories.
|
|
103
|
+
Omitting the profiles filter runs all profiles; a nonempty list selects a subset.
|
|
104
|
+
Configure before building; build before testing. Diagnose with a subset,
|
|
105
|
+
then verify all profiles. Each execution reports error (null means success)
|
|
106
|
+
and, when launched, process_id for its command log.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
ICON = IconFile("icons/cmake.svg")
|
|
110
|
+
PROFILES_WIDGET = Widget("assets/cmake-profiles.html")
|
|
111
|
+
CONFIGURE_WIDGET = Widget("assets/cmake-configure.html")
|
|
112
|
+
BUILD_WIDGET = Widget("assets/cmake-build.html")
|
|
113
|
+
TEST_WIDGET = Widget("assets/cmake-test.html")
|
|
114
|
+
|
|
115
|
+
def __init__(
|
|
116
|
+
self,
|
|
117
|
+
toolchains: ToolchainService,
|
|
118
|
+
profiles: tuple[ProfileDefinition, ...] = (),
|
|
119
|
+
*,
|
|
120
|
+
project_root: Path,
|
|
121
|
+
storage_root: Path,
|
|
122
|
+
protected_paths: set[Path],
|
|
123
|
+
default_toolset: str = "system",
|
|
124
|
+
progress_interval: float = 1.0,
|
|
125
|
+
) -> None:
|
|
126
|
+
self.project_root = project_root.resolve()
|
|
127
|
+
self.storage_root = storage_root.resolve()
|
|
128
|
+
self.protected_paths = protected_paths
|
|
129
|
+
self.toolchains = toolchains
|
|
130
|
+
self.definitions = profiles
|
|
131
|
+
self.default_toolset = default_toolset
|
|
132
|
+
self.progress_interval = progress_interval
|
|
133
|
+
self.configurations: dict[str, CompilationContext] = {}
|
|
134
|
+
self.configurations_loaded = False
|
|
135
|
+
self.configuration_subscribers: dict[
|
|
136
|
+
asyncio.Queue[list[CompilationContext]], tuple[str, ...]
|
|
137
|
+
] = {}
|
|
138
|
+
self.configuration_lock = asyncio.Lock()
|
|
139
|
+
|
|
140
|
+
def toolset(self, selector: str) -> Toolset:
|
|
141
|
+
matches = [
|
|
142
|
+
item
|
|
143
|
+
for item in self.toolchains.list_toolsets()
|
|
144
|
+
if selector in (item.id, item.name)
|
|
145
|
+
]
|
|
146
|
+
if len(matches) != 1:
|
|
147
|
+
raise CMakeError(f"Expected one toolset matching {selector!r}.")
|
|
148
|
+
return matches[0]
|
|
149
|
+
|
|
150
|
+
def cmake_methods(self, toolset_id: str) -> cmake.Methods:
|
|
151
|
+
tool = self.toolchains.get_tool(toolset_id, "cmake")
|
|
152
|
+
if tool is None:
|
|
153
|
+
raise CMakeError(f"Toolset {toolset_id!r} has no cmake executable.")
|
|
154
|
+
return cast(cmake.Methods, tool.methods)
|
|
155
|
+
|
|
156
|
+
def ctest_methods(self, toolset_id: str) -> ctest.Methods:
|
|
157
|
+
tool = self.toolchains.get_tool(toolset_id, "ctest")
|
|
158
|
+
if tool is None:
|
|
159
|
+
raise CMakeError(f"Toolset {toolset_id!r} has no ctest executable.")
|
|
160
|
+
return cast(ctest.Methods, tool.methods)
|
|
161
|
+
|
|
162
|
+
async def available_presets(self, toolset_id: str) -> cmake.PresetsResult:
|
|
163
|
+
methods = self.cmake_methods(toolset_id)
|
|
164
|
+
return await methods["presets"](self.project_root)
|
|
165
|
+
|
|
166
|
+
async def selection(
|
|
167
|
+
self,
|
|
168
|
+
names: list[str] | None,
|
|
169
|
+
) -> tuple[list[ProfileDefinition], dict[str, cmake.PresetsResult | CMakeError]]:
|
|
170
|
+
catalogs = {}
|
|
171
|
+
if self.definitions:
|
|
172
|
+
definitions = list(self.definitions)
|
|
173
|
+
elif any(
|
|
174
|
+
(self.project_root / filename).is_file()
|
|
175
|
+
for filename in ("CMakePresets.json", "CMakeUserPresets.json")
|
|
176
|
+
):
|
|
177
|
+
toolset = self.toolset(self.default_toolset)
|
|
178
|
+
available = await self.available_presets(toolset.id)
|
|
179
|
+
catalogs[toolset.id] = available
|
|
180
|
+
definitions = [
|
|
181
|
+
ProfileDefinition(
|
|
182
|
+
name="presets",
|
|
183
|
+
toolset=toolset.id,
|
|
184
|
+
configure_presets=available.configure,
|
|
185
|
+
build_presets=available.build,
|
|
186
|
+
test_presets=available.test,
|
|
187
|
+
)
|
|
188
|
+
]
|
|
189
|
+
else:
|
|
190
|
+
definitions = [
|
|
191
|
+
ProfileDefinition(
|
|
192
|
+
name=configuration,
|
|
193
|
+
toolset=self.default_toolset,
|
|
194
|
+
configuration=configuration,
|
|
195
|
+
)
|
|
196
|
+
for configuration in ("Debug", "Release")
|
|
197
|
+
]
|
|
198
|
+
if names is not None:
|
|
199
|
+
if not names or len(names) != len(set(names)):
|
|
200
|
+
raise CMakeError("Profiles must be a nonempty list without duplicates.")
|
|
201
|
+
unknown = set(names) - {definition.name for definition in definitions}
|
|
202
|
+
if unknown:
|
|
203
|
+
raise CMakeError(f"Unknown CMake profiles: {', '.join(sorted(unknown))}.")
|
|
204
|
+
definitions = [definition for definition in definitions if definition.name in names]
|
|
205
|
+
for definition in definitions:
|
|
206
|
+
try:
|
|
207
|
+
toolset = self.toolset(definition.toolset)
|
|
208
|
+
except CMakeError:
|
|
209
|
+
# Report a bad toolset on its own profile without stopping the others.
|
|
210
|
+
continue
|
|
211
|
+
if definition.uses_presets and toolset.id not in catalogs:
|
|
212
|
+
try:
|
|
213
|
+
catalogs[toolset.id] = await self.available_presets(toolset.id)
|
|
214
|
+
except (CMakeError, ToolchainError, ProcessError) as error:
|
|
215
|
+
catalogs[toolset.id] = CMakeError(str(error))
|
|
216
|
+
return definitions, catalogs
|
|
217
|
+
|
|
218
|
+
def resolve_profile(
|
|
219
|
+
self,
|
|
220
|
+
definition: ProfileDefinition,
|
|
221
|
+
catalogs: dict[str, cmake.PresetsResult | CMakeError],
|
|
222
|
+
) -> CMakeProfile:
|
|
223
|
+
if not definition.uses_presets:
|
|
224
|
+
return self.plain_profile(definition)
|
|
225
|
+
toolset = self.toolset(definition.toolset)
|
|
226
|
+
available = catalogs[toolset.id]
|
|
227
|
+
if isinstance(available, CMakeError):
|
|
228
|
+
raise available
|
|
229
|
+
for kind in ("configure", "build", "test"):
|
|
230
|
+
chosen = getattr(definition, kind + "_presets") or []
|
|
231
|
+
if len(chosen) != len(set(chosen)):
|
|
232
|
+
raise CMakeError(f"Repeated {kind} preset in profile {definition.name!r}.")
|
|
233
|
+
unknown = set(chosen) - set(getattr(available, kind))
|
|
234
|
+
if unknown:
|
|
235
|
+
raise CMakeError(
|
|
236
|
+
f"Unavailable {kind} presets for {toolset.name}: {', '.join(sorted(unknown))}."
|
|
237
|
+
)
|
|
238
|
+
if definition.build_directory is not None:
|
|
239
|
+
self.build_path(definition.build_directory)
|
|
240
|
+
return CMakeProfile(
|
|
241
|
+
name=definition.name,
|
|
242
|
+
toolset_id=toolset.id,
|
|
243
|
+
mode="presets",
|
|
244
|
+
build_directory=definition.build_directory,
|
|
245
|
+
configure_presets=definition.configure_presets or [],
|
|
246
|
+
build_presets=definition.build_presets or [],
|
|
247
|
+
test_presets=definition.test_presets or [],
|
|
248
|
+
configuration=definition.configuration,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
def operation_presets(self, profile: CMakeProfile, operation: str) -> list[str | None]:
|
|
252
|
+
if profile.mode == "plain":
|
|
253
|
+
return [None]
|
|
254
|
+
selected = getattr(profile, operation + "_presets")
|
|
255
|
+
if selected:
|
|
256
|
+
return selected
|
|
257
|
+
if operation != "configure" and profile.build_directory is not None:
|
|
258
|
+
return [None]
|
|
259
|
+
raise CMakeError(
|
|
260
|
+
f"Profile {profile.name!r} has no {operation} presets. "
|
|
261
|
+
"Select the required presets in --cmake-profile; for build/test without "
|
|
262
|
+
"presets, set build-directory to the existing preset build directory."
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
def plain_profile(self, definition: ProfileDefinition) -> CMakeProfile:
|
|
266
|
+
toolset = self.toolset(definition.toolset)
|
|
267
|
+
directory = definition.build_directory
|
|
268
|
+
if directory is None:
|
|
269
|
+
if (
|
|
270
|
+
not definition.name
|
|
271
|
+
or definition.name in (".", "..")
|
|
272
|
+
or any(char in definition.name for char in "/\\\0")
|
|
273
|
+
):
|
|
274
|
+
raise CMakeError("Directory key must be one nonempty directory name.")
|
|
275
|
+
directory = WorkspacePath(f"storage/build/cmake-{definition.name}")
|
|
276
|
+
self.build_path(directory)
|
|
277
|
+
return CMakeProfile(
|
|
278
|
+
name=definition.name,
|
|
279
|
+
toolset_id=toolset.id,
|
|
280
|
+
build_directory=directory,
|
|
281
|
+
configuration=definition.configuration,
|
|
282
|
+
generator=definition.generator or "Ninja",
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
def build_path(self, directory: WorkspacePath) -> Path:
|
|
286
|
+
if directory.area == "root":
|
|
287
|
+
raise CMakeError("Build directory must be inside the project or storage root.")
|
|
288
|
+
base = self.project_root if directory.area == "project" else self.storage_root
|
|
289
|
+
path = base / directory.relative
|
|
290
|
+
try:
|
|
291
|
+
if not path.resolve().is_relative_to(base):
|
|
292
|
+
raise CMakeError(f"Build path resolves outside its root: {directory}.")
|
|
293
|
+
if path in (self.project_root, self.storage_root):
|
|
294
|
+
raise CMakeError("Cannot use a workspace root as a build directory.")
|
|
295
|
+
if os.name == "nt" and any(
|
|
296
|
+
os.path.isreserved(part) for part in Path(directory.relative).parts
|
|
297
|
+
):
|
|
298
|
+
raise CMakeError(f"Build path contains a reserved name: {directory}.")
|
|
299
|
+
current = path
|
|
300
|
+
while True:
|
|
301
|
+
if current.is_symlink() or current.is_junction():
|
|
302
|
+
raise CMakeError("Build path cannot traverse symbolic links or junctions.")
|
|
303
|
+
if current == base:
|
|
304
|
+
break
|
|
305
|
+
current = current.parent
|
|
306
|
+
if any(path.is_relative_to(protected) for protected in self.protected_paths):
|
|
307
|
+
raise CMakeError(f"Build path is protected: {directory}.")
|
|
308
|
+
except OSError as error:
|
|
309
|
+
raise CMakeError(f"Cannot inspect build path {directory}.") from error
|
|
310
|
+
return path
|
|
311
|
+
|
|
312
|
+
def cache(self, directory: WorkspacePath) -> dict[str, str]:
|
|
313
|
+
path = self.build_path(directory) / "CMakeCache.txt"
|
|
314
|
+
base = self.project_root if directory.area == "project" else self.storage_root
|
|
315
|
+
try:
|
|
316
|
+
if not path.resolve().is_relative_to(base):
|
|
317
|
+
raise CMakeError("CMake cache resolves outside the build root.")
|
|
318
|
+
text = path.read_text(encoding="utf-8")
|
|
319
|
+
except (OSError, UnicodeError) as error:
|
|
320
|
+
raise CMakeError(f"Cannot read CMake cache in {directory}.") from error
|
|
321
|
+
if "\0" in text:
|
|
322
|
+
raise CMakeError(f"CMake cache in {directory} is not a text file.")
|
|
323
|
+
return cmake.parse_cache(text)
|
|
324
|
+
|
|
325
|
+
def require_configured(self, profile: CMakeProfile) -> dict[str, str]:
|
|
326
|
+
if profile.build_directory is None:
|
|
327
|
+
raise CMakeError("Set build-directory for operations without a preset.")
|
|
328
|
+
try:
|
|
329
|
+
cache = self.cache(profile.build_directory)
|
|
330
|
+
except CMakeError as error:
|
|
331
|
+
raise CMakeError(f"Configure profile {profile.name!r} first.") from error
|
|
332
|
+
source = cache.get("CMAKE_HOME_DIRECTORY")
|
|
333
|
+
if source is None or Path(source).resolve() != self.project_root:
|
|
334
|
+
raise CMakeError("Build directory belongs to another source project.")
|
|
335
|
+
return cache
|
|
336
|
+
|
|
337
|
+
def require_configuration(
|
|
338
|
+
self,
|
|
339
|
+
cache: dict[str, str],
|
|
340
|
+
configuration: str | None,
|
|
341
|
+
) -> None:
|
|
342
|
+
choices = cache.get("CMAKE_CONFIGURATION_TYPES", "").split(";")
|
|
343
|
+
if choices != [""] and configuration not in choices:
|
|
344
|
+
raise CMakeError(
|
|
345
|
+
"Select an available multi-config configuration in the operator "
|
|
346
|
+
f"profile or preset: {', '.join(choices)}."
|
|
347
|
+
)
|
|
348
|
+
if (
|
|
349
|
+
choices == [""]
|
|
350
|
+
and configuration is not None
|
|
351
|
+
and cache.get("CMAKE_BUILD_TYPE") != configuration
|
|
352
|
+
):
|
|
353
|
+
raise CMakeError(
|
|
354
|
+
"The build directory has a different configuration; configure this profile first."
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
def configure_definitions(self, definition: ProfileDefinition) -> dict[str, str]:
|
|
358
|
+
result = dict(definition.definitions)
|
|
359
|
+
reserved = {"CMAKE_HOME_DIRECTORY", "CMAKE_CACHEFILE_DIR"}
|
|
360
|
+
if reserved.intersection(key.partition(":")[0] for key in result):
|
|
361
|
+
raise CMakeError("CMake source/build directories cannot be cache overrides.")
|
|
362
|
+
toolset = self.toolset(definition.toolset)
|
|
363
|
+
for field, variable in (
|
|
364
|
+
(definition.c_compiler, "CMAKE_C_COMPILER"),
|
|
365
|
+
(definition.cxx_compiler, "CMAKE_CXX_COMPILER"),
|
|
366
|
+
):
|
|
367
|
+
if field is not None:
|
|
368
|
+
tool = self.toolchains.get_tool(toolset.id, field)
|
|
369
|
+
if tool is None:
|
|
370
|
+
raise CMakeError(f"Toolset {toolset.name!r} has no {field!r}.")
|
|
371
|
+
if any(key.partition(":")[0] == variable for key in result):
|
|
372
|
+
raise CMakeError(f"Duplicate setting for {variable}.")
|
|
373
|
+
result[variable] = str(tool.path)
|
|
374
|
+
if definition.toolchain_file is not None:
|
|
375
|
+
if any(key.partition(":")[0] == "CMAKE_TOOLCHAIN_FILE" for key in result):
|
|
376
|
+
raise CMakeError("Duplicate setting for CMAKE_TOOLCHAIN_FILE.")
|
|
377
|
+
reference = definition.toolchain_file
|
|
378
|
+
if reference.area == "root":
|
|
379
|
+
raise CMakeError("Toolchain file must be inside the project or storage root.")
|
|
380
|
+
base = self.project_root if reference.area == "project" else self.storage_root
|
|
381
|
+
toolchain_file = (base / reference.relative).resolve()
|
|
382
|
+
if not toolchain_file.is_relative_to(base):
|
|
383
|
+
raise CMakeError(f"Toolchain file is outside its root: {reference}.")
|
|
384
|
+
if not toolchain_file.is_file():
|
|
385
|
+
raise CMakeError(f"Toolchain file does not exist: {reference}.")
|
|
386
|
+
result["CMAKE_TOOLCHAIN_FILE"] = str(toolchain_file)
|
|
387
|
+
if definition.configuration is not None:
|
|
388
|
+
configured = [
|
|
389
|
+
value
|
|
390
|
+
for key, value in result.items()
|
|
391
|
+
if key.partition(":")[0] == "CMAKE_BUILD_TYPE"
|
|
392
|
+
]
|
|
393
|
+
if configured and any(value != definition.configuration for value in configured):
|
|
394
|
+
raise CMakeError("configuration conflicts with CMAKE_BUILD_TYPE.")
|
|
395
|
+
if not configured:
|
|
396
|
+
result["CMAKE_BUILD_TYPE"] = definition.configuration
|
|
397
|
+
if not any(key.partition(":")[0] == "CMAKE_EXPORT_COMPILE_COMMANDS" for key in result):
|
|
398
|
+
result["CMAKE_EXPORT_COMPILE_COMMANDS"] = "ON"
|
|
399
|
+
if (definition.generator or "Ninja") in ("Ninja", "Ninja Multi-Config"):
|
|
400
|
+
ninja = self.toolchains.get_tool(toolset.id, "ninja")
|
|
401
|
+
if ninja is None:
|
|
402
|
+
raise CMakeError(f"Toolset {toolset.name!r} has no ninja executable.")
|
|
403
|
+
if not any(key.partition(":")[0] == "CMAKE_MAKE_PROGRAM" for key in result):
|
|
404
|
+
result["CMAKE_MAKE_PROGRAM"] = str(ninja.path)
|
|
405
|
+
return result
|
|
406
|
+
|
|
407
|
+
def prepare_file_api(self, directory: WorkspacePath) -> None:
|
|
408
|
+
build_directory = self.build_path(directory)
|
|
409
|
+
reference = WorkspacePath(f"{directory}/.cmake/api/v1/query/client-forgemcp")
|
|
410
|
+
query = self.build_path(reference)
|
|
411
|
+
try:
|
|
412
|
+
build_directory.mkdir(parents=True, exist_ok=True)
|
|
413
|
+
query.mkdir(parents=True, exist_ok=True)
|
|
414
|
+
for name in ("codemodel-v2", "cache-v2", "toolchains-v1"):
|
|
415
|
+
target = WorkspacePath(f"{reference}/{name}")
|
|
416
|
+
file = self.build_path(target)
|
|
417
|
+
file.write_text("", encoding="utf-8")
|
|
418
|
+
except OSError as error:
|
|
419
|
+
raise CMakeError(f"Cannot prepare CMake File API in {directory}.") from error
|
|
420
|
+
|
|
421
|
+
def compilation_database(self, directory: WorkspacePath) -> WorkspacePath | None:
|
|
422
|
+
path = self.build_path(directory) / "compile_commands.json"
|
|
423
|
+
return WorkspacePath(f"{directory}/compile_commands.json") if path.is_file() else None
|
|
424
|
+
|
|
425
|
+
async def compilation_contexts(
|
|
426
|
+
self,
|
|
427
|
+
profiles: Sequence[str] = (),
|
|
428
|
+
) -> list[CompilationContext]:
|
|
429
|
+
"""Return successful configurations with databases that still exist."""
|
|
430
|
+
if not self.configurations_loaded:
|
|
431
|
+
try:
|
|
432
|
+
definitions, catalogs = await self.selection(None)
|
|
433
|
+
except (CMakeError, ToolchainError, ProcessError):
|
|
434
|
+
return []
|
|
435
|
+
for definition in definitions:
|
|
436
|
+
try:
|
|
437
|
+
profile = self.resolve_profile(definition, catalogs)
|
|
438
|
+
presets = self.operation_presets(profile, "configure")
|
|
439
|
+
except (CMakeError, ToolchainError):
|
|
440
|
+
continue
|
|
441
|
+
for preset in presets:
|
|
442
|
+
directory = profile.build_directory
|
|
443
|
+
if directory is None or (preset is not None and len(presets) != 1):
|
|
444
|
+
continue
|
|
445
|
+
try:
|
|
446
|
+
database = self.compilation_database(directory)
|
|
447
|
+
except (CMakeError, OSError):
|
|
448
|
+
continue
|
|
449
|
+
if database is None:
|
|
450
|
+
continue
|
|
451
|
+
identifier = quote(profile.name, safe="")
|
|
452
|
+
if preset is not None:
|
|
453
|
+
identifier += "/" + quote(preset, safe="")
|
|
454
|
+
self.configurations[identifier] = CompilationContext(
|
|
455
|
+
id=identifier,
|
|
456
|
+
toolset_id=profile.toolset_id,
|
|
457
|
+
build_directory=directory,
|
|
458
|
+
compilation_database=database,
|
|
459
|
+
)
|
|
460
|
+
self.configurations_loaded = True
|
|
461
|
+
if profiles:
|
|
462
|
+
definitions, _ = await self.selection(list(profiles))
|
|
463
|
+
selected = {quote(definition.name, safe="") for definition in definitions}
|
|
464
|
+
else:
|
|
465
|
+
selected = None
|
|
466
|
+
for identifier, context in tuple(self.configurations.items()):
|
|
467
|
+
try:
|
|
468
|
+
available = self.compilation_database(context.build_directory)
|
|
469
|
+
except (CMakeError, OSError):
|
|
470
|
+
available = None
|
|
471
|
+
if available != context.compilation_database:
|
|
472
|
+
del self.configurations[identifier]
|
|
473
|
+
return [
|
|
474
|
+
context.model_copy(deep=True)
|
|
475
|
+
for context in self.configurations.values()
|
|
476
|
+
if selected is None or context.id.split("/", 1)[0] in selected
|
|
477
|
+
]
|
|
478
|
+
|
|
479
|
+
async def configuration_updates(self) -> AsyncGenerator[list[CompilationContext], None]:
|
|
480
|
+
"""Yield the current configurations, then their latest changed snapshot."""
|
|
481
|
+
queue: asyncio.Queue[list[CompilationContext]] = asyncio.Queue(maxsize=1)
|
|
482
|
+
async with self.configuration_lock:
|
|
483
|
+
current = await self.compilation_contexts()
|
|
484
|
+
self.configuration_subscribers[queue] = tuple(
|
|
485
|
+
item.model_dump_json() for item in current
|
|
486
|
+
)
|
|
487
|
+
queue.put_nowait(current)
|
|
488
|
+
try:
|
|
489
|
+
while True:
|
|
490
|
+
yield await queue.get()
|
|
491
|
+
finally:
|
|
492
|
+
async with self.configuration_lock:
|
|
493
|
+
self.configuration_subscribers.pop(queue, None)
|
|
494
|
+
|
|
495
|
+
async def publish_configurations(self, *, refresh: bool = False) -> None:
|
|
496
|
+
"""Publish changed snapshots, or refresh databases after successful configure."""
|
|
497
|
+
if not self.configuration_subscribers:
|
|
498
|
+
return
|
|
499
|
+
async with self.configuration_lock:
|
|
500
|
+
current = await self.compilation_contexts()
|
|
501
|
+
signature = tuple(item.model_dump_json() for item in current)
|
|
502
|
+
for queue, previous in tuple(self.configuration_subscribers.items()):
|
|
503
|
+
if signature == previous and not refresh:
|
|
504
|
+
continue
|
|
505
|
+
if queue.full():
|
|
506
|
+
queue.get_nowait()
|
|
507
|
+
snapshot = [item.model_copy(deep=True) for item in current]
|
|
508
|
+
queue.put_nowait(snapshot)
|
|
509
|
+
self.configuration_subscribers[queue] = signature
|
|
510
|
+
|
|
511
|
+
def register(self, mcp: MCPServer, apps: Apps, complete: Complete) -> None:
|
|
512
|
+
icon = self.ICON.icon
|
|
513
|
+
changes_files = ToolAnnotations(
|
|
514
|
+
read_only_hint=False,
|
|
515
|
+
destructive_hint=True,
|
|
516
|
+
idempotent_hint=False,
|
|
517
|
+
open_world_hint=True,
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
@apps.tool(
|
|
521
|
+
resource_uri=self.PROFILES_WIDGET.uri,
|
|
522
|
+
icons=[icon],
|
|
523
|
+
annotations=ToolAnnotations(read_only_hint=True),
|
|
524
|
+
)
|
|
525
|
+
async def cmake_profiles(ctx: Context) -> list[CMakeProfile]:
|
|
526
|
+
"""List effective profiles with their toolsets, build settings, and preset selections."""
|
|
527
|
+
report = progress(ctx, interval=self.progress_interval)
|
|
528
|
+
await report(0, message="Reading CMake profiles")
|
|
529
|
+
try:
|
|
530
|
+
definitions, catalogs = await self.selection(None)
|
|
531
|
+
profiles = []
|
|
532
|
+
for definition in definitions:
|
|
533
|
+
profile = self.resolve_profile(definition, catalogs)
|
|
534
|
+
profiles.append(profile)
|
|
535
|
+
await report(len(profiles), message=f"Read {profile.name}")
|
|
536
|
+
return profiles
|
|
537
|
+
except (CMakeError, ToolchainError, ProcessError) as error:
|
|
538
|
+
raise ToolError(str(error)) from error
|
|
539
|
+
|
|
540
|
+
@apps.tool(
|
|
541
|
+
resource_uri=self.CONFIGURE_WIDGET.uri,
|
|
542
|
+
icons=[icon],
|
|
543
|
+
annotations=changes_files,
|
|
544
|
+
)
|
|
545
|
+
async def cmake_configure(
|
|
546
|
+
ctx: Context,
|
|
547
|
+
profiles: list[str] | None = None,
|
|
548
|
+
timeout: ProcessTimeout = ProcessTimeout(total=600),
|
|
549
|
+
) -> list[CMakeConfigureResult]:
|
|
550
|
+
"""Configure selected profiles; return build/database paths and per-execution errors.
|
|
551
|
+
|
|
552
|
+
Changing a manual profile's generator removes and recreates its build directory.
|
|
553
|
+
"""
|
|
554
|
+
report = progress(ctx, interval=self.progress_interval)
|
|
555
|
+
await report(0, message="Preparing CMake configure")
|
|
556
|
+
try:
|
|
557
|
+
definitions, catalogs = await self.selection(profiles)
|
|
558
|
+
await self.compilation_contexts()
|
|
559
|
+
except (CMakeError, ToolchainError, ProcessError) as error:
|
|
560
|
+
raise ToolError(str(error)) from error
|
|
561
|
+
results: list[CMakeConfigureResult] = []
|
|
562
|
+
configured = {}
|
|
563
|
+
updates = 0
|
|
564
|
+
|
|
565
|
+
async def report_status(message: str) -> None:
|
|
566
|
+
nonlocal updates
|
|
567
|
+
updates += 1
|
|
568
|
+
label = profile.name if preset is None else f"{profile.name}/{preset}"
|
|
569
|
+
await report(updates, message=f"{label}: {message}")
|
|
570
|
+
|
|
571
|
+
for definition in definitions:
|
|
572
|
+
try:
|
|
573
|
+
profile = self.resolve_profile(definition, catalogs)
|
|
574
|
+
presets = self.operation_presets(profile, "configure")
|
|
575
|
+
methods = self.cmake_methods(profile.toolset_id)
|
|
576
|
+
except (CMakeError, ToolchainError) as error:
|
|
577
|
+
identifier = quote(definition.name, safe="")
|
|
578
|
+
for key in tuple(self.configurations):
|
|
579
|
+
if key == identifier or key.startswith(identifier + "/"):
|
|
580
|
+
del self.configurations[key]
|
|
581
|
+
await self.publish_configurations()
|
|
582
|
+
results.append(
|
|
583
|
+
CMakeConfigureResult(
|
|
584
|
+
profile=definition.name,
|
|
585
|
+
error=str(error),
|
|
586
|
+
)
|
|
587
|
+
)
|
|
588
|
+
continue
|
|
589
|
+
for preset in presets:
|
|
590
|
+
identifier = quote(profile.name, safe="")
|
|
591
|
+
if preset is not None:
|
|
592
|
+
identifier += "/" + quote(preset, safe="")
|
|
593
|
+
result = CMakeConfigureResult(
|
|
594
|
+
profile=profile.name,
|
|
595
|
+
preset=preset,
|
|
596
|
+
build_directory=profile.build_directory,
|
|
597
|
+
)
|
|
598
|
+
results.append(result)
|
|
599
|
+
try:
|
|
600
|
+
key = (profile.toolset_id, preset, profile.build_directory)
|
|
601
|
+
if preset is not None and key in configured:
|
|
602
|
+
prior = configured[key]
|
|
603
|
+
result.error = prior.error
|
|
604
|
+
result.process_id = prior.process_id
|
|
605
|
+
result.compilation_database = prior.compilation_database
|
|
606
|
+
if (
|
|
607
|
+
result.error is None
|
|
608
|
+
and result.build_directory is not None
|
|
609
|
+
and result.compilation_database is not None
|
|
610
|
+
and len(presets) == 1
|
|
611
|
+
):
|
|
612
|
+
self.configurations[identifier] = CompilationContext(
|
|
613
|
+
id=identifier,
|
|
614
|
+
toolset_id=profile.toolset_id,
|
|
615
|
+
build_directory=result.build_directory,
|
|
616
|
+
compilation_database=result.compilation_database,
|
|
617
|
+
)
|
|
618
|
+
else:
|
|
619
|
+
self.configurations.pop(identifier, None)
|
|
620
|
+
await self.publish_configurations()
|
|
621
|
+
continue
|
|
622
|
+
build_directory = (
|
|
623
|
+
self.build_path(profile.build_directory)
|
|
624
|
+
if profile.build_directory is not None else None
|
|
625
|
+
)
|
|
626
|
+
settings = {}
|
|
627
|
+
if preset is None:
|
|
628
|
+
if profile.build_directory is None:
|
|
629
|
+
raise CMakeError("Plain configure requires a build directory.")
|
|
630
|
+
settings = self.configure_definitions(definition)
|
|
631
|
+
build_directory = self.build_path(profile.build_directory)
|
|
632
|
+
if (build_directory / "CMakeCache.txt").exists():
|
|
633
|
+
cache = self.require_configured(profile)
|
|
634
|
+
if cache.get("CMAKE_GENERATOR") != profile.generator:
|
|
635
|
+
await report_status("Clearing build directory for generator change")
|
|
636
|
+
if any(
|
|
637
|
+
protected.is_relative_to(build_directory)
|
|
638
|
+
for protected in self.protected_paths
|
|
639
|
+
) or self.storage_root.is_relative_to(build_directory):
|
|
640
|
+
raise CMakeError("Build directory contains a protected path.")
|
|
641
|
+
if build_directory.exists():
|
|
642
|
+
def raise_walk_error(error: OSError) -> None:
|
|
643
|
+
raise error
|
|
644
|
+
|
|
645
|
+
try:
|
|
646
|
+
for parent, directories, files in os.walk(
|
|
647
|
+
build_directory,
|
|
648
|
+
followlinks=False,
|
|
649
|
+
onerror=raise_walk_error,
|
|
650
|
+
):
|
|
651
|
+
for name in (*directories, *files):
|
|
652
|
+
item = Path(parent) / name
|
|
653
|
+
if item.is_symlink() or item.is_junction():
|
|
654
|
+
raise CMakeError(
|
|
655
|
+
"Build directory contains a link or junction."
|
|
656
|
+
)
|
|
657
|
+
base = (
|
|
658
|
+
self.project_root
|
|
659
|
+
if profile.build_directory.area == "project"
|
|
660
|
+
else self.storage_root
|
|
661
|
+
)
|
|
662
|
+
if not build_directory.resolve().is_relative_to(base):
|
|
663
|
+
raise CMakeError("Build directory escaped its root.")
|
|
664
|
+
shutil.rmtree(build_directory)
|
|
665
|
+
await self.publish_configurations()
|
|
666
|
+
except OSError as error:
|
|
667
|
+
raise CMakeError("Cannot clear build directory.") from error
|
|
668
|
+
self.prepare_file_api(profile.build_directory)
|
|
669
|
+
await report_status("Starting configure")
|
|
670
|
+
command = await methods["configure"](
|
|
671
|
+
self.project_root,
|
|
672
|
+
build_directory,
|
|
673
|
+
preset=preset,
|
|
674
|
+
generator=profile.generator,
|
|
675
|
+
definitions=settings,
|
|
676
|
+
timeout=timeout,
|
|
677
|
+
on_progress=report_status,
|
|
678
|
+
)
|
|
679
|
+
result.process_id = command.process_id
|
|
680
|
+
if command.return_code != 0:
|
|
681
|
+
result.error = f"CMake configure exited with {command.return_code}."
|
|
682
|
+
if command.return_code == 0 and result.build_directory is not None:
|
|
683
|
+
result.compilation_database = self.compilation_database(result.build_directory)
|
|
684
|
+
if preset is not None:
|
|
685
|
+
configured[key] = result
|
|
686
|
+
except (CMakeError, ToolchainError, ProcessError) as error:
|
|
687
|
+
result.error = str(error)
|
|
688
|
+
result.process_id = getattr(error, "process_id", result.process_id)
|
|
689
|
+
if (
|
|
690
|
+
result.error is None
|
|
691
|
+
and result.build_directory is not None
|
|
692
|
+
and result.compilation_database is not None
|
|
693
|
+
and (preset is None or len(presets) == 1)
|
|
694
|
+
):
|
|
695
|
+
self.configurations[identifier] = CompilationContext(
|
|
696
|
+
id=identifier,
|
|
697
|
+
toolset_id=profile.toolset_id,
|
|
698
|
+
build_directory=result.build_directory,
|
|
699
|
+
compilation_database=result.compilation_database,
|
|
700
|
+
)
|
|
701
|
+
else:
|
|
702
|
+
self.configurations.pop(identifier, None)
|
|
703
|
+
await self.publish_configurations(refresh=result.error is None)
|
|
704
|
+
await report_status(result.error or "Completed successfully")
|
|
705
|
+
return results
|
|
706
|
+
|
|
707
|
+
@apps.tool(
|
|
708
|
+
resource_uri=self.BUILD_WIDGET.uri,
|
|
709
|
+
icons=[icon],
|
|
710
|
+
annotations=changes_files,
|
|
711
|
+
)
|
|
712
|
+
async def cmake_build(
|
|
713
|
+
ctx: Context,
|
|
714
|
+
profiles: list[str] | None = None,
|
|
715
|
+
targets: list[str] | None = None,
|
|
716
|
+
parallel: Annotated[int | None, Field(ge=1)] = None,
|
|
717
|
+
timeout: ProcessTimeout = ProcessTimeout(total=600),
|
|
718
|
+
) -> list[CMakeBuildResult]:
|
|
719
|
+
"""Build selected profiles and their build presets after configuration.
|
|
720
|
+
|
|
721
|
+
Omitted targets use CMake or preset defaults; a nonempty targets list
|
|
722
|
+
selects explicit build targets.
|
|
723
|
+
"""
|
|
724
|
+
if targets is not None and (not targets or any(not name for name in targets)):
|
|
725
|
+
raise ToolError("Targets must be a nonempty list of nonempty names.")
|
|
726
|
+
report = progress(ctx, interval=self.progress_interval)
|
|
727
|
+
await report(0, message="Preparing CMake build")
|
|
728
|
+
try:
|
|
729
|
+
definitions, catalogs = await self.selection(profiles)
|
|
730
|
+
except (CMakeError, ToolchainError, ProcessError) as error:
|
|
731
|
+
raise ToolError(str(error)) from error
|
|
732
|
+
results: list[CMakeBuildResult] = []
|
|
733
|
+
updates = 0
|
|
734
|
+
|
|
735
|
+
async def report_status(message: str) -> None:
|
|
736
|
+
nonlocal updates
|
|
737
|
+
updates += 1
|
|
738
|
+
label = profile.name if preset is None else f"{profile.name}/{preset}"
|
|
739
|
+
await report(updates, message=f"{label}: {message}")
|
|
740
|
+
|
|
741
|
+
for definition in definitions:
|
|
742
|
+
try:
|
|
743
|
+
profile = self.resolve_profile(definition, catalogs)
|
|
744
|
+
methods = self.cmake_methods(profile.toolset_id)
|
|
745
|
+
presets = self.operation_presets(profile, "build")
|
|
746
|
+
except (CMakeError, ToolchainError) as error:
|
|
747
|
+
results.append(
|
|
748
|
+
CMakeBuildResult(
|
|
749
|
+
profile=definition.name,
|
|
750
|
+
error=str(error),
|
|
751
|
+
)
|
|
752
|
+
)
|
|
753
|
+
continue
|
|
754
|
+
for preset in presets:
|
|
755
|
+
result = CMakeBuildResult(
|
|
756
|
+
profile=profile.name,
|
|
757
|
+
preset=preset,
|
|
758
|
+
)
|
|
759
|
+
results.append(result)
|
|
760
|
+
try:
|
|
761
|
+
configuration = profile.configuration
|
|
762
|
+
result.configuration = configuration
|
|
763
|
+
build_directory = None
|
|
764
|
+
if preset is None:
|
|
765
|
+
cache = self.require_configured(profile)
|
|
766
|
+
self.require_configuration(cache, configuration)
|
|
767
|
+
build_directory = self.build_path(profile.build_directory)
|
|
768
|
+
await report_status("Starting build")
|
|
769
|
+
command = await methods["build"](
|
|
770
|
+
self.project_root,
|
|
771
|
+
build_directory,
|
|
772
|
+
preset=preset,
|
|
773
|
+
configuration=configuration,
|
|
774
|
+
targets=targets,
|
|
775
|
+
parallel=parallel,
|
|
776
|
+
timeout=timeout,
|
|
777
|
+
on_progress=report_status,
|
|
778
|
+
)
|
|
779
|
+
result.completed_steps = command.completed_steps
|
|
780
|
+
result.total_steps = command.total_steps
|
|
781
|
+
result.process_id = command.process_id
|
|
782
|
+
if command.return_code != 0:
|
|
783
|
+
result.error = f"CMake build exited with {command.return_code}."
|
|
784
|
+
except (CMakeError, ToolchainError, ProcessError) as error:
|
|
785
|
+
result.error = str(error)
|
|
786
|
+
result.process_id = getattr(error, "process_id", result.process_id)
|
|
787
|
+
await report_status(result.error or "Completed successfully")
|
|
788
|
+
return results
|
|
789
|
+
|
|
790
|
+
@apps.tool(
|
|
791
|
+
resource_uri=self.TEST_WIDGET.uri,
|
|
792
|
+
icons=[icon],
|
|
793
|
+
annotations=changes_files,
|
|
794
|
+
)
|
|
795
|
+
async def cmake_test(
|
|
796
|
+
ctx: Context,
|
|
797
|
+
profiles: list[str] | None = None,
|
|
798
|
+
names: list[str] | None = None,
|
|
799
|
+
parallel: Annotated[int | None, Field(ge=1)] = None,
|
|
800
|
+
timeout: ProcessTimeout = ProcessTimeout(total=600),
|
|
801
|
+
) -> list[CMakeTestResult]:
|
|
802
|
+
"""Run CTest for selected profiles and test presets after building; return test cases.
|
|
803
|
+
|
|
804
|
+
names selects exact test names, not regex patterns; omit it to use
|
|
805
|
+
CTest or preset defaults.
|
|
806
|
+
"""
|
|
807
|
+
if names is not None and (not names or any(not name for name in names)):
|
|
808
|
+
raise ToolError("Test names must be a nonempty list of nonempty names.")
|
|
809
|
+
report = progress(ctx, interval=self.progress_interval)
|
|
810
|
+
await report(0, message="Preparing CTest")
|
|
811
|
+
try:
|
|
812
|
+
definitions, catalogs = await self.selection(profiles)
|
|
813
|
+
except (CMakeError, ToolchainError, ProcessError) as error:
|
|
814
|
+
raise ToolError(str(error)) from error
|
|
815
|
+
results: list[CMakeTestResult] = []
|
|
816
|
+
updates = 0
|
|
817
|
+
|
|
818
|
+
async def report_status(message: str) -> None:
|
|
819
|
+
nonlocal updates
|
|
820
|
+
updates += 1
|
|
821
|
+
label = profile.name if preset is None else f"{profile.name}/{preset}"
|
|
822
|
+
await report(updates, message=f"{label}: {message}")
|
|
823
|
+
|
|
824
|
+
for definition in definitions:
|
|
825
|
+
try:
|
|
826
|
+
profile = self.resolve_profile(definition, catalogs)
|
|
827
|
+
methods = self.ctest_methods(profile.toolset_id)
|
|
828
|
+
presets = self.operation_presets(profile, "test")
|
|
829
|
+
except (CMakeError, ToolchainError) as error:
|
|
830
|
+
results.append(
|
|
831
|
+
CMakeTestResult(
|
|
832
|
+
profile=definition.name,
|
|
833
|
+
error=str(error),
|
|
834
|
+
)
|
|
835
|
+
)
|
|
836
|
+
continue
|
|
837
|
+
for preset in presets:
|
|
838
|
+
result = CMakeTestResult(
|
|
839
|
+
profile=profile.name,
|
|
840
|
+
preset=preset,
|
|
841
|
+
)
|
|
842
|
+
results.append(result)
|
|
843
|
+
try:
|
|
844
|
+
configuration = profile.configuration
|
|
845
|
+
result.configuration = configuration
|
|
846
|
+
build_directory = None
|
|
847
|
+
if preset is None:
|
|
848
|
+
cache = self.require_configured(profile)
|
|
849
|
+
self.require_configuration(cache, configuration)
|
|
850
|
+
build_directory = self.build_path(profile.build_directory)
|
|
851
|
+
temporary_root = self.build_path(WorkspacePath("storage/tmp"))
|
|
852
|
+
try:
|
|
853
|
+
temporary_root.mkdir(parents=True, exist_ok=True)
|
|
854
|
+
except OSError as error:
|
|
855
|
+
raise CMakeError("Cannot create CTest temporary storage.") from error
|
|
856
|
+
try:
|
|
857
|
+
with tempfile.TemporaryDirectory(
|
|
858
|
+
prefix="ctest-",
|
|
859
|
+
dir=temporary_root,
|
|
860
|
+
) as temporary:
|
|
861
|
+
report_path = Path(temporary) / "results.xml"
|
|
862
|
+
await report_status("Starting tests")
|
|
863
|
+
command = await methods["test"](
|
|
864
|
+
self.project_root,
|
|
865
|
+
build_directory,
|
|
866
|
+
report_path=report_path,
|
|
867
|
+
preset=preset,
|
|
868
|
+
configuration=configuration,
|
|
869
|
+
names=names,
|
|
870
|
+
parallel=parallel,
|
|
871
|
+
timeout=timeout,
|
|
872
|
+
on_progress=report_status,
|
|
873
|
+
)
|
|
874
|
+
result.process_id = command.process_id
|
|
875
|
+
result.tests = command.tests
|
|
876
|
+
except OSError as error:
|
|
877
|
+
raise CMakeError("Cannot use CTest temporary storage.") from error
|
|
878
|
+
failed = any(
|
|
879
|
+
test.status in ("failed", "not_run") for test in result.tests
|
|
880
|
+
)
|
|
881
|
+
if command.return_code != 0 or failed:
|
|
882
|
+
result.error = f"CTest failed (exit {command.return_code})."
|
|
883
|
+
except (CMakeError, ToolchainError, ProcessError) as error:
|
|
884
|
+
result.error = str(error)
|
|
885
|
+
result.process_id = getattr(error, "process_id", result.process_id)
|
|
886
|
+
await report_status(result.error or "Completed successfully")
|
|
887
|
+
return results
|
|
888
|
+
|
|
889
|
+
for widget in (
|
|
890
|
+
self.PROFILES_WIDGET,
|
|
891
|
+
self.CONFIGURE_WIDGET,
|
|
892
|
+
self.BUILD_WIDGET,
|
|
893
|
+
self.TEST_WIDGET,
|
|
894
|
+
):
|
|
895
|
+
apps.add_html_resource(widget.uri, widget.content)
|