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.
- cpdocs/__init__.py +5 -0
- cpdocs/__main__.py +5 -0
- cpdocs/build.py +645 -0
- cpdocs/clang_backend.py +1254 -0
- cpdocs/cli.py +78 -0
- cpdocs/config.py +160 -0
- cpdocs/contract.py +407 -0
- cpdocs/html_renderer.py +1803 -0
- cpdocs/model.py +228 -0
- cpdocs/properdocs_core.py +158 -0
- cpdocs/properdocs_plugin.py +236 -0
- cpdocs/python_backend.py +469 -0
- cpdocs/python_manifest.py +84 -0
- cpdocs/render.py +838 -0
- cpdocs/rust_backend.py +234 -0
- cpdocs/share/cmake/cpdocs.cmake +464 -0
- cpdocs/versioning.py +201 -0
- cpdocs-0.1.0.dist-info/METADATA +48 -0
- cpdocs-0.1.0.dist-info/RECORD +21 -0
- cpdocs-0.1.0.dist-info/WHEEL +4 -0
- cpdocs-0.1.0.dist-info/entry_points.txt +5 -0
cpdocs/clang_backend.py
ADDED
|
@@ -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
|