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/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, "·")