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/render.py
ADDED
|
@@ -0,0 +1,838 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 cpdocs developers
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
"""Shared rendering helpers for cpdocs' code-oriented API layout."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import html
|
|
10
|
+
import re
|
|
11
|
+
from collections.abc import Iterable
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
from pygments import highlight, lex
|
|
16
|
+
from pygments.formatters import HtmlFormatter
|
|
17
|
+
from pygments.lexers import CppLexer, PythonLexer, RustLexer, TextLexer
|
|
18
|
+
except ImportError: # pragma: no cover - optional dependency in some bootstrap environments
|
|
19
|
+
highlight = None
|
|
20
|
+
lex = None
|
|
21
|
+
HtmlFormatter = None
|
|
22
|
+
CppLexer = PythonLexer = RustLexer = TextLexer = None
|
|
23
|
+
|
|
24
|
+
from .model import ApiEntity, ApiGraph, ApiSignature
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def split_qualified(name: str, separator: str) -> list[str]:
|
|
28
|
+
"""Split a qualified name on ``separator`` outside template argument lists."""
|
|
29
|
+
|
|
30
|
+
parts: list[str] = []
|
|
31
|
+
depth = 0
|
|
32
|
+
start = 0
|
|
33
|
+
index = 0
|
|
34
|
+
while index < len(name):
|
|
35
|
+
character = name[index]
|
|
36
|
+
if character == "<":
|
|
37
|
+
depth += 1
|
|
38
|
+
elif character == ">":
|
|
39
|
+
depth = max(0, depth - 1)
|
|
40
|
+
elif depth == 0 and name.startswith(separator, index):
|
|
41
|
+
parts.append(name[start:index])
|
|
42
|
+
index += len(separator)
|
|
43
|
+
start = index
|
|
44
|
+
continue
|
|
45
|
+
index += 1
|
|
46
|
+
parts.append(name[start:])
|
|
47
|
+
return [part for part in parts if part]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _segments(entity: ApiEntity) -> list[str]:
|
|
51
|
+
separator = "." if entity.language == "python" else "::"
|
|
52
|
+
return split_qualified(entity.qualified_name, separator)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# Operator names as words, longest spelling first, so that ``operator<<`` is not read as ``<``.
|
|
56
|
+
_OPERATOR_WORDS = (
|
|
57
|
+
("<=>", "spaceship"),
|
|
58
|
+
("<<=", "shl-assign"),
|
|
59
|
+
(">>=", "shr-assign"),
|
|
60
|
+
("->*", "arrow-star"),
|
|
61
|
+
("new[]", "new-array"),
|
|
62
|
+
("delete[]", "delete-array"),
|
|
63
|
+
('""', "literal"),
|
|
64
|
+
("()", "call"),
|
|
65
|
+
("[]", "subscript"),
|
|
66
|
+
("->", "arrow"),
|
|
67
|
+
("<<", "shl"),
|
|
68
|
+
(">>", "shr"),
|
|
69
|
+
("==", "eq"),
|
|
70
|
+
("!=", "ne"),
|
|
71
|
+
("<=", "le"),
|
|
72
|
+
(">=", "ge"),
|
|
73
|
+
("&&", "and"),
|
|
74
|
+
("||", "or"),
|
|
75
|
+
("++", "inc"),
|
|
76
|
+
("--", "dec"),
|
|
77
|
+
("+=", "add-assign"),
|
|
78
|
+
("-=", "sub-assign"),
|
|
79
|
+
("*=", "mul-assign"),
|
|
80
|
+
("/=", "div-assign"),
|
|
81
|
+
("%=", "mod-assign"),
|
|
82
|
+
("&=", "and-assign"),
|
|
83
|
+
("|=", "or-assign"),
|
|
84
|
+
("^=", "xor-assign"),
|
|
85
|
+
("<", "lt"),
|
|
86
|
+
(">", "gt"),
|
|
87
|
+
("+", "add"),
|
|
88
|
+
("-", "sub"),
|
|
89
|
+
("*", "mul"),
|
|
90
|
+
("/", "div"),
|
|
91
|
+
("%", "mod"),
|
|
92
|
+
("&", "bitand"),
|
|
93
|
+
("|", "bitor"),
|
|
94
|
+
("^", "xor"),
|
|
95
|
+
("~", "compl"),
|
|
96
|
+
("!", "not"),
|
|
97
|
+
("=", "assign"),
|
|
98
|
+
(",", "comma"),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Symbols inside template arguments that must stay distinguishable, spelled as words.
|
|
102
|
+
_TYPE_WORDS = (
|
|
103
|
+
("...", " pack "),
|
|
104
|
+
("&&", " rref "),
|
|
105
|
+
("&", " ref "),
|
|
106
|
+
("*", " ptr "),
|
|
107
|
+
("[]", " array "),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _slug(value: str) -> str:
|
|
112
|
+
"""A readable URL segment for one name component.
|
|
113
|
+
|
|
114
|
+
``storage_traits<bool>`` becomes ``storage_traits-bool``, ``vec<T, 0>`` becomes ``vec-t-0``
|
|
115
|
+
and ``operator[]`` becomes ``operator-subscript``. Names that still collide after this are told
|
|
116
|
+
apart when a site is rendered, see :func:`assign_documents`.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
text = value.strip()
|
|
120
|
+
operator = re.fullmatch(r"operator\s*(.+)", text)
|
|
121
|
+
if operator is not None:
|
|
122
|
+
tail = operator.group(1).replace(" ", "")
|
|
123
|
+
for spelling, word in _OPERATOR_WORDS:
|
|
124
|
+
if tail == spelling:
|
|
125
|
+
return f"operator-{word}"
|
|
126
|
+
for spelling, word in _TYPE_WORDS:
|
|
127
|
+
text = text.replace(spelling, word)
|
|
128
|
+
cleaned = re.sub(r"[^A-Za-z0-9_]+", "-", text).strip("-").lower()
|
|
129
|
+
return cleaned or "symbol"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# Documents of entities whose readable path collides with another entity's, set per rendered site.
|
|
133
|
+
_DOCUMENT_OVERRIDES: dict[str, Path] = {}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def assign_documents(entities: Iterable[ApiEntity]) -> None:
|
|
137
|
+
"""Resolve path collisions between entities before a site is rendered.
|
|
138
|
+
|
|
139
|
+
Entities whose readable document path is shared get a short digest of their qualified name
|
|
140
|
+
appended; all other paths stay readable and do not depend on the rest of the API.
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
_DOCUMENT_OVERRIDES.clear()
|
|
144
|
+
by_path: dict[Path, list[ApiEntity]] = {}
|
|
145
|
+
for entity in entities:
|
|
146
|
+
by_path.setdefault(entity_document(entity), []).append(entity)
|
|
147
|
+
for path, values in by_path.items():
|
|
148
|
+
if len(values) < 2:
|
|
149
|
+
continue
|
|
150
|
+
for entity in values:
|
|
151
|
+
key = f"{entity.language}:{entity.kind}:{entity.qualified_name}"
|
|
152
|
+
digest = hashlib.sha1(key.encode("utf-8")).hexdigest()[:7]
|
|
153
|
+
if path.name == "index.md":
|
|
154
|
+
_DOCUMENT_OVERRIDES[entity.id] = (
|
|
155
|
+
path.parent.with_name(f"{path.parent.name}-{digest}") / "index.md"
|
|
156
|
+
)
|
|
157
|
+
else:
|
|
158
|
+
_DOCUMENT_OVERRIDES[entity.id] = path.with_name(f"{path.stem}-{digest}.md")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def entity_document(entity: ApiEntity) -> Path:
|
|
162
|
+
override = _DOCUMENT_OVERRIDES.get(entity.id)
|
|
163
|
+
if override is not None:
|
|
164
|
+
return override
|
|
165
|
+
parts = [_slug(part) for part in _segments(entity)]
|
|
166
|
+
if entity.kind in {"namespace", "module", "package"}:
|
|
167
|
+
return Path("api", *parts, "index.md")
|
|
168
|
+
if entity.kind in {"class", "struct", "union", "enum", "trait", "protocol"}:
|
|
169
|
+
if len(parts) > 1:
|
|
170
|
+
return Path("api", *parts[:-1], f"{entity.kind}-{parts[-1]}", "index.md")
|
|
171
|
+
return Path("api", f"{entity.kind}-{parts[0]}", "index.md")
|
|
172
|
+
if len(parts) > 1:
|
|
173
|
+
return Path("api", *parts[:-1], f"{entity.kind}-{parts[-1]}.md")
|
|
174
|
+
return Path("api", f"{entity.kind}-{parts[0]}.md")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _source_document(source_path: str) -> Path:
|
|
178
|
+
logical = Path(source_path)
|
|
179
|
+
return Path("_sources", *logical.parent.parts, logical.name + ".md")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _source_language(source_path: str) -> str:
|
|
183
|
+
suffix = Path(source_path).suffix.casefold()
|
|
184
|
+
if suffix in {".h", ".hh", ".hpp", ".hxx", ".c", ".cc", ".cpp", ".cxx", ".cu", ".cuh"}:
|
|
185
|
+
return "cpp"
|
|
186
|
+
if suffix in {".py", ".pyi"}:
|
|
187
|
+
return "python"
|
|
188
|
+
if suffix == ".rs":
|
|
189
|
+
return "rust"
|
|
190
|
+
return "text"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _pygments_lexer(language: str):
|
|
194
|
+
lexer_map = {
|
|
195
|
+
"c": CppLexer,
|
|
196
|
+
"cpp": CppLexer,
|
|
197
|
+
"python": PythonLexer,
|
|
198
|
+
"rust": RustLexer,
|
|
199
|
+
}
|
|
200
|
+
lexer_type = lexer_map.get(language, TextLexer)
|
|
201
|
+
return lexer_type() if lexer_type is not None else None
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
_FALLBACK_KEYWORDS = {
|
|
205
|
+
"cpp": {
|
|
206
|
+
"alignas",
|
|
207
|
+
"alignof",
|
|
208
|
+
"asm",
|
|
209
|
+
"auto",
|
|
210
|
+
"bool",
|
|
211
|
+
"break",
|
|
212
|
+
"case",
|
|
213
|
+
"catch",
|
|
214
|
+
"char",
|
|
215
|
+
"char8_t",
|
|
216
|
+
"char16_t",
|
|
217
|
+
"char32_t",
|
|
218
|
+
"class",
|
|
219
|
+
"concept",
|
|
220
|
+
"const",
|
|
221
|
+
"consteval",
|
|
222
|
+
"constexpr",
|
|
223
|
+
"constinit",
|
|
224
|
+
"const_cast",
|
|
225
|
+
"continue",
|
|
226
|
+
"co_await",
|
|
227
|
+
"co_return",
|
|
228
|
+
"co_yield",
|
|
229
|
+
"decltype",
|
|
230
|
+
"default",
|
|
231
|
+
"delete",
|
|
232
|
+
"do",
|
|
233
|
+
"double",
|
|
234
|
+
"dynamic_cast",
|
|
235
|
+
"else",
|
|
236
|
+
"enum",
|
|
237
|
+
"explicit",
|
|
238
|
+
"export",
|
|
239
|
+
"extern",
|
|
240
|
+
"false",
|
|
241
|
+
"float",
|
|
242
|
+
"for",
|
|
243
|
+
"friend",
|
|
244
|
+
"goto",
|
|
245
|
+
"if",
|
|
246
|
+
"inline",
|
|
247
|
+
"int",
|
|
248
|
+
"long",
|
|
249
|
+
"mutable",
|
|
250
|
+
"namespace",
|
|
251
|
+
"new",
|
|
252
|
+
"noexcept",
|
|
253
|
+
"nullptr",
|
|
254
|
+
"operator",
|
|
255
|
+
"private",
|
|
256
|
+
"protected",
|
|
257
|
+
"public",
|
|
258
|
+
"register",
|
|
259
|
+
"reinterpret_cast",
|
|
260
|
+
"requires",
|
|
261
|
+
"return",
|
|
262
|
+
"short",
|
|
263
|
+
"signed",
|
|
264
|
+
"sizeof",
|
|
265
|
+
"static",
|
|
266
|
+
"static_assert",
|
|
267
|
+
"static_cast",
|
|
268
|
+
"struct",
|
|
269
|
+
"switch",
|
|
270
|
+
"template",
|
|
271
|
+
"this",
|
|
272
|
+
"thread_local",
|
|
273
|
+
"throw",
|
|
274
|
+
"true",
|
|
275
|
+
"try",
|
|
276
|
+
"typedef",
|
|
277
|
+
"typeid",
|
|
278
|
+
"typename",
|
|
279
|
+
"union",
|
|
280
|
+
"unsigned",
|
|
281
|
+
"using",
|
|
282
|
+
"virtual",
|
|
283
|
+
"void",
|
|
284
|
+
"volatile",
|
|
285
|
+
"wchar_t",
|
|
286
|
+
"while",
|
|
287
|
+
},
|
|
288
|
+
"python": {
|
|
289
|
+
"and",
|
|
290
|
+
"as",
|
|
291
|
+
"assert",
|
|
292
|
+
"async",
|
|
293
|
+
"await",
|
|
294
|
+
"break",
|
|
295
|
+
"class",
|
|
296
|
+
"continue",
|
|
297
|
+
"def",
|
|
298
|
+
"del",
|
|
299
|
+
"elif",
|
|
300
|
+
"else",
|
|
301
|
+
"except",
|
|
302
|
+
"False",
|
|
303
|
+
"finally",
|
|
304
|
+
"for",
|
|
305
|
+
"from",
|
|
306
|
+
"global",
|
|
307
|
+
"if",
|
|
308
|
+
"import",
|
|
309
|
+
"in",
|
|
310
|
+
"is",
|
|
311
|
+
"lambda",
|
|
312
|
+
"None",
|
|
313
|
+
"nonlocal",
|
|
314
|
+
"not",
|
|
315
|
+
"or",
|
|
316
|
+
"pass",
|
|
317
|
+
"raise",
|
|
318
|
+
"return",
|
|
319
|
+
"True",
|
|
320
|
+
"try",
|
|
321
|
+
"while",
|
|
322
|
+
"with",
|
|
323
|
+
"yield",
|
|
324
|
+
},
|
|
325
|
+
"rust": {
|
|
326
|
+
"as",
|
|
327
|
+
"async",
|
|
328
|
+
"await",
|
|
329
|
+
"break",
|
|
330
|
+
"const",
|
|
331
|
+
"continue",
|
|
332
|
+
"crate",
|
|
333
|
+
"dyn",
|
|
334
|
+
"else",
|
|
335
|
+
"enum",
|
|
336
|
+
"extern",
|
|
337
|
+
"false",
|
|
338
|
+
"fn",
|
|
339
|
+
"for",
|
|
340
|
+
"if",
|
|
341
|
+
"impl",
|
|
342
|
+
"in",
|
|
343
|
+
"let",
|
|
344
|
+
"loop",
|
|
345
|
+
"match",
|
|
346
|
+
"mod",
|
|
347
|
+
"move",
|
|
348
|
+
"mut",
|
|
349
|
+
"pub",
|
|
350
|
+
"ref",
|
|
351
|
+
"return",
|
|
352
|
+
"self",
|
|
353
|
+
"Self",
|
|
354
|
+
"static",
|
|
355
|
+
"struct",
|
|
356
|
+
"super",
|
|
357
|
+
"trait",
|
|
358
|
+
"true",
|
|
359
|
+
"type",
|
|
360
|
+
"unsafe",
|
|
361
|
+
"use",
|
|
362
|
+
"where",
|
|
363
|
+
"while",
|
|
364
|
+
},
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
_FALLBACK_TOKEN = re.compile(
|
|
369
|
+
r"//[^\n]*|/\*.*?\*/|#[^\n]*|\"(?:\\.|[^\"\\])*\"|'(?:\\.|[^'\\])*'|"
|
|
370
|
+
r"\b(?:0[xX][0-9A-Fa-f]+|\d+(?:\.\d+)?)\b|\b[A-Za-z_]\w*\b|\s+|.",
|
|
371
|
+
re.DOTALL,
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _fallback_lex(code: str, language: str) -> list[tuple[str, str]]:
|
|
376
|
+
"""Small built-in highlighter used when Pygments is unavailable at render time."""
|
|
377
|
+
|
|
378
|
+
keywords = _FALLBACK_KEYWORDS.get("cpp" if language == "c" else language, set())
|
|
379
|
+
values: list[tuple[str, str]] = []
|
|
380
|
+
for match in _FALLBACK_TOKEN.finditer(code):
|
|
381
|
+
token = match.group(0)
|
|
382
|
+
if language in {"c", "cpp"} and token.startswith("#"):
|
|
383
|
+
kind = "preprocessor"
|
|
384
|
+
elif (
|
|
385
|
+
token.startswith("//")
|
|
386
|
+
or token.startswith("/*")
|
|
387
|
+
or (language == "python" and token.startswith("#"))
|
|
388
|
+
):
|
|
389
|
+
kind = "comment"
|
|
390
|
+
elif token.startswith(('"', "'")):
|
|
391
|
+
kind = "string"
|
|
392
|
+
elif re.fullmatch(r"(?:0[xX][0-9A-Fa-f]+|\d+(?:\.\d+)?)", token):
|
|
393
|
+
kind = "number"
|
|
394
|
+
elif re.fullmatch(r"[A-Za-z_]\w*", token):
|
|
395
|
+
kind = "keyword" if token in keywords else "name"
|
|
396
|
+
else:
|
|
397
|
+
kind = "plain"
|
|
398
|
+
values.append((kind, token))
|
|
399
|
+
return values
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _fallback_token_html(kind: str, value: str) -> str:
|
|
403
|
+
escaped = html.escape(value)
|
|
404
|
+
if kind == "plain" or not value.strip():
|
|
405
|
+
return escaped
|
|
406
|
+
return f'<span class="cpdocs-syntax-{kind}">{escaped}</span>'
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _token_style(formatter: HtmlFormatter, token_type) -> str:
|
|
410
|
+
style = formatter.style.style_for_token(token_type)
|
|
411
|
+
values: list[str] = []
|
|
412
|
+
if style.get("color"):
|
|
413
|
+
values.append(f"color: #{style['color']}")
|
|
414
|
+
if style.get("bgcolor"):
|
|
415
|
+
values.append(f"background-color: #{style['bgcolor']}")
|
|
416
|
+
if style.get("bold"):
|
|
417
|
+
values.append("font-weight: bold")
|
|
418
|
+
if style.get("italic"):
|
|
419
|
+
values.append("font-style: italic")
|
|
420
|
+
if style.get("underline"):
|
|
421
|
+
values.append("text-decoration: underline")
|
|
422
|
+
return "; ".join(values)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _highlight_source_lines(content: str, language: str) -> list[str]:
|
|
426
|
+
if lex is None or HtmlFormatter is None:
|
|
427
|
+
rendered: list[list[str]] = [[]]
|
|
428
|
+
for kind, value in _fallback_lex(content, language):
|
|
429
|
+
parts = value.split("\n")
|
|
430
|
+
for index, part in enumerate(parts):
|
|
431
|
+
if part:
|
|
432
|
+
rendered[-1].append(_fallback_token_html(kind, part))
|
|
433
|
+
if index + 1 < len(parts):
|
|
434
|
+
rendered.append([])
|
|
435
|
+
if content.endswith("\n") and rendered and not rendered[-1]:
|
|
436
|
+
rendered.pop()
|
|
437
|
+
return ["".join(parts) or " " for parts in rendered]
|
|
438
|
+
|
|
439
|
+
lexer = _pygments_lexer(language)
|
|
440
|
+
if lexer is None:
|
|
441
|
+
return [html.escape(value) or " " for value in content.splitlines()]
|
|
442
|
+
|
|
443
|
+
formatter = HtmlFormatter(noclasses=True, style="friendly")
|
|
444
|
+
rendered: list[list[str]] = [[]]
|
|
445
|
+
for token_type, value in lex(content, lexer):
|
|
446
|
+
style = _token_style(formatter, token_type)
|
|
447
|
+
parts = value.split("\n")
|
|
448
|
+
for index, part in enumerate(parts):
|
|
449
|
+
if part:
|
|
450
|
+
escaped = html.escape(part)
|
|
451
|
+
if style:
|
|
452
|
+
rendered[-1].append(
|
|
453
|
+
f'<span style="{html.escape(style, quote=True)}">{escaped}</span>'
|
|
454
|
+
)
|
|
455
|
+
else:
|
|
456
|
+
rendered[-1].append(escaped)
|
|
457
|
+
if index + 1 < len(parts):
|
|
458
|
+
rendered.append([])
|
|
459
|
+
|
|
460
|
+
# lex() normally preserves the final newline as an empty logical line. Do not add an extra
|
|
461
|
+
# displayed line for that terminator; line numbers should match the source file exactly.
|
|
462
|
+
if content.endswith("\n") and rendered and not rendered[-1]:
|
|
463
|
+
rendered.pop()
|
|
464
|
+
return ["".join(parts) or " " for parts in rendered]
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
_TYPE_LIKE_KINDS = {
|
|
468
|
+
"class",
|
|
469
|
+
"struct",
|
|
470
|
+
"union",
|
|
471
|
+
"enum",
|
|
472
|
+
"trait",
|
|
473
|
+
"protocol",
|
|
474
|
+
"type_alias",
|
|
475
|
+
"concept",
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
_CLASS_LIKE_KINDS = {"class", "struct", "union", "trait", "protocol"}
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _preferred_type_target(name: str, entity: ApiEntity, graph: ApiGraph) -> ApiEntity | None:
|
|
482
|
+
candidates = [
|
|
483
|
+
candidate
|
|
484
|
+
for candidate in graph.entities.values()
|
|
485
|
+
if candidate.kind in _TYPE_LIKE_KINDS and candidate.name == name
|
|
486
|
+
]
|
|
487
|
+
if not candidates:
|
|
488
|
+
return None
|
|
489
|
+
if entity.parent:
|
|
490
|
+
same_parent = [candidate for candidate in candidates if candidate.parent == entity.parent]
|
|
491
|
+
if len(same_parent) == 1:
|
|
492
|
+
return same_parent[0]
|
|
493
|
+
return candidates[0] if len(candidates) == 1 else None
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _embedded_member_parent(entity: ApiEntity, graph: ApiGraph) -> ApiEntity | None:
|
|
497
|
+
if not entity.parent or entity.parent not in graph.entities:
|
|
498
|
+
return None
|
|
499
|
+
parent = graph.entities[entity.parent]
|
|
500
|
+
return parent if parent.kind in _CLASS_LIKE_KINDS else None
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _member_anchor(entity: ApiEntity) -> str:
|
|
504
|
+
return f"member-{_slug(entity.kind)}-{_slug(entity.name)}"
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _entity_href(document_path: Path, entity: ApiEntity, graph: ApiGraph) -> str:
|
|
508
|
+
parent = _embedded_member_parent(entity, graph)
|
|
509
|
+
if parent is not None:
|
|
510
|
+
return _relative_link(document_path, entity_document(parent)) + f"#{_member_anchor(entity)}"
|
|
511
|
+
return _relative_link(document_path, entity_document(entity))
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _highlight_declaration(
|
|
515
|
+
code: str,
|
|
516
|
+
language: str,
|
|
517
|
+
*,
|
|
518
|
+
entity: ApiEntity,
|
|
519
|
+
graph: ApiGraph,
|
|
520
|
+
document_path: Path,
|
|
521
|
+
) -> str:
|
|
522
|
+
"""Highlight a declaration and add semantic links to known API types."""
|
|
523
|
+
|
|
524
|
+
if lex is None or HtmlFormatter is None:
|
|
525
|
+
values: list[str] = []
|
|
526
|
+
for kind, value in _fallback_lex(code, language):
|
|
527
|
+
token_html = _fallback_token_html(kind, value)
|
|
528
|
+
stripped = value.strip()
|
|
529
|
+
if kind == "name" and stripped:
|
|
530
|
+
target = _preferred_type_target(stripped, entity, graph)
|
|
531
|
+
if target is not None and target.id != entity.id:
|
|
532
|
+
href = _entity_href(document_path, target, graph)
|
|
533
|
+
token_html = (
|
|
534
|
+
f'<a class="cpdocs-api-signature-type" href="{html.escape(href)}">'
|
|
535
|
+
+ token_html
|
|
536
|
+
+ "</a>"
|
|
537
|
+
)
|
|
538
|
+
elif stripped == entity.name:
|
|
539
|
+
token_html = f'<span class="cpdocs-api-signature-name">{token_html}</span>'
|
|
540
|
+
values.append(token_html)
|
|
541
|
+
return "".join(values)
|
|
542
|
+
|
|
543
|
+
lexer = _pygments_lexer(language)
|
|
544
|
+
if lexer is None:
|
|
545
|
+
return html.escape(code)
|
|
546
|
+
|
|
547
|
+
formatter = HtmlFormatter(noclasses=True, style="friendly")
|
|
548
|
+
values: list[str] = []
|
|
549
|
+
tokens = list(lex(code, lexer))
|
|
550
|
+
if tokens and tokens[-1][1] == "\n":
|
|
551
|
+
tokens.pop()
|
|
552
|
+
for token_type, value in tokens:
|
|
553
|
+
if not value:
|
|
554
|
+
continue
|
|
555
|
+
style = _token_style(formatter, token_type)
|
|
556
|
+
escaped = html.escape(value)
|
|
557
|
+
token_html = (
|
|
558
|
+
f'<span style="{html.escape(style, quote=True)}">{escaped}</span>' if style else escaped
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
stripped = value.strip()
|
|
562
|
+
if stripped and re.fullmatch(r"[A-Za-z_]\w*", stripped):
|
|
563
|
+
target = _preferred_type_target(stripped, entity, graph)
|
|
564
|
+
if target is not None and target.id != entity.id:
|
|
565
|
+
href = _entity_href(document_path, target, graph)
|
|
566
|
+
token_html = (
|
|
567
|
+
f'<a class="cpdocs-api-signature-type" href="{html.escape(href)}">'
|
|
568
|
+
+ token_html
|
|
569
|
+
+ "</a>"
|
|
570
|
+
)
|
|
571
|
+
elif stripped == entity.name:
|
|
572
|
+
token_html = f'<span class="cpdocs-api-signature-name">{token_html}</span>'
|
|
573
|
+
values.append(token_html)
|
|
574
|
+
return "".join(values)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _parameter_spelling(signature: ApiSignature, language: str) -> str:
|
|
578
|
+
if language != "python":
|
|
579
|
+
values: list[str] = []
|
|
580
|
+
for parameter in signature.parameters:
|
|
581
|
+
value = parameter.type or parameter.name
|
|
582
|
+
if parameter.name and parameter.type:
|
|
583
|
+
value += f" {parameter.name}"
|
|
584
|
+
if parameter.default is not None:
|
|
585
|
+
value += f" = {parameter.default}"
|
|
586
|
+
values.append(value)
|
|
587
|
+
return ", ".join(values)
|
|
588
|
+
|
|
589
|
+
values: list[str] = []
|
|
590
|
+
parameters = list(signature.parameters)
|
|
591
|
+
positional_only = [item for item in parameters if item.kind == "positional-only"]
|
|
592
|
+
has_var_positional = any(item.kind == "var-positional" for item in parameters)
|
|
593
|
+
keyword_marker_emitted = False
|
|
594
|
+
for index, parameter in enumerate(parameters):
|
|
595
|
+
if (
|
|
596
|
+
parameter.kind == "keyword-only"
|
|
597
|
+
and not has_var_positional
|
|
598
|
+
and not keyword_marker_emitted
|
|
599
|
+
):
|
|
600
|
+
values.append("*")
|
|
601
|
+
keyword_marker_emitted = True
|
|
602
|
+
prefix = ""
|
|
603
|
+
if parameter.kind == "var-positional":
|
|
604
|
+
prefix = "*"
|
|
605
|
+
keyword_marker_emitted = True
|
|
606
|
+
elif parameter.kind == "var-keyword":
|
|
607
|
+
prefix = "**"
|
|
608
|
+
value = prefix + parameter.name
|
|
609
|
+
if parameter.type:
|
|
610
|
+
value += f": {parameter.type}"
|
|
611
|
+
if parameter.default is not None:
|
|
612
|
+
value += f" = {parameter.default}"
|
|
613
|
+
values.append(value)
|
|
614
|
+
if positional_only and index + 1 == len(positional_only):
|
|
615
|
+
values.append("/")
|
|
616
|
+
return ", ".join(values)
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _with_template(template: str, declaration: str) -> str:
|
|
620
|
+
return f"{template}\n{declaration}" if template else declaration
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _signature_spelling(entity: ApiEntity, signature: ApiSignature) -> str:
|
|
624
|
+
return _with_template(signature.template, _plain_signature_spelling(entity, signature))
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _plain_signature_spelling(entity: ApiEntity, signature: ApiSignature) -> str:
|
|
628
|
+
if signature.spelling:
|
|
629
|
+
return signature.spelling
|
|
630
|
+
parameters = _parameter_spelling(signature, entity.language)
|
|
631
|
+
if entity.language == "python":
|
|
632
|
+
prefix = "async def" if "async" in signature.qualifiers else "def"
|
|
633
|
+
value = f"{prefix} {entity.qualified_name}({parameters})"
|
|
634
|
+
if signature.returns:
|
|
635
|
+
value += f" -> {signature.returns}"
|
|
636
|
+
return value
|
|
637
|
+
if entity.language == "rust":
|
|
638
|
+
modifiers = [value for value in entity.properties if value != "pub"]
|
|
639
|
+
prefix = "pub " + ((" ".join(modifiers) + " ") if modifiers else "")
|
|
640
|
+
value = f"{prefix}fn {entity.qualified_name}({parameters})"
|
|
641
|
+
if signature.returns:
|
|
642
|
+
value += f" -> {signature.returns}"
|
|
643
|
+
return value
|
|
644
|
+
prefix_values = [
|
|
645
|
+
value
|
|
646
|
+
for value in entity.properties
|
|
647
|
+
if value in {"inline", "constexpr", "consteval", "static", "virtual"}
|
|
648
|
+
]
|
|
649
|
+
prefix = (" ".join(prefix_values) + " ") if prefix_values else ""
|
|
650
|
+
result = (signature.returns + " ") if signature.returns else ""
|
|
651
|
+
suffix_values = [value for value in signature.qualifiers if value not in prefix_values]
|
|
652
|
+
suffix = (" " + " ".join(suffix_values)) if suffix_values else ""
|
|
653
|
+
return f"{prefix}{result}{entity.qualified_name}({parameters}){suffix}".strip()
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _entity_declaration(entity: ApiEntity) -> str:
|
|
657
|
+
if entity.language == "python":
|
|
658
|
+
if entity.kind in {"class", "protocol"}:
|
|
659
|
+
bases = f"({', '.join(entity.bases)})" if entity.bases else ""
|
|
660
|
+
return f"class {entity.qualified_name}{bases}"
|
|
661
|
+
if entity.kind in {"module", "package"}:
|
|
662
|
+
return f"module {entity.qualified_name}"
|
|
663
|
+
if entity.kind == "type_alias":
|
|
664
|
+
target = next((value[2:] for value in entity.properties if value.startswith("= ")), "")
|
|
665
|
+
return f"{entity.qualified_name} = {target}".rstrip()
|
|
666
|
+
if entity.kind in {"attribute", "variable", "constant", "property"}:
|
|
667
|
+
type_name = next(
|
|
668
|
+
(
|
|
669
|
+
value
|
|
670
|
+
for value in entity.properties
|
|
671
|
+
if not value.startswith("=") and value not in {"property"}
|
|
672
|
+
),
|
|
673
|
+
"",
|
|
674
|
+
)
|
|
675
|
+
assigned = next(
|
|
676
|
+
(value[2:] for value in entity.properties if value.startswith("= ")), ""
|
|
677
|
+
)
|
|
678
|
+
value = entity.qualified_name + (f": {type_name}" if type_name else "")
|
|
679
|
+
return value + (f" = {assigned}" if assigned else "")
|
|
680
|
+
return entity.qualified_name
|
|
681
|
+
|
|
682
|
+
if entity.language == "rust":
|
|
683
|
+
prefix = "pub " if "pub" in entity.properties else ""
|
|
684
|
+
keyword = {
|
|
685
|
+
"struct": "struct",
|
|
686
|
+
"enum": "enum",
|
|
687
|
+
"trait": "trait",
|
|
688
|
+
"module": "mod",
|
|
689
|
+
"type_alias": "type",
|
|
690
|
+
"constant": "const",
|
|
691
|
+
}.get(entity.kind, entity.kind)
|
|
692
|
+
return f"{prefix}{keyword} {entity.qualified_name}".strip()
|
|
693
|
+
|
|
694
|
+
if entity.declaration:
|
|
695
|
+
return entity.declaration
|
|
696
|
+
return _with_template(entity.template, _cpp_declaration(entity))
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def _cpp_declaration(entity: ApiEntity) -> str:
|
|
700
|
+
if entity.kind == "namespace":
|
|
701
|
+
return f"namespace {entity.qualified_name}"
|
|
702
|
+
if entity.kind in {"class", "struct", "union"}:
|
|
703
|
+
bases = f" : {', '.join(entity.bases)}" if entity.bases else ""
|
|
704
|
+
return f"{entity.kind} {entity.qualified_name}{bases}"
|
|
705
|
+
if entity.kind == "enum":
|
|
706
|
+
scoped = " class" if "scoped" in entity.properties else ""
|
|
707
|
+
return f"enum{scoped} {entity.qualified_name}"
|
|
708
|
+
if entity.kind == "type_alias":
|
|
709
|
+
target = next((value[2:] for value in entity.properties if value.startswith("= ")), "")
|
|
710
|
+
return f"using {entity.qualified_name} = {target}".rstrip()
|
|
711
|
+
if entity.kind in {"attribute", "variable", "constant"}:
|
|
712
|
+
type_name = next((value for value in entity.properties if not value.startswith("=")), "")
|
|
713
|
+
value = next((value[2:] for value in entity.properties if value.startswith("= ")), "")
|
|
714
|
+
return f"{type_name} {entity.qualified_name}".strip() + (f" = {value}" if value else "")
|
|
715
|
+
if entity.kind == "concept":
|
|
716
|
+
return f"concept {entity.qualified_name}"
|
|
717
|
+
if entity.kind == "macro" and entity.signatures:
|
|
718
|
+
return entity.signatures[0].spelling or entity.name
|
|
719
|
+
return entity.qualified_name
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def _paragraphs(text: str) -> str:
|
|
723
|
+
if not text.strip():
|
|
724
|
+
return '<p class="cpdocs-api-undocumented">No API documentation has been provided.</p>'
|
|
725
|
+
return "\n".join(
|
|
726
|
+
f"<p>{html.escape(paragraph.strip())}</p>"
|
|
727
|
+
for paragraph in re.split(r"\n\s*\n", text.strip())
|
|
728
|
+
if paragraph.strip()
|
|
729
|
+
)
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def _variant_anchor(name: str) -> str:
|
|
733
|
+
return "variant-" + _slug(name)
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def _resolve_related_entity(reference: str, entity: ApiEntity, graph: ApiGraph) -> ApiEntity | None:
|
|
737
|
+
normalized = reference.strip()
|
|
738
|
+
if not normalized:
|
|
739
|
+
return None
|
|
740
|
+
|
|
741
|
+
if "::" in normalized or "." in normalized:
|
|
742
|
+
for candidate in graph.entities.values():
|
|
743
|
+
if candidate.qualified_name == normalized or candidate.name == normalized:
|
|
744
|
+
return candidate
|
|
745
|
+
|
|
746
|
+
if entity.parent and entity.parent in graph.entities:
|
|
747
|
+
parent = graph.entities[entity.parent]
|
|
748
|
+
for child_id in parent.children:
|
|
749
|
+
child = graph.entities[child_id]
|
|
750
|
+
if child.name == normalized:
|
|
751
|
+
return child
|
|
752
|
+
|
|
753
|
+
matches = [candidate for candidate in graph.entities.values() if candidate.name == normalized]
|
|
754
|
+
return matches[0] if len(matches) == 1 else None
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def _documentation_blocks(
|
|
758
|
+
entity: ApiEntity, graph: ApiGraph
|
|
759
|
+
) -> tuple[str, list[tuple[str, ApiEntity | None]]]:
|
|
760
|
+
related_names = list(entity.related)
|
|
761
|
+
paragraphs: list[str] = []
|
|
762
|
+
for block in re.split(r"\n\s*\n", entity.documentation.strip()):
|
|
763
|
+
paragraph = block.strip()
|
|
764
|
+
if not paragraph:
|
|
765
|
+
continue
|
|
766
|
+
if paragraph.startswith("CPDOCS-API-RELATES-TO:"):
|
|
767
|
+
payload = paragraph.split(":", 1)[1]
|
|
768
|
+
for item in re.split(r"\s*,\s*", payload.strip()):
|
|
769
|
+
if item and item not in related_names:
|
|
770
|
+
related_names.append(item)
|
|
771
|
+
continue
|
|
772
|
+
paragraphs.append(paragraph)
|
|
773
|
+
text = "\n\n".join(paragraphs)
|
|
774
|
+
related = [(name, _resolve_related_entity(name, entity, graph)) for name in related_names]
|
|
775
|
+
return _paragraphs(text), related
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def _variant_display_name(name: str) -> str:
|
|
779
|
+
if name.casefold() in {"cpu", "cuda", "hip", "mpi", "sycl"}:
|
|
780
|
+
return name.upper()
|
|
781
|
+
return name
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def _member_summary_spelling(entity: ApiEntity) -> str:
|
|
785
|
+
if entity.kind in {"attribute", "variable", "constant"}:
|
|
786
|
+
type_name = next((value for value in entity.properties if not value.startswith("=")), "")
|
|
787
|
+
value = next((value[2:] for value in entity.properties if value.startswith("= ")), "")
|
|
788
|
+
if entity.language == "python":
|
|
789
|
+
result = entity.name + (f": {type_name}" if type_name else "")
|
|
790
|
+
return result + (f" = {value}" if value else "")
|
|
791
|
+
return f"{type_name} {entity.name}".strip() + (f" = {value}" if value else "")
|
|
792
|
+
if entity.kind == "type_alias":
|
|
793
|
+
target = next((value[2:] for value in entity.properties if value.startswith("= ")), "")
|
|
794
|
+
if entity.language == "python":
|
|
795
|
+
return f"{entity.name} = {target}".rstrip()
|
|
796
|
+
return _with_template(entity.template, f"using {entity.name} = {target}".rstrip())
|
|
797
|
+
if entity.kind in {"class", "struct", "union", "enum", "trait", "protocol"}:
|
|
798
|
+
return f"{entity.kind} {entity.name}"
|
|
799
|
+
return entity.name
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def _relative_link(source: Path, target: Path) -> str:
|
|
803
|
+
import posixpath
|
|
804
|
+
|
|
805
|
+
def output_directory(document: Path) -> Path:
|
|
806
|
+
if document.name == "index.md":
|
|
807
|
+
return document.parent
|
|
808
|
+
return document.with_suffix("")
|
|
809
|
+
|
|
810
|
+
source_dir = output_directory(source)
|
|
811
|
+
target_dir = output_directory(target)
|
|
812
|
+
value = posixpath.relpath(target_dir.as_posix(), source_dir.as_posix())
|
|
813
|
+
return "./" if value == "." else value.rstrip("/") + "/"
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def _kind_marker(kind: str) -> str:
|
|
817
|
+
return {
|
|
818
|
+
"namespace": "N",
|
|
819
|
+
"module": "M",
|
|
820
|
+
"package": "P",
|
|
821
|
+
"class": "C",
|
|
822
|
+
"struct": "S",
|
|
823
|
+
"union": "U",
|
|
824
|
+
"enum": "E",
|
|
825
|
+
"trait": "T",
|
|
826
|
+
"protocol": "P",
|
|
827
|
+
"function": "F",
|
|
828
|
+
"method": "F",
|
|
829
|
+
"constructor": "F",
|
|
830
|
+
"type_alias": "T",
|
|
831
|
+
"variable": "V",
|
|
832
|
+
"attribute": "A",
|
|
833
|
+
"property": "P",
|
|
834
|
+
"constant": "C",
|
|
835
|
+
"macro": "D",
|
|
836
|
+
"concept": "K",
|
|
837
|
+
"deduction_guide": "G",
|
|
838
|
+
}.get(kind, "·")
|