cpdocs 0.1.0__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.
@@ -0,0 +1,1254 @@
1
+ # SPDX-FileCopyrightText: 2026 cpdocs developers
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Clang semantic-AST backend for C and C++ API extraction.
4
+
5
+ The backend intentionally treats Clang as the semantic authority. It invokes the compiler frontend
6
+ with ``-ast-dump=json`` only as a transport format; the rest of cpdocs consumes the normalized
7
+ :class:`ApiGraph`, so this adapter can later be replaced by LibTooling without touching rendering,
8
+ versioning, or availability handling.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import re
15
+ import shlex
16
+ import subprocess
17
+ from collections.abc import Iterable
18
+ from dataclasses import replace
19
+ from functools import lru_cache
20
+ from pathlib import Path
21
+
22
+ from .contract import ParseOptions
23
+ from .model import (
24
+ ApiEntity,
25
+ ApiGraph,
26
+ ApiParameter,
27
+ ApiSignature,
28
+ SourceLocation,
29
+ relative_source_path,
30
+ stable_entity_id,
31
+ )
32
+
33
+ _RECORD_KINDS = {
34
+ "CXXRecordDecl": "class",
35
+ "RecordDecl": "struct",
36
+ "ClassTemplateDecl": "class",
37
+ "ClassTemplatePartialSpecializationDecl": "class",
38
+ "ClassTemplateSpecializationDecl": "class",
39
+ }
40
+ _CALLABLE_KINDS = {
41
+ "FunctionDecl": "function",
42
+ "CXXMethodDecl": "method",
43
+ "CXXConstructorDecl": "constructor",
44
+ "CXXDestructorDecl": "method",
45
+ "ConversionFunctionDecl": "method",
46
+ }
47
+ _ALIAS_KINDS = {"TypedefDecl": "type_alias", "TypeAliasDecl": "type_alias"}
48
+ _VARIABLE_KINDS = {"VarDecl": "variable", "FieldDecl": "attribute"}
49
+ _SKIP_NAMES = {"__va_list_tag", "__NSConstantString_tag"}
50
+
51
+
52
+ def _node_file(
53
+ node: dict[str, object],
54
+ inherited: Path | None,
55
+ file_state: list[Path | None],
56
+ ) -> Path | None:
57
+ """Resolve Clang's state-compressed JSON source location to an explicit file path."""
58
+
59
+ loc = node.get("loc")
60
+ if isinstance(loc, dict):
61
+ file_value = loc.get("file")
62
+ if isinstance(file_value, str) and file_value:
63
+ value = Path(file_value)
64
+ file_state[0] = value
65
+ return value
66
+ if inherited is not None:
67
+ return inherited
68
+ return file_state[0]
69
+
70
+
71
+ @lru_cache(maxsize=256)
72
+ def _source_bytes(path: Path) -> bytes:
73
+ try:
74
+ return path.read_bytes()
75
+ except OSError:
76
+ return b""
77
+
78
+
79
+ def _line_column_from_offset(path: Path, offset: int) -> tuple[int | None, int | None]:
80
+ content = _source_bytes(path)
81
+ if not content or offset < 0 or offset > len(content):
82
+ return None, None
83
+ before = content[:offset]
84
+ line = before.count(b"\n") + 1
85
+ last_newline = before.rfind(b"\n")
86
+ column = offset + 1 if last_newline < 0 else offset - last_newline
87
+ return line, column
88
+
89
+
90
+ def _inside(path: Path | None, roots: Iterable[Path]) -> bool:
91
+ if path is None:
92
+ return False
93
+ try:
94
+ resolved = path.resolve()
95
+ except OSError:
96
+ resolved = path
97
+ for root in roots:
98
+ try:
99
+ resolved.relative_to(root.resolve())
100
+ return True
101
+ except (OSError, ValueError):
102
+ continue
103
+ return False
104
+
105
+
106
+ def _logical_public_path(path: Path, public_roots: tuple[Path, ...], project_root: Path) -> str:
107
+ for root in public_roots:
108
+ try:
109
+ return path.resolve().relative_to(root.resolve()).as_posix()
110
+ except (OSError, ValueError):
111
+ continue
112
+ return relative_source_path(path, project_root)
113
+
114
+
115
+ def _comment_line(node: dict[str, object]) -> int | None:
116
+ loc = node.get("loc")
117
+ if isinstance(loc, dict) and isinstance(loc.get("line"), int):
118
+ return int(loc["line"])
119
+ return None
120
+
121
+
122
+ def _paragraph_text(node: dict[str, object]) -> str:
123
+ """Rebuild a paragraph from its text and inline HTML pieces.
124
+
125
+ Clang splits a paragraph into one text node per source line and parses ``<name>`` as an HTML
126
+ tag even inside backticks; ``labelled<double>`` must come back without added spaces.
127
+ """
128
+
129
+ pieces: list[str] = []
130
+ previous_line: int | None = None
131
+ for child in node.get("inner", []) if isinstance(node.get("inner"), list) else []:
132
+ if not isinstance(child, dict):
133
+ continue
134
+ kind = child.get("kind")
135
+ line = _comment_line(child)
136
+ if kind == "TextComment":
137
+ text = str(child.get("text", ""))
138
+ if pieces and line is not None and previous_line is not None and line != previous_line:
139
+ pieces.append(" ")
140
+ pieces.append(text)
141
+ elif kind == "HTMLStartTagComment":
142
+ pieces.append(f"<{child.get('name', '')}" + ("/>" if child.get("selfClosing") else ">"))
143
+ elif kind == "HTMLEndTagComment":
144
+ pieces.append(f"</{child.get('name', '')}>")
145
+ elif kind == "InlineCommandComment":
146
+ arguments = child.get("args")
147
+ if isinstance(arguments, list) and arguments:
148
+ pieces.append("`" + " ".join(str(value) for value in arguments) + "`")
149
+ if line is not None:
150
+ previous_line = line
151
+ return re.sub(r"\s+", " ", "".join(pieces)).strip()
152
+
153
+
154
+ def _comment_text(node: dict[str, object]) -> str:
155
+ paragraphs: list[str] = []
156
+
157
+ def flatten(current: dict[str, object]) -> str:
158
+ kind = str(current.get("kind", ""))
159
+ if kind == "ParagraphComment":
160
+ return _paragraph_text(current)
161
+ text = current.get("text")
162
+ if isinstance(text, str):
163
+ return text.strip()
164
+ children = current.get("inner")
165
+ parts: list[str] = []
166
+ if isinstance(children, list):
167
+ for child in children:
168
+ if isinstance(child, dict):
169
+ value = flatten(child)
170
+ if value:
171
+ parts.append(value)
172
+ body = " ".join(parts).strip()
173
+ # Parameter and return commands are kept as canonical ``@tparam``, ``@param`` and
174
+ # ``@return`` paragraphs; the renderer turns them into lists below the description.
175
+ if kind in {"ParamCommandComment", "TParamCommandComment"}:
176
+ parameter = current.get("param") or current.get("paramName")
177
+ command = "tparam" if kind == "TParamCommandComment" else "param"
178
+ if parameter:
179
+ return f"@{command} {parameter} {body}".rstrip()
180
+ if kind == "BlockCommandComment":
181
+ command = current.get("name")
182
+ if command in {"return", "returns", "result"} and body:
183
+ return f"@return {body}"
184
+ if command in {"throw", "throws", "exception"} and body:
185
+ return f"@throws {body}"
186
+ return body
187
+
188
+ children = node.get("inner")
189
+ if isinstance(children, list):
190
+ for child in children:
191
+ if not isinstance(child, dict):
192
+ continue
193
+ value = flatten(child)
194
+ if value:
195
+ paragraphs.append(value)
196
+ return "\n\n".join(paragraphs)
197
+
198
+
199
+ def _attached_comment(node: dict[str, object]) -> str:
200
+ children = node.get("inner")
201
+ if not isinstance(children, list):
202
+ return ""
203
+ for child in children:
204
+ if isinstance(child, dict) and child.get("kind") == "FullComment":
205
+ return _comment_text(child)
206
+ return ""
207
+
208
+
209
+ def _location(
210
+ node: dict[str, object],
211
+ path: Path | None,
212
+ project_root: Path,
213
+ public_roots: tuple[Path, ...],
214
+ ) -> SourceLocation | None:
215
+ if path is None:
216
+ return None
217
+ loc = node.get("loc")
218
+ line = None
219
+ column = None
220
+ if isinstance(loc, dict):
221
+ if isinstance(loc.get("line"), int):
222
+ line = int(loc["line"])
223
+ if isinstance(loc.get("col"), int):
224
+ column = int(loc["col"])
225
+ if (line is None or column is None) and isinstance(loc.get("offset"), int):
226
+ inferred_line, inferred_column = _line_column_from_offset(path, int(loc["offset"]))
227
+ line = line or inferred_line
228
+ column = column or inferred_column
229
+ # Public API locations use the installed/header namespace where possible. Generated headers
230
+ # therefore do not leak profile-specific build-directory paths into the published reference.
231
+ return SourceLocation(_logical_public_path(path, public_roots, project_root), line, column)
232
+
233
+
234
+ def _related_markers(node: dict[str, object], path: Path | None) -> list[str]:
235
+ """Read CPDOCS-API-RELATES-TO markers immediately preceding a declaration.
236
+
237
+ These markers are intentionally ordinary source comments rather than part of the Doxygen-style
238
+ documentation block. Clang therefore does not consistently include them in FullComment nodes,
239
+ even with ``-fparse-all-comments``. Source locations are semantic, so using them to recover the
240
+ adjacent marker is deterministic and keeps this small cpdocs extension independent of Doxygen.
241
+ """
242
+
243
+ if path is None:
244
+ return []
245
+ loc = node.get("loc")
246
+ line: int | None = None
247
+ if isinstance(loc, dict):
248
+ if isinstance(loc.get("line"), int):
249
+ line = int(loc["line"])
250
+ elif isinstance(loc.get("offset"), int):
251
+ line, _ = _line_column_from_offset(path, int(loc["offset"]))
252
+ if line is None or line <= 1:
253
+ return []
254
+
255
+ try:
256
+ lines = path.read_text(encoding="utf-8").splitlines()
257
+ except (OSError, UnicodeDecodeError):
258
+ return []
259
+
260
+ marker = re.compile(r"CPDOCS-API-RELATES-TO:\s*(.+?)\s*(?:\*/)?$")
261
+ # The marker is conventionally the comment directly above the declaration. Permit a small
262
+ # comment/blank window so attributes do not make the convention brittle, but stop at source.
263
+ values: list[str] = []
264
+ for index in range(line - 2, max(-1, line - 12), -1):
265
+ text = lines[index].strip()
266
+ match = marker.search(text)
267
+ if match:
268
+ for value in re.split(r"\s*,\s*", match.group(1)):
269
+ if value and value not in values:
270
+ values.append(value)
271
+ continue
272
+ if not text:
273
+ continue
274
+ if text.startswith(("//", "/*", "*", "*/", "[[")):
275
+ continue
276
+ break
277
+ return values
278
+
279
+
280
+ def _qual_type(node: dict[str, object]) -> str:
281
+ value = node.get("type")
282
+ if isinstance(value, dict):
283
+ spelling = value.get("qualType")
284
+ if isinstance(spelling, str):
285
+ return spelling
286
+ return ""
287
+
288
+
289
+ def _parameters(node: dict[str, object]) -> tuple[ApiParameter, ...]:
290
+ parameters: list[ApiParameter] = []
291
+ children = node.get("inner")
292
+ if not isinstance(children, list):
293
+ return ()
294
+ for child in children:
295
+ if not isinstance(child, dict) or child.get("kind") != "ParmVarDecl":
296
+ continue
297
+ parameters.append(
298
+ ApiParameter(
299
+ name=str(child.get("name", "")),
300
+ type=_qual_type(child),
301
+ )
302
+ )
303
+ return tuple(parameters)
304
+
305
+
306
+ def _return_type(node: dict[str, object]) -> str:
307
+ """Return type from Clang's function type spelling ``RET (PARAMS) QUALIFIERS``."""
308
+
309
+ kind = str(node.get("kind", ""))
310
+ if kind in {"CXXConstructorDecl", "CXXDestructorDecl", "CXXConversionDecl"}:
311
+ return ""
312
+ spelling = _qual_type(node)
313
+ depth = 0
314
+ for index, character in enumerate(spelling):
315
+ if character == "<":
316
+ depth += 1
317
+ elif character == ">":
318
+ depth -= 1
319
+ elif character == "(" and depth == 0:
320
+ result = spelling[:index].strip()
321
+ trailing = re.search(r"\)\s*(?:[a-z&\s]*)->\s*(.+)$", spelling[index:])
322
+ if result == "auto" and trailing:
323
+ return trailing.group(1).strip()
324
+ return result
325
+ return ""
326
+
327
+
328
+ def _qualifiers(node: dict[str, object]) -> tuple[str, ...]:
329
+ values: list[str] = []
330
+ for field, label in (
331
+ ("inline", "inline"),
332
+ ("constexpr", "constexpr"),
333
+ ("consteval", "consteval"),
334
+ ("virtual", "virtual"),
335
+ ("pure", "pure virtual"),
336
+ ):
337
+ if node.get(field) is True:
338
+ values.append(label)
339
+ storage = node.get("storageClass")
340
+ if isinstance(storage, str) and storage:
341
+ values.append(storage)
342
+ spelling = _qual_type(node)
343
+ suffix = spelling.rsplit(")", 1)[1] if ")" in spelling else ""
344
+ for token in ("const", "volatile", "noexcept"):
345
+ if re.search(rf"\b{token}\b", suffix) and token not in values:
346
+ values.append(token)
347
+ if "&&" in suffix:
348
+ values.append("&&")
349
+ elif "&" in suffix:
350
+ values.append("&")
351
+ return tuple(values)
352
+
353
+
354
+ def _signature(node: dict[str, object]) -> ApiSignature:
355
+ parameters = _parameters(node)
356
+ qualifiers = _qualifiers(node)
357
+ return ApiSignature(parameters=parameters, returns=_return_type(node), qualifiers=qualifiers)
358
+
359
+
360
+ # --------------------------------------------------------------------------------------------------
361
+ # Source text of declarations
362
+ #
363
+ # Clang's JSON dump keeps template parameter lists, requires-clauses and concept definitions only as
364
+ # expression trees. What a reader needs is the declaration as written, so these helpers slice it out
365
+ # of the header using the byte offsets of the AST node ranges.
366
+ # --------------------------------------------------------------------------------------------------
367
+
368
+ _SOURCE_BYTES: dict[Path, bytes] = {}
369
+ _TEMPLATE_KINDS = {
370
+ "ClassTemplateDecl",
371
+ "FunctionTemplateDecl",
372
+ "VarTemplateDecl",
373
+ "TypeAliasTemplateDecl",
374
+ }
375
+ _TEMPLATE_PARAMETER_KINDS = {
376
+ "TemplateTypeParmDecl",
377
+ "NonTypeTemplateParmDecl",
378
+ "TemplateTemplateParmDecl",
379
+ }
380
+ _SPECIALIZATION_KINDS = {
381
+ "ClassTemplateSpecializationDecl",
382
+ "ClassTemplatePartialSpecializationDecl",
383
+ }
384
+
385
+
386
+ def _offset(location: object) -> int | None:
387
+ if not isinstance(location, dict):
388
+ return None
389
+ value = location.get("offset")
390
+ if isinstance(value, int):
391
+ return value
392
+ return None
393
+
394
+
395
+ def _range_offsets(node: dict[str, object]) -> tuple[int, int] | None:
396
+ source_range = node.get("range")
397
+ if not isinstance(source_range, dict):
398
+ return None
399
+ begin = source_range.get("begin")
400
+ end = source_range.get("end")
401
+ start = _offset(begin)
402
+ stop = _offset(end)
403
+ if start is None or stop is None or not isinstance(end, dict):
404
+ return None
405
+ token = end.get("tokLen")
406
+ return start, stop + (token if isinstance(token, int) else 0)
407
+
408
+
409
+ def _source_slice(path: Path | None, start: int, stop: int) -> str:
410
+ if path is None:
411
+ return ""
412
+ data = _SOURCE_BYTES.get(path)
413
+ if data is None:
414
+ try:
415
+ data = path.read_bytes()
416
+ except OSError:
417
+ data = b""
418
+ _SOURCE_BYTES[path] = data
419
+ return data[start:stop].decode("utf-8", errors="replace")
420
+
421
+
422
+ def _normalize_declaration(text: str) -> str:
423
+ """Collapse whitespace inside a declaration while keeping its line structure readable."""
424
+
425
+ lines = [line.rstrip() for line in text.strip().splitlines() if line.strip()]
426
+ if not lines:
427
+ return ""
428
+ indents = [len(line) - len(line.lstrip()) for line in lines[1:]]
429
+ common = min(indents) if indents else 0
430
+ result = [re.sub(r"\s+", " ", lines[0]).strip()]
431
+ for line in lines[1:]:
432
+ body = line[common:]
433
+ indent = len(body) - len(body.lstrip())
434
+ result.append(" " * indent + re.sub(r"\s+", " ", body.strip()))
435
+ return "\n".join(result).rstrip(";").rstrip()
436
+
437
+
438
+ def _templated_child(node: dict[str, object]) -> dict[str, object] | None:
439
+ children = node.get("inner")
440
+ if not isinstance(children, list):
441
+ return None
442
+ for child in children:
443
+ if (
444
+ isinstance(child, dict)
445
+ and child.get("kind") not in _TEMPLATE_PARAMETER_KINDS
446
+ and not child.get("isImplicit")
447
+ and _range_offsets(child) is not None
448
+ and str(child.get("kind", "")).endswith("Decl")
449
+ ):
450
+ return child
451
+ return None
452
+
453
+
454
+ def _template_header(node: dict[str, object], child: dict[str, object], path: Path | None) -> str:
455
+ """``template <...>`` and any leading requires-clause, as written before ``child``."""
456
+
457
+ outer = _range_offsets(node)
458
+ inner = _range_offsets(child)
459
+ if outer is None or inner is None or inner[0] <= outer[0]:
460
+ return ""
461
+ return _normalize_declaration(_source_slice(path, outer[0], inner[0]))
462
+
463
+
464
+ def _specialization(node: dict[str, object], path: Path | None) -> tuple[str, str] | None:
465
+ """Template header and argument list of an explicit or partial specialization."""
466
+
467
+ whole = _range_offsets(node)
468
+ if whole is None:
469
+ return None
470
+ text = _source_slice(path, whole[0], whole[1])
471
+ name = str(node.get("name", ""))
472
+ match = re.search(r"\b(?:struct|class|union)\s+" + re.escape(name) + r"\s*<", text)
473
+ if match is None:
474
+ return None
475
+ depth = 0
476
+ for index in range(match.end() - 1, len(text)):
477
+ if text[index] == "<":
478
+ depth += 1
479
+ elif text[index] == ">":
480
+ depth -= 1
481
+ if depth == 0:
482
+ arguments = re.sub(r"\s+", " ", text[match.end() - 1 : index + 1])
483
+ return _normalize_declaration(text[: match.start()]), arguments
484
+ return None
485
+
486
+
487
+ def _initializer(node: dict[str, object], path: Path | None) -> str:
488
+ """The written initializer of a variable declaration, if it has one."""
489
+
490
+ if node.get("init") is None:
491
+ return ""
492
+ whole = _range_offsets(node)
493
+ if whole is None:
494
+ return ""
495
+ text = _source_slice(path, whole[0], whole[1])
496
+ name = str(node.get("name", ""))
497
+ match = re.search(r"\b" + re.escape(name) + r"\s*(=|\{)", text)
498
+ if match is None:
499
+ return ""
500
+ value = text[match.end() :] if match.group(1) == "=" else "{" + text[match.end() :]
501
+ return re.sub(r"\s+", " ", value).strip().rstrip(";").strip()
502
+
503
+
504
+ def _is_tag_typedef(
505
+ node: dict[str, object], name: str, scopes: tuple[str, ...], graph: ApiGraph
506
+ ) -> bool:
507
+ """Whether a typedef merely re-exports the record or enum tag of the same name.
508
+
509
+ The typedef's documentation is moved onto the tag when the tag has none.
510
+ """
511
+
512
+ target = _qual_type(node)
513
+ if target not in {f"struct {name}", f"union {name}", f"enum {name}", f"class {name}"}:
514
+ return False
515
+ qualified = "::".join((*scopes, name))
516
+ for tag_kind in ("struct", "union", "enum", "class"):
517
+ tag = graph.entities.get(stable_entity_id("cpp", tag_kind, qualified))
518
+ if tag is not None:
519
+ if not tag.documentation:
520
+ tag.documentation = _attached_comment(node)
521
+ return True
522
+ return False
523
+
524
+
525
+ def _record_kind(node: dict[str, object]) -> str:
526
+ tag = node.get("tagUsed")
527
+ if isinstance(tag, str) and tag in {"class", "struct", "union"}:
528
+ return tag
529
+ return _RECORD_KINDS.get(str(node.get("kind", "")), "class")
530
+
531
+
532
+ def _entity_properties(node: dict[str, object]) -> list[str]:
533
+ properties = list(_qualifiers(node))
534
+ access = node.get("access")
535
+ if isinstance(access, str) and access not in {"", "public"}:
536
+ properties.append(access)
537
+ if node.get("isAggregate") is True:
538
+ properties.append("aggregate")
539
+ return list(dict.fromkeys(properties))
540
+
541
+
542
+ def _base_names(node: dict[str, object]) -> list[str]:
543
+ values: list[str] = []
544
+ bases = node.get("bases")
545
+ if isinstance(bases, list):
546
+ for base in bases:
547
+ if not isinstance(base, dict):
548
+ continue
549
+ spelling = _qual_type(base)
550
+ if spelling and spelling not in values:
551
+ values.append(spelling)
552
+ return values
553
+
554
+
555
+ def _walk_ast(
556
+ node: dict[str, object],
557
+ *,
558
+ graph: ApiGraph,
559
+ project_root: Path,
560
+ public_roots: tuple[Path, ...],
561
+ inherited_file: Path | None = None,
562
+ parent_entity: ApiEntity | None = None,
563
+ scopes: tuple[str, ...] = (),
564
+ template_context: bool = False,
565
+ access: str = "public",
566
+ file_state: list[Path | None] | None = None,
567
+ ) -> None:
568
+ if file_state is None:
569
+ file_state = [inherited_file]
570
+ kind = str(node.get("kind", ""))
571
+ path = _node_file(node, inherited_file, file_state)
572
+ name = str(node.get("name", ""))
573
+ in_public_file = _inside(path, public_roots)
574
+
575
+ if node.get("isImplicit") is True:
576
+ return
577
+ if name in _SKIP_NAMES:
578
+ return
579
+
580
+ # Clang represents C++ access changes as ordered AccessSpecDecl siblings rather than copying the
581
+ # access level onto every member. The parent traversal supplies the effective access here.
582
+ if access != "public" and kind in {
583
+ *_RECORD_KINDS,
584
+ *_CALLABLE_KINDS,
585
+ *_ALIAS_KINDS,
586
+ *_VARIABLE_KINDS,
587
+ "EnumDecl",
588
+ "ConceptDecl",
589
+ "FunctionTemplateDecl",
590
+ }:
591
+ return
592
+
593
+ # Implicit instantiations carry no source range; only written specializations are API.
594
+ if kind in _SPECIALIZATION_KINDS and _range_offsets(node) is None:
595
+ return
596
+ if kind in _TEMPLATE_KINDS:
597
+ templated = _templated_child(node)
598
+ if templated is not None:
599
+ templated["__cpdocs_template"] = _template_header(node, templated, path)
600
+ if templated.get("kind") == "CXXDeductionGuideDecl":
601
+ templated["__cpdocs_guide"] = _normalize_declaration(
602
+ _source_slice(path, *(_range_offsets(node) or (0, 0)))
603
+ )
604
+ template_header = str(node.get("__cpdocs_template", ""))
605
+
606
+ entity: ApiEntity | None = None
607
+ next_scopes = scopes
608
+ next_parent = parent_entity
609
+ next_template = template_context or kind in {"FunctionTemplateDecl", "ClassTemplateDecl"}
610
+
611
+ if kind == "NamespaceDecl" and name and in_public_file:
612
+ qualified = "::".join((*scopes, name))
613
+ entity = ApiEntity(
614
+ id=stable_entity_id("cpp", "namespace", qualified),
615
+ language="cpp",
616
+ kind="namespace",
617
+ name=name,
618
+ qualified_name=qualified,
619
+ parent=parent_entity.id if parent_entity else None,
620
+ documentation=_attached_comment(node),
621
+ source=_location(node, path, project_root, public_roots),
622
+ related=_related_markers(node, path),
623
+ )
624
+ graph.add(entity)
625
+ entity = graph.entities[entity.id]
626
+ next_scopes = (*scopes, name)
627
+ next_parent = entity
628
+
629
+ elif kind in _RECORD_KINDS and name and in_public_file:
630
+ # ClassTemplateDecl wraps the actual CXXRecordDecl. Let the record child own the semantic
631
+ # entity, but propagate template context so it receives a template property.
632
+ if kind == "ClassTemplateDecl":
633
+ pass
634
+ else:
635
+ display_name = name
636
+ if kind in _SPECIALIZATION_KINDS:
637
+ specialization = _specialization(node, path)
638
+ if specialization is not None:
639
+ template_header, arguments = specialization
640
+ display_name = name + arguments
641
+ qualified = "::".join((*scopes, display_name))
642
+ record_kind = _record_kind(node)
643
+ entity = ApiEntity(
644
+ id=stable_entity_id("cpp", record_kind, qualified),
645
+ language="cpp",
646
+ kind=record_kind,
647
+ name=display_name,
648
+ template=template_header,
649
+ qualified_name=qualified,
650
+ parent=parent_entity.id if parent_entity else None,
651
+ documentation=_attached_comment(node),
652
+ source=_location(node, path, project_root, public_roots),
653
+ properties=_entity_properties(node)
654
+ + (["template"] if template_context or template_header else []),
655
+ bases=_base_names(node),
656
+ related=_related_markers(node, path),
657
+ )
658
+ graph.add(entity)
659
+ entity = graph.entities[entity.id]
660
+ next_scopes = (*scopes, display_name)
661
+ next_parent = entity
662
+
663
+ elif kind == "CXXDeductionGuideDecl" and in_public_file:
664
+ # A deduction guide belongs to the class template it deduces.
665
+ guided = name.removeprefix("<deduction guide for ").removesuffix(">")
666
+ record_name = "::".join((*scopes, guided))
667
+ owner = next(
668
+ (
669
+ graph.entities[stable_entity_id("cpp", record_kind, record_name)]
670
+ for record_kind in ("struct", "class", "union")
671
+ if stable_entity_id("cpp", record_kind, record_name) in graph.entities
672
+ ),
673
+ None,
674
+ )
675
+ spelling = str(node.get("__cpdocs_guide", "")) or _normalize_declaration(
676
+ _source_slice(path, *(_range_offsets(node) or (0, 0)))
677
+ )
678
+ if owner is not None and spelling:
679
+ qualified = f"{record_name}::{guided}"
680
+ guide = ApiEntity(
681
+ id=stable_entity_id("cpp", "deduction_guide", qualified),
682
+ language="cpp",
683
+ kind="deduction_guide",
684
+ name=guided,
685
+ qualified_name=qualified,
686
+ parent=owner.id,
687
+ signatures=[ApiSignature(spelling=spelling)],
688
+ documentation=_attached_comment(node),
689
+ source=_location(node, path, project_root, public_roots),
690
+ )
691
+ graph.add(guide)
692
+ return
693
+
694
+ elif kind in _CALLABLE_KINDS and name and in_public_file:
695
+ qualified = "::".join((*scopes, name))
696
+ callable_kind = _CALLABLE_KINDS[kind]
697
+ entity_id = stable_entity_id("cpp", callable_kind, qualified)
698
+ entity = ApiEntity(
699
+ id=entity_id,
700
+ language="cpp",
701
+ kind=callable_kind,
702
+ name=name,
703
+ qualified_name=qualified,
704
+ parent=parent_entity.id if parent_entity else None,
705
+ signatures=[
706
+ replace(
707
+ _signature(node),
708
+ template=template_header,
709
+ documentation=_attached_comment(node),
710
+ source=_location(node, path, project_root, public_roots),
711
+ )
712
+ ],
713
+ documentation=_attached_comment(node),
714
+ source=_location(node, path, project_root, public_roots),
715
+ properties=_entity_properties(node)
716
+ + (["template"] if template_context or template_header else []),
717
+ related=_related_markers(node, path),
718
+ )
719
+ graph.add(entity)
720
+ next_parent = graph.entities[entity_id]
721
+
722
+ elif kind == "EnumDecl" and name and in_public_file:
723
+ qualified = "::".join((*scopes, name))
724
+ entity = ApiEntity(
725
+ id=stable_entity_id("cpp", "enum", qualified),
726
+ language="cpp",
727
+ kind="enum",
728
+ name=name,
729
+ qualified_name=qualified,
730
+ parent=parent_entity.id if parent_entity else None,
731
+ documentation=_attached_comment(node),
732
+ source=_location(node, path, project_root, public_roots),
733
+ properties=["scoped"] if node.get("scopedEnumTag") else [],
734
+ related=_related_markers(node, path),
735
+ )
736
+ graph.add(entity)
737
+ entity = graph.entities[entity.id]
738
+ next_scopes = (*scopes, name)
739
+ next_parent = entity
740
+
741
+ elif kind == "EnumConstantDecl" and name and in_public_file:
742
+ qualified = "::".join((*scopes, name))
743
+ entity = ApiEntity(
744
+ id=stable_entity_id("cpp", "constant", qualified),
745
+ language="cpp",
746
+ kind="constant",
747
+ name=name,
748
+ qualified_name=qualified,
749
+ parent=parent_entity.id if parent_entity else None,
750
+ documentation=_attached_comment(node),
751
+ source=_location(node, path, project_root, public_roots),
752
+ related=_related_markers(node, path),
753
+ )
754
+ graph.add(entity)
755
+
756
+ elif (
757
+ kind in _ALIAS_KINDS
758
+ and name
759
+ and in_public_file
760
+ and _is_tag_typedef(node, name, scopes, graph)
761
+ ):
762
+ # ``typedef struct ring {...} ring;`` names the tag a second time; document the tag only.
763
+ pass
764
+
765
+ elif kind in _ALIAS_KINDS and name and in_public_file:
766
+ qualified = "::".join((*scopes, name))
767
+ target = _qual_type(node)
768
+ entity = ApiEntity(
769
+ id=stable_entity_id("cpp", "type_alias", qualified),
770
+ language="cpp",
771
+ kind="type_alias",
772
+ name=name,
773
+ qualified_name=qualified,
774
+ parent=parent_entity.id if parent_entity else None,
775
+ documentation=_attached_comment(node),
776
+ source=_location(node, path, project_root, public_roots),
777
+ properties=[f"= {target}"] if target else [],
778
+ related=_related_markers(node, path),
779
+ template=template_header,
780
+ )
781
+ graph.add(entity)
782
+
783
+ elif kind in _VARIABLE_KINDS and name and in_public_file:
784
+ qualified = "::".join((*scopes, name))
785
+ variable_kind = _VARIABLE_KINDS[kind]
786
+ entity = ApiEntity(
787
+ id=stable_entity_id("cpp", variable_kind, qualified),
788
+ language="cpp",
789
+ kind=variable_kind,
790
+ name=name,
791
+ qualified_name=qualified,
792
+ parent=parent_entity.id if parent_entity else None,
793
+ documentation=_attached_comment(node),
794
+ source=_location(node, path, project_root, public_roots),
795
+ properties=([_qual_type(node)] if _qual_type(node) else [])
796
+ + ([f"= {_initializer(node, path)}"] if _initializer(node, path) else []),
797
+ related=_related_markers(node, path),
798
+ template=template_header,
799
+ )
800
+ graph.add(entity)
801
+
802
+ elif kind == "ConceptDecl" and name and in_public_file:
803
+ qualified = "::".join((*scopes, name))
804
+ entity = ApiEntity(
805
+ id=stable_entity_id("cpp", "concept", qualified),
806
+ language="cpp",
807
+ kind="concept",
808
+ name=name,
809
+ qualified_name=qualified,
810
+ parent=parent_entity.id if parent_entity else None,
811
+ declaration=_normalize_declaration(
812
+ _source_slice(path, *(_range_offsets(node) or (0, 0)))
813
+ ),
814
+ documentation=_attached_comment(node),
815
+ source=_location(node, path, project_root, public_roots),
816
+ related=_related_markers(node, path),
817
+ )
818
+ graph.add(entity)
819
+
820
+ # Callable bodies, variable initializers, aliases, and concepts can contain declarations that are
821
+ # implementation details. Their semantic metadata has already been harvested above; only
822
+ # namespace/record/enum/template containers should contribute nested public API entities.
823
+ if kind in {
824
+ *_CALLABLE_KINDS,
825
+ *_ALIAS_KINDS,
826
+ *_VARIABLE_KINDS,
827
+ "ConceptDecl",
828
+ "EnumConstantDecl",
829
+ }:
830
+ return
831
+
832
+ children = node.get("inner")
833
+ if not isinstance(children, list):
834
+ return
835
+
836
+ # Comments and parameter declarations are metadata, not API entities. For records, preserve the
837
+ # source-order access state because Clang emits AccessSpecDecl markers as siblings.
838
+ member_access = access
839
+ if entity is not None and entity.kind in {"class", "struct", "union"}:
840
+ member_access = "private" if entity.kind == "class" else "public"
841
+ for child in children:
842
+ if not isinstance(child, dict):
843
+ continue
844
+ child_kind = child.get("kind")
845
+ if child_kind == "AccessSpecDecl":
846
+ child_access = child.get("access")
847
+ if isinstance(child_access, str) and child_access:
848
+ member_access = child_access
849
+ continue
850
+ if child_kind in {
851
+ "FullComment",
852
+ "ParagraphComment",
853
+ "TextComment",
854
+ "ParmVarDecl",
855
+ "CXXBaseSpecifier",
856
+ }:
857
+ continue
858
+ _walk_ast(
859
+ child,
860
+ graph=graph,
861
+ project_root=project_root,
862
+ public_roots=public_roots,
863
+ inherited_file=path,
864
+ parent_entity=next_parent,
865
+ scopes=next_scopes,
866
+ template_context=next_template,
867
+ access=member_access,
868
+ file_state=file_state,
869
+ )
870
+
871
+
872
+ def _compile_entry_arguments(entry: dict[str, object]) -> list[str]:
873
+ arguments = entry.get("arguments")
874
+ if isinstance(arguments, list) and all(isinstance(value, str) for value in arguments):
875
+ return list(arguments)
876
+ command = entry.get("command")
877
+ if isinstance(command, str):
878
+ return shlex.split(command)
879
+ return []
880
+
881
+
882
+ def _choose_compile_entry(
883
+ entries: list[dict[str, object]], *, language: str
884
+ ) -> dict[str, object] | None:
885
+ preferred = {".c"} if language == "c" else {".cc", ".cpp", ".cxx", ".c++", ".C"}
886
+ for entry in entries:
887
+ source = entry.get("file")
888
+ if isinstance(source, str) and Path(source).suffix in preferred:
889
+ return entry
890
+ return entries[0] if entries else None
891
+
892
+
893
+ def _frontend_arguments(
894
+ entry: dict[str, object] | None,
895
+ *,
896
+ public_roots: tuple[Path, ...],
897
+ predefines: Iterable[str],
898
+ ) -> tuple[Path | None, list[str]]:
899
+ if entry is None:
900
+ args = ["-std=c++20"]
901
+ directory = None
902
+ else:
903
+ raw = _compile_entry_arguments(entry)
904
+ directory_value = entry.get("directory")
905
+ directory = Path(directory_value) if isinstance(directory_value, str) else None
906
+ args = raw[1:] if raw else []
907
+
908
+ source = entry.get("file")
909
+ source_values = {str(source)} if isinstance(source, str) else set()
910
+ if isinstance(source, str) and directory is not None:
911
+ source_values.add(str((directory / source).resolve()))
912
+
913
+ cleaned: list[str] = []
914
+ skip_next = False
915
+ options_with_value = {"-o", "-MF", "-MT", "-MQ", "--serialize-diagnostics"}
916
+ for argument in args:
917
+ if skip_next:
918
+ skip_next = False
919
+ continue
920
+ if argument in options_with_value:
921
+ skip_next = True
922
+ continue
923
+ if argument in {"-c", "-MD", "-MMD", "-MP"}:
924
+ continue
925
+ if argument in source_values:
926
+ continue
927
+ try:
928
+ if source_values and str(Path(argument).resolve()) in source_values:
929
+ continue
930
+ except OSError:
931
+ pass
932
+ if argument.startswith("-o") and len(argument) > 2:
933
+ continue
934
+ cleaned.append(argument)
935
+ args = cleaned
936
+
937
+ for root in public_roots:
938
+ args.append(f"-I{root}")
939
+ names: set[str] = set()
940
+ for value in predefines:
941
+ if not value:
942
+ continue
943
+ args.append(f"-D{value}")
944
+ names.add(value.split("=", 1)[0])
945
+ if "__CUDACC__" in names or "__HIPCC__" in names:
946
+ args.extend(["-D__host__=", "-D__device__=", "-D__global__="])
947
+ return directory, args
948
+
949
+
950
+ def _public_headers(public_roots: Iterable[Path]) -> list[Path]:
951
+ suffixes = {".h", ".hh", ".hpp", ".hxx", ".cuh"}
952
+ values: set[Path] = set()
953
+ for root in public_roots:
954
+ if not root.is_dir():
955
+ continue
956
+ for path in root.rglob("*"):
957
+ if path.is_file() and path.suffix.lower() in suffixes:
958
+ values.add(path.resolve())
959
+ return sorted(values)
960
+
961
+
962
+ def _write_umbrella(path: Path, headers: Iterable[Path]) -> None:
963
+ lines = ["// Generated by cpdocs for semantic API extraction."]
964
+ for header in headers:
965
+ escaped = header.as_posix().replace('"', '\\"')
966
+ lines.append(f'#include "{escaped}"')
967
+ lines.append("")
968
+ path.write_text("\n".join(lines), encoding="utf-8")
969
+
970
+
971
+ def _include_guard_name(lines: list[str]) -> str | None:
972
+ """Recognize the conventional #ifndef/#define include-guard pair near the file start."""
973
+
974
+ ifndef = re.compile(r"^\s*#\s*ifndef\s+([A-Za-z_][A-Za-z0-9_]*)\s*$")
975
+ define = re.compile(r"^\s*#\s*define\s+([A-Za-z_][A-Za-z0-9_]*)\b")
976
+ candidate: str | None = None
977
+ for line in lines[:40]:
978
+ stripped = line.strip()
979
+ if (
980
+ not stripped
981
+ or stripped.startswith("//")
982
+ or stripped.startswith("/*")
983
+ or stripped.startswith("*")
984
+ ):
985
+ continue
986
+ if candidate is None:
987
+ match = ifndef.match(line)
988
+ if match is None:
989
+ if stripped.startswith("#pragma once"):
990
+ return None
991
+ continue
992
+ candidate = match.group(1)
993
+ continue
994
+ match = define.match(line)
995
+ if match is not None:
996
+ return candidate if match.group(1) == candidate else None
997
+ if not stripped.startswith("#"):
998
+ return None
999
+ return None
1000
+
1001
+
1002
+ def _macro_candidates(
1003
+ headers: Iterable[Path],
1004
+ project_root: Path,
1005
+ public_roots: tuple[Path, ...],
1006
+ active_definitions: dict[str, str],
1007
+ ) -> list[ApiEntity]:
1008
+ entities: list[ApiEntity] = []
1009
+ define = re.compile(r"^\s*#\s*define\s+([A-Za-z_][A-Za-z0-9_]*)(\([^\n]*?\))?(?:\s+(.*))?$")
1010
+ comment_lines: list[str] = []
1011
+ for header in headers:
1012
+ try:
1013
+ lines = header.read_text(encoding="utf-8").splitlines()
1014
+ except (OSError, UnicodeDecodeError):
1015
+ continue
1016
+ include_guard = _include_guard_name(lines)
1017
+ for number, line in enumerate(lines, 1):
1018
+ stripped = line.strip()
1019
+ if stripped.startswith("///"):
1020
+ comment_lines.append(stripped[3:].strip())
1021
+ continue
1022
+ match = define.match(line)
1023
+ if match:
1024
+ name = match.group(1)
1025
+ if name == include_guard:
1026
+ comment_lines.clear()
1027
+ continue
1028
+ args = match.group(2) or ""
1029
+ value = (match.group(3) or "").strip()
1030
+ candidate_tail = " ".join((args + ((" " + value) if value else "")).split())
1031
+ active_tail = active_definitions.get(name)
1032
+ if active_tail is None or candidate_tail != active_tail:
1033
+ comment_lines.clear()
1034
+ continue
1035
+ parameters: tuple[ApiParameter, ...] = ()
1036
+ if args:
1037
+ names = [item.strip() for item in args[1:-1].split(",") if item.strip()]
1038
+ parameters = tuple(ApiParameter(item) for item in names)
1039
+ entity = ApiEntity(
1040
+ id=stable_entity_id("cpp", "macro", name),
1041
+ language="cpp",
1042
+ kind="macro",
1043
+ name=name,
1044
+ qualified_name=name,
1045
+ signatures=[ApiSignature(parameters=parameters, spelling=line.strip())],
1046
+ documentation=" ".join(comment_lines).strip(),
1047
+ source=SourceLocation(
1048
+ _logical_public_path(header, public_roots, project_root), number, 1
1049
+ ),
1050
+ properties=[value] if value else [],
1051
+ )
1052
+ entities.append(entity)
1053
+ comment_lines.clear()
1054
+ continue
1055
+ if stripped and not stripped.startswith("//"):
1056
+ comment_lines.clear()
1057
+ return entities
1058
+
1059
+
1060
+ def _active_macro_definitions(
1061
+ *,
1062
+ clang_executable: str,
1063
+ frontend_args: list[str],
1064
+ umbrella: Path,
1065
+ cwd: Path,
1066
+ ) -> dict[str, str]:
1067
+ """Return macro definitions active under the exact semantic extraction configuration."""
1068
+
1069
+ result = subprocess.run(
1070
+ [clang_executable, *frontend_args, "-dM", "-E", str(umbrella)],
1071
+ cwd=cwd,
1072
+ check=False,
1073
+ text=True,
1074
+ stdout=subprocess.PIPE,
1075
+ stderr=subprocess.PIPE,
1076
+ )
1077
+ if result.returncode != 0:
1078
+ raise RuntimeError("Clang preprocessor macro discovery failed:\n" + result.stderr.strip())
1079
+ pattern = re.compile(r"^#define\s+([A-Za-z_][A-Za-z0-9_]*)(.*)$")
1080
+ values: dict[str, str] = {}
1081
+ for line in result.stdout.splitlines():
1082
+ match = pattern.match(line)
1083
+ if match is not None:
1084
+ values[match.group(1)] = " ".join(match.group(2).strip().split())
1085
+ return values
1086
+
1087
+
1088
+ def _parse_arguments(
1089
+ parse: ParseOptions, *, language: str, api_roots: tuple[Path, ...]
1090
+ ) -> tuple[Path | None, list[str]]:
1091
+ """Assemble the Clang frontend arguments described by a variant manifest.
1092
+
1093
+ A compilation database, when the build system provides one, contributes the options of one
1094
+ representative translation unit. The explicit manifest fields are appended afterwards so that
1095
+ they take precedence.
1096
+ """
1097
+
1098
+ directory: Path | None = None
1099
+ args: list[str] = []
1100
+ if parse.compile_database is not None:
1101
+ try:
1102
+ raw_entries = json.loads(parse.compile_database.read_text(encoding="utf-8"))
1103
+ except (OSError, json.JSONDecodeError) as error:
1104
+ raise RuntimeError(
1105
+ f"Cannot read compilation database {parse.compile_database}: {error}"
1106
+ ) from error
1107
+ entries = (
1108
+ [entry for entry in raw_entries if isinstance(entry, dict)]
1109
+ if isinstance(raw_entries, list)
1110
+ else []
1111
+ )
1112
+ entry = _choose_compile_entry(entries, language=language)
1113
+ if entry is not None:
1114
+ directory, args = _frontend_arguments(entry, public_roots=(), predefines=())
1115
+
1116
+ args.extend(["-x", "c" if language == "c" else "c++"])
1117
+ if parse.standard:
1118
+ args.append(f"-std={parse.standard}")
1119
+ for path in parse.include:
1120
+ args.append(f"-I{path}")
1121
+ for root in api_roots:
1122
+ args.append(f"-I{root}")
1123
+ for path in parse.system_include:
1124
+ args.extend(["-isystem", str(path)])
1125
+ names: set[str] = set()
1126
+ for value in parse.define:
1127
+ if value:
1128
+ args.append(f"-D{value}")
1129
+ names.add(value.split("=", 1)[0])
1130
+ if "__CUDACC__" in names or "__HIPCC__" in names:
1131
+ args.extend(["-D__host__=", "-D__device__=", "-D__global__="])
1132
+ args.extend(parse.flags)
1133
+ return directory, args
1134
+
1135
+
1136
+ def _relabel(graph: ApiGraph, language: str) -> None:
1137
+ """Relabel entities extracted by the shared C/C++ walker for a C unit."""
1138
+
1139
+ if language == "cpp":
1140
+ return
1141
+
1142
+ def relabel(identifier: str | None) -> str | None:
1143
+ if identifier is None or not identifier.startswith("cpp:"):
1144
+ return identifier
1145
+ return language + identifier[3:]
1146
+
1147
+ entities = list(graph.entities.values())
1148
+ graph.entities.clear()
1149
+ for entity in entities:
1150
+ entity.id = relabel(entity.id) or entity.id
1151
+ entity.parent = relabel(entity.parent)
1152
+ entity.language = language
1153
+ graph.entities[entity.id] = entity
1154
+ graph.language = language
1155
+
1156
+
1157
+ def extract_clang_graph(
1158
+ *,
1159
+ project: str,
1160
+ version: str,
1161
+ project_root: Path,
1162
+ language: str,
1163
+ api_roots: Iterable[Path],
1164
+ parse: ParseOptions,
1165
+ variant: str,
1166
+ clang_executable: str,
1167
+ work_directory: Path,
1168
+ ) -> ApiGraph:
1169
+ """Extract one variant of a C or C++ unit with Clang's semantic frontend."""
1170
+
1171
+ roots = tuple(path.resolve() for path in api_roots)
1172
+ missing = [root for root in roots if not root.is_dir()]
1173
+ if missing:
1174
+ raise RuntimeError(
1175
+ "API roots named in the variant manifest do not exist:\n"
1176
+ + "\n".join(f" - {root}" for root in missing)
1177
+ )
1178
+ headers = _public_headers(roots)
1179
+ graph = ApiGraph(project=project, version=version, language="cpp")
1180
+ for header in headers:
1181
+ try:
1182
+ graph.sources[_logical_public_path(header, roots, project_root)] = header.read_text(
1183
+ encoding="utf-8"
1184
+ )
1185
+ except (OSError, UnicodeDecodeError):
1186
+ pass
1187
+ if not headers:
1188
+ _relabel(graph, language)
1189
+ return graph
1190
+
1191
+ work_directory.mkdir(parents=True, exist_ok=True)
1192
+ umbrella = work_directory / ("cpdocs-api.c" if language == "c" else "cpdocs-api.cpp")
1193
+ _write_umbrella(umbrella, headers)
1194
+ cwd, frontend_args = _parse_arguments(parse, language=language, api_roots=roots)
1195
+
1196
+ command = [
1197
+ clang_executable,
1198
+ *frontend_args,
1199
+ "-fparse-all-comments",
1200
+ "-Wno-unknown-warning-option",
1201
+ "-Wno-ignored-attributes",
1202
+ "-Xclang",
1203
+ "-ast-dump=json",
1204
+ "-fsyntax-only",
1205
+ str(umbrella),
1206
+ ]
1207
+ result = subprocess.run(
1208
+ command,
1209
+ cwd=cwd or project_root,
1210
+ check=False,
1211
+ text=True,
1212
+ stdout=subprocess.PIPE,
1213
+ stderr=subprocess.PIPE,
1214
+ )
1215
+ if result.returncode != 0:
1216
+ raise RuntimeError(
1217
+ f"Clang semantic API extraction failed for variant {variant!r}:\n"
1218
+ + " ".join(command)
1219
+ + "\n\n"
1220
+ + result.stderr.strip()
1221
+ )
1222
+ try:
1223
+ tree = json.loads(result.stdout)
1224
+ except json.JSONDecodeError as error:
1225
+ raise RuntimeError(
1226
+ f"Clang returned invalid AST JSON for variant {variant!r}: {error}"
1227
+ ) from error
1228
+ if not isinstance(tree, dict):
1229
+ raise RuntimeError(f"Clang returned an unexpected AST root for variant {variant!r}")
1230
+
1231
+ _walk_ast(
1232
+ tree,
1233
+ graph=graph,
1234
+ project_root=project_root,
1235
+ public_roots=roots,
1236
+ file_state=[None],
1237
+ )
1238
+
1239
+ # The semantic AST deliberately does not represent macros as declarations. Discover only the
1240
+ # source macros that are active under this exact Clang configuration so conditional macros get
1241
+ # the same variant-aware availability semantics as declarations.
1242
+ active_macros = _active_macro_definitions(
1243
+ clang_executable=clang_executable,
1244
+ frontend_args=frontend_args,
1245
+ umbrella=umbrella,
1246
+ cwd=cwd or project_root,
1247
+ )
1248
+ for entity in _macro_candidates(headers, project_root, roots, active_macros):
1249
+ graph.add(entity)
1250
+
1251
+ graph.rebuild_children()
1252
+ _relabel(graph, language)
1253
+ graph.rebuild_children()
1254
+ return graph