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