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/rust_backend.py ADDED
@@ -0,0 +1,234 @@
1
+ # SPDX-FileCopyrightText: 2026 cpdocs developers
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Rustdoc-JSON backend for the shared cpdocs API graph."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ from pathlib import Path
9
+
10
+ from .model import ApiEntity, ApiGraph, ApiParameter, ApiSignature, SourceLocation, stable_entity_id
11
+
12
+ _KIND_MAP = {
13
+ "module": "module",
14
+ "struct": "struct",
15
+ "enum": "enum",
16
+ "trait": "trait",
17
+ "function": "function",
18
+ "type_alias": "type_alias",
19
+ "constant": "constant",
20
+ "static": "variable",
21
+ "macro": "macro",
22
+ "proc_macro": "macro",
23
+ "union": "union",
24
+ }
25
+
26
+
27
+ def _rust_type(value: object) -> str:
28
+ """Render the stable, user-facing portion of a rustdoc JSON type node."""
29
+
30
+ if value is None:
31
+ return ""
32
+ if isinstance(value, str):
33
+ return value
34
+ if isinstance(value, list):
35
+ return ", ".join(filter(None, (_rust_type(item) for item in value)))
36
+ if not isinstance(value, dict) or not value:
37
+ return ""
38
+ if "primitive" in value:
39
+ return str(value["primitive"])
40
+ if "generic" in value:
41
+ return str(value["generic"])
42
+ if "infer" in value:
43
+ return "_"
44
+ if "slice" in value:
45
+ return f"[{_rust_type(value['slice'])}]"
46
+ if "array" in value and isinstance(value["array"], dict):
47
+ array = value["array"]
48
+ length = array.get("len", "_")
49
+ return f"[{_rust_type(array.get('type'))}; {length}]"
50
+ if "tuple" in value:
51
+ parts = value["tuple"] if isinstance(value["tuple"], list) else []
52
+ rendered = ", ".join(_rust_type(item) for item in parts)
53
+ if len(parts) == 1:
54
+ rendered += ","
55
+ return f"({rendered})"
56
+ if "borrowed_ref" in value and isinstance(value["borrowed_ref"], dict):
57
+ ref = value["borrowed_ref"]
58
+ lifetime = ref.get("lifetime")
59
+ lifetime_text = f"'{lifetime} " if isinstance(lifetime, str) and lifetime else ""
60
+ mutable = "mut " if ref.get("is_mutable") else ""
61
+ return f"&{lifetime_text}{mutable}{_rust_type(ref.get('type'))}"
62
+ if "raw_pointer" in value and isinstance(value["raw_pointer"], dict):
63
+ pointer = value["raw_pointer"]
64
+ qualifier = "mut" if pointer.get("is_mutable") else "const"
65
+ return f"*{qualifier} {_rust_type(pointer.get('type'))}"
66
+ if "resolved_path" in value and isinstance(value["resolved_path"], dict):
67
+ path = value["resolved_path"]
68
+ name = str(path.get("name", ""))
69
+ args = path.get("args")
70
+ rendered_args: list[str] = []
71
+ if isinstance(args, dict):
72
+ angle = args.get("angle_bracketed")
73
+ if isinstance(angle, dict):
74
+ raw_args = angle.get("args")
75
+ if isinstance(raw_args, list):
76
+ for argument in raw_args:
77
+ if not isinstance(argument, dict):
78
+ continue
79
+ if "type" in argument:
80
+ rendered_args.append(_rust_type(argument["type"]))
81
+ elif "lifetime" in argument:
82
+ rendered_args.append(str(argument["lifetime"]))
83
+ elif "const" in argument:
84
+ rendered_args.append(str(argument["const"]))
85
+ if rendered_args:
86
+ name += "<" + ", ".join(rendered_args) + ">"
87
+ return name
88
+ if "impl_trait" in value:
89
+ bounds = value["impl_trait"] if isinstance(value["impl_trait"], list) else []
90
+ return "impl " + " + ".join(filter(None, (_rust_type(item) for item in bounds)))
91
+ if "dyn_trait" in value:
92
+ traits = value["dyn_trait"] if isinstance(value["dyn_trait"], list) else []
93
+ return "dyn " + " + ".join(filter(None, (_rust_type(item) for item in traits)))
94
+ # rustdoc evolves its JSON schema. Unknown type variants stay localized to this adapter rather
95
+ # than leaking backend-specific structure into ApiGraph or the renderer.
96
+ return next((_rust_type(item) for item in value.values() if _rust_type(item)), "")
97
+
98
+
99
+ def _function_signature(item: dict[str, object]) -> tuple[ApiSignature | None, list[str]]:
100
+ inner = item.get("inner")
101
+ if not isinstance(inner, dict):
102
+ return None, []
103
+ function = inner.get("function")
104
+ if not isinstance(function, dict):
105
+ return None, []
106
+ signature = function.get("sig")
107
+ parameters: list[ApiParameter] = []
108
+ returns = ""
109
+ if isinstance(signature, dict):
110
+ inputs = signature.get("inputs")
111
+ if isinstance(inputs, list):
112
+ for raw in inputs:
113
+ if isinstance(raw, list) and len(raw) >= 2:
114
+ parameters.append(ApiParameter(name=str(raw[0]), type=_rust_type(raw[1])))
115
+ returns = _rust_type(signature.get("output"))
116
+ properties: list[str] = ["pub"]
117
+ header = function.get("header")
118
+ if isinstance(header, dict):
119
+ for field, label in (("is_const", "const"), ("is_async", "async"), ("is_unsafe", "unsafe")):
120
+ if header.get(field):
121
+ properties.append(label)
122
+ abi = header.get("abi")
123
+ if isinstance(abi, str) and abi not in {"", "Rust"}:
124
+ properties.append(f'extern "{abi}"')
125
+ return ApiSignature(
126
+ parameters=tuple(parameters), returns=returns, qualifiers=tuple(properties[1:])
127
+ ), properties
128
+
129
+
130
+ def _inner_kind(item: dict[str, object]) -> str | None:
131
+ inner = item.get("inner")
132
+ if not isinstance(inner, dict) or not inner:
133
+ return None
134
+ return next(iter(inner))
135
+
136
+
137
+ def build_rust_graph(
138
+ *, rustdoc_json: Path, project: str, version: str = "main", project_root: Path | None = None
139
+ ) -> ApiGraph:
140
+ try:
141
+ raw = json.loads(rustdoc_json.read_text(encoding="utf-8"))
142
+ except (OSError, json.JSONDecodeError) as error:
143
+ raise RuntimeError(f"Cannot read rustdoc JSON {rustdoc_json}: {error}") from error
144
+ if not isinstance(raw, dict):
145
+ raise RuntimeError(f"Unexpected rustdoc JSON root in {rustdoc_json}")
146
+
147
+ index = raw.get("index")
148
+ paths = raw.get("paths")
149
+ if not isinstance(index, dict) or not isinstance(paths, dict):
150
+ raise RuntimeError("rustdoc JSON does not contain index/paths maps")
151
+
152
+ graph = ApiGraph(project=project, version=version, language="rust")
153
+ rust_to_entity: dict[str, str] = {}
154
+ path_to_id: dict[str, str] = {}
155
+
156
+ for rust_id, raw_item in index.items():
157
+ if not isinstance(raw_item, dict):
158
+ continue
159
+ visibility = raw_item.get("visibility")
160
+ if visibility not in {"public", None} and not (
161
+ isinstance(visibility, dict) and "public" in visibility
162
+ ):
163
+ continue
164
+ kind_value = _inner_kind(raw_item)
165
+ kind = _KIND_MAP.get(kind_value or "")
166
+ if kind is None:
167
+ continue
168
+ path_info = paths.get(rust_id)
169
+ path_values = path_info.get("path") if isinstance(path_info, dict) else None
170
+ if isinstance(path_values, list) and path_values:
171
+ qualified = "::".join(str(value) for value in path_values)
172
+ else:
173
+ name = raw_item.get("name")
174
+ if not isinstance(name, str) or not name:
175
+ continue
176
+ qualified = name
177
+ name = str(raw_item.get("name") or qualified.rsplit("::", 1)[-1])
178
+ entity_id = stable_entity_id("rust", kind, qualified)
179
+ source = None
180
+ span = raw_item.get("span")
181
+ if isinstance(span, dict) and isinstance(span.get("filename"), str):
182
+ filename = Path(str(span["filename"]))
183
+ if project_root is not None:
184
+ try:
185
+ filename_text = (
186
+ filename.resolve().relative_to(project_root.resolve()).as_posix()
187
+ )
188
+ except (OSError, ValueError):
189
+ filename_text = filename.as_posix()
190
+ else:
191
+ filename_text = filename.as_posix()
192
+ begin = span.get("begin")
193
+ line = None
194
+ column = None
195
+ if isinstance(begin, list) and len(begin) >= 2:
196
+ if isinstance(begin[0], int):
197
+ line = begin[0]
198
+ if isinstance(begin[1], int):
199
+ column = begin[1]
200
+ source = SourceLocation(filename_text, line, column)
201
+ if project_root is not None:
202
+ physical = filename if filename.is_absolute() else project_root / filename
203
+ try:
204
+ graph.sources.setdefault(filename_text, physical.read_text(encoding="utf-8"))
205
+ except (OSError, UnicodeDecodeError):
206
+ pass
207
+ function_signature, function_properties = _function_signature(raw_item)
208
+ entity = ApiEntity(
209
+ id=entity_id,
210
+ language="rust",
211
+ kind=kind,
212
+ name=name,
213
+ qualified_name=qualified,
214
+ signatures=[function_signature] if function_signature is not None else [],
215
+ documentation=str(raw_item.get("docs") or ""),
216
+ source=source,
217
+ properties=function_properties or ["pub"],
218
+ )
219
+ graph.add(entity)
220
+ rust_to_entity[str(rust_id)] = entity_id
221
+ path_to_id[qualified] = entity_id
222
+
223
+ # Infer hierarchy from semantic paths. rustdoc ids are deliberately not exposed outside this
224
+ # adapter; renderer URLs depend only on the normalized cpdocs identity.
225
+ for entity in graph.entities.values():
226
+ parent_path = (
227
+ entity.qualified_name.rsplit("::", 1)[0] if "::" in entity.qualified_name else ""
228
+ )
229
+ parent = path_to_id.get(parent_path)
230
+ if parent and parent != entity.id:
231
+ entity.parent = parent
232
+
233
+ graph.rebuild_children()
234
+ return graph