pycforge 0.15.2__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.
Files changed (221) hide show
  1. pycforge/__init__.py +8 -0
  2. pycforge/__main__.py +2 -0
  3. pycforge/_version.py +1 -0
  4. pycforge/converter/__init__.py +0 -0
  5. pycforge/converter/analysis/__init__.py +5 -0
  6. pycforge/converter/analysis/functions.py +723 -0
  7. pycforge/converter/analysis/model.py +158 -0
  8. pycforge/converter/analysis/planning.py +464 -0
  9. pycforge/converter/analysis/stage.py +1976 -0
  10. pycforge/converter/analysis/symbols.py +237 -0
  11. pycforge/converter/analysis/validation.py +2166 -0
  12. pycforge/converter/c_output/__init__.py +2 -0
  13. pycforge/converter/c_output/conformance.py +297 -0
  14. pycforge/converter/c_output/renderer.py +220 -0
  15. pycforge/converter/conditional_regions/__init__.py +71 -0
  16. pycforge/converter/conditional_regions/analysis.py +481 -0
  17. pycforge/converter/conditional_regions/lowering.py +579 -0
  18. pycforge/converter/conditional_regions/model.py +243 -0
  19. pycforge/converter/conditional_regions/validation.py +690 -0
  20. pycforge/converter/containers/__init__.py +19 -0
  21. pycforge/converter/containers/analysis.py +542 -0
  22. pycforge/converter/containers/lowering.py +290 -0
  23. pycforge/converter/containers/model.py +146 -0
  24. pycforge/converter/contracts/__init__.py +0 -0
  25. pycforge/converter/contracts/configuration.py +178 -0
  26. pycforge/converter/contracts/identifiers.py +68 -0
  27. pycforge/converter/contracts/versions.py +71 -0
  28. pycforge/converter/core/__init__.py +0 -0
  29. pycforge/converter/core/artifact_io.py +87 -0
  30. pycforge/converter/core/cancellation.py +7 -0
  31. pycforge/converter/core/canonicalization.py +254 -0
  32. pycforge/converter/core/context.py +13 -0
  33. pycforge/converter/core/diagnostics.py +95 -0
  34. pycforge/converter/core/enums.py +23 -0
  35. pycforge/converter/core/fingerprint.py +22 -0
  36. pycforge/converter/core/pipeline.py +8 -0
  37. pycforge/converter/core/progress.py +25 -0
  38. pycforge/converter/core/request.py +50 -0
  39. pycforge/converter/core/resource_policy.py +31 -0
  40. pycforge/converter/core/result.py +23 -0
  41. pycforge/converter/core/serialization.py +36 -0
  42. pycforge/converter/core/stage.py +10 -0
  43. pycforge/converter/core/stage_artifact.py +80 -0
  44. pycforge/converter/core/stage_outcome.py +15 -0
  45. pycforge/converter/decision_trace/__init__.py +0 -0
  46. pycforge/converter/decision_trace/recorder.py +52 -0
  47. pycforge/converter/facade.py +459 -0
  48. pycforge/converter/frontend/__init__.py +4 -0
  49. pycforge/converter/frontend/normalizer.py +59 -0
  50. pycforge/converter/frontend/parser.py +20 -0
  51. pycforge/converter/frontend/source_document.py +93 -0
  52. pycforge/converter/frontend/stages.py +518 -0
  53. pycforge/converter/frontend/validation.py +76 -0
  54. pycforge/converter/io/__init__.py +0 -0
  55. pycforge/converter/io/atomic_writer.py +33 -0
  56. pycforge/converter/ir/__init__.py +1 -0
  57. pycforge/converter/ir/c_ir/__init__.py +4 -0
  58. pycforge/converter/ir/c_ir/builder.py +26 -0
  59. pycforge/converter/ir/c_ir/model.py +318 -0
  60. pycforge/converter/ir/c_ir/serialization.py +88 -0
  61. pycforge/converter/ir/c_ir/validation.py +834 -0
  62. pycforge/converter/ir/python_ir/__init__.py +17 -0
  63. pycforge/converter/ir/python_ir/nodes.py +86 -0
  64. pycforge/converter/keyword_calls/__init__.py +68 -0
  65. pycforge/converter/keyword_calls/analysis.py +458 -0
  66. pycforge/converter/keyword_calls/lowering.py +271 -0
  67. pycforge/converter/keyword_calls/model.py +304 -0
  68. pycforge/converter/keyword_calls/validation.py +802 -0
  69. pycforge/converter/keyword_only_calls/__init__.py +68 -0
  70. pycforge/converter/keyword_only_calls/analysis.py +640 -0
  71. pycforge/converter/keyword_only_calls/lowering.py +376 -0
  72. pycforge/converter/keyword_only_calls/model.py +328 -0
  73. pycforge/converter/keyword_only_calls/validation.py +1357 -0
  74. pycforge/converter/lowering.py +1000 -0
  75. pycforge/converter/modules/__init__.py +13 -0
  76. pycforge/converter/modules/analysis.py +1250 -0
  77. pycforge/converter/modules/lowering.py +200 -0
  78. pycforge/converter/modules/model.py +73 -0
  79. pycforge/converter/modules/stage.py +192 -0
  80. pycforge/converter/numeric_semantics/__init__.py +23 -0
  81. pycforge/converter/numeric_semantics/analysis.py +420 -0
  82. pycforge/converter/numeric_semantics/lowering.py +153 -0
  83. pycforge/converter/numeric_semantics/model.py +123 -0
  84. pycforge/converter/records/__init__.py +34 -0
  85. pycforge/converter/records/analysis.py +1394 -0
  86. pycforge/converter/records/lowering.py +393 -0
  87. pycforge/converter/records/model.py +319 -0
  88. pycforge/converter/rules/__init__.py +3 -0
  89. pycforge/converter/support_templates/__init__.py +49 -0
  90. pycforge/converter/support_templates/assembly.py +106 -0
  91. pycforge/converter/support_templates/factories.py +348 -0
  92. pycforge/converter/support_templates/model.py +159 -0
  93. pycforge/converter/support_templates/registry.py +388 -0
  94. pycforge/converter/telemetry/__init__.py +0 -0
  95. pycforge/converter/telemetry/sink.py +39 -0
  96. pycforge/ide/__init__.py +19 -0
  97. pycforge/ide/__main__.py +5 -0
  98. pycforge/ide/_worker_event_codec.py +276 -0
  99. pycforge/ide/_worker_protocol_json.py +197 -0
  100. pycforge/ide/_worker_protocol_types.py +90 -0
  101. pycforge/ide/_worker_request_codec.py +200 -0
  102. pycforge/ide/_worker_request_data.py +260 -0
  103. pycforge/ide/_worker_result_data.py +343 -0
  104. pycforge/ide/action_contract.py +469 -0
  105. pycforge/ide/action_validation.py +229 -0
  106. pycforge/ide/command_palette.py +223 -0
  107. pycforge/ide/controller.py +904 -0
  108. pycforge/ide/controller_conversion.py +546 -0
  109. pycforge/ide/controller_io.py +205 -0
  110. pycforge/ide/editor.py +951 -0
  111. pycforge/ide/editor_commands_qt.py +274 -0
  112. pycforge/ide/editor_lexical.py +43 -0
  113. pycforge/ide/editor_sidebars.py +236 -0
  114. pycforge/ide/editor_syntax.py +243 -0
  115. pycforge/ide/find_replace.py +535 -0
  116. pycforge/ide/icons.py +91 -0
  117. pycforge/ide/io_service.py +196 -0
  118. pycforge/ide/model.py +164 -0
  119. pycforge/ide/panels.py +903 -0
  120. pycforge/ide/positions.py +220 -0
  121. pycforge/ide/process_worker.py +130 -0
  122. pycforge/ide/qt.py +230 -0
  123. pycforge/ide/qt_actions.py +418 -0
  124. pycforge/ide/qt_close.py +169 -0
  125. pycforge/ide/qt_command_palette.py +297 -0
  126. pycforge/ide/qt_contract.py +220 -0
  127. pycforge/ide/qt_documents.py +590 -0
  128. pycforge/ide/qt_editor_buffers.py +113 -0
  129. pycforge/ide/qt_editor_surfaces.py +112 -0
  130. pycforge/ide/qt_menus.py +324 -0
  131. pycforge/ide/qt_projection.py +500 -0
  132. pycforge/ide/qt_shell.py +348 -0
  133. pycforge/ide/qt_shell_interactions.py +320 -0
  134. pycforge/ide/qt_state.py +393 -0
  135. pycforge/ide/qt_workspace_features.py +398 -0
  136. pycforge/ide/qt_workspace_navigation.py +208 -0
  137. pycforge/ide/qt_workspace_observers.py +173 -0
  138. pycforge/ide/qt_workspace_panels.py +545 -0
  139. pycforge/ide/qt_workspace_widgets.py +228 -0
  140. pycforge/ide/resources/icons/about.svg +1 -0
  141. pycforge/ide/resources/icons/add-document.svg +1 -0
  142. pycforge/ide/resources/icons/brand-mark.svg +1 -0
  143. pycforge/ide/resources/icons/bundle-search.svg +1 -0
  144. pycforge/ide/resources/icons/cancel.svg +1 -0
  145. pycforge/ide/resources/icons/check.svg +1 -0
  146. pycforge/ide/resources/icons/chevron-right.svg +1 -0
  147. pycforge/ide/resources/icons/close.svg +1 -0
  148. pycforge/ide/resources/icons/collapse-all.svg +1 -0
  149. pycforge/ide/resources/icons/command-palette.svg +1 -0
  150. pycforge/ide/resources/icons/convert.svg +1 -0
  151. pycforge/ide/resources/icons/copy.svg +1 -0
  152. pycforge/ide/resources/icons/cut.svg +1 -0
  153. pycforge/ide/resources/icons/decision-trace.svg +1 -0
  154. pycforge/ide/resources/icons/details.svg +1 -0
  155. pycforge/ide/resources/icons/diagnostics.svg +1 -0
  156. pycforge/ide/resources/icons/duplicate-line.svg +1 -0
  157. pycforge/ide/resources/icons/exit.svg +1 -0
  158. pycforge/ide/resources/icons/expand-all.svg +1 -0
  159. pycforge/ide/resources/icons/export.svg +1 -0
  160. pycforge/ide/resources/icons/find.svg +1 -0
  161. pycforge/ide/resources/icons/go-to-line.svg +1 -0
  162. pycforge/ide/resources/icons/go-to-output.svg +1 -0
  163. pycforge/ide/resources/icons/go-to-source.svg +1 -0
  164. pycforge/ide/resources/icons/history.svg +1 -0
  165. pycforge/ide/resources/icons/indent.svg +1 -0
  166. pycforge/ide/resources/icons/link-c.svg +1 -0
  167. pycforge/ide/resources/icons/mappings.svg +1 -0
  168. pycforge/ide/resources/icons/module.svg +1 -0
  169. pycforge/ide/resources/icons/move-down.svg +1 -0
  170. pycforge/ide/resources/icons/move-line-down.svg +1 -0
  171. pycforge/ide/resources/icons/move-line-up.svg +1 -0
  172. pycforge/ide/resources/icons/move-up.svg +1 -0
  173. pycforge/ide/resources/icons/next.svg +1 -0
  174. pycforge/ide/resources/icons/open.svg +1 -0
  175. pycforge/ide/resources/icons/outdent.svg +1 -0
  176. pycforge/ide/resources/icons/outline.svg +1 -0
  177. pycforge/ide/resources/icons/paste.svg +1 -0
  178. pycforge/ide/resources/icons/previous.svg +1 -0
  179. pycforge/ide/resources/icons/primary-module.svg +1 -0
  180. pycforge/ide/resources/icons/redo.svg +1 -0
  181. pycforge/ide/resources/icons/remove-document.svg +1 -0
  182. pycforge/ide/resources/icons/replace.svg +1 -0
  183. pycforge/ide/resources/icons/save-as.svg +1 -0
  184. pycforge/ide/resources/icons/save.svg +1 -0
  185. pycforge/ide/resources/icons/select-all.svg +1 -0
  186. pycforge/ide/resources/icons/settings.svg +1 -0
  187. pycforge/ide/resources/icons/show-c.svg +1 -0
  188. pycforge/ide/resources/icons/split-view.svg +1 -0
  189. pycforge/ide/resources/icons/summary.svg +1 -0
  190. pycforge/ide/resources/icons/telemetry.svg +1 -0
  191. pycforge/ide/resources/icons/toggle-comment.svg +1 -0
  192. pycforge/ide/resources/icons/toggle-fold.svg +1 -0
  193. pycforge/ide/resources/icons/undo.svg +1 -0
  194. pycforge/ide/resources/icons/whitespace.svg +1 -0
  195. pycforge/ide/revisions.py +400 -0
  196. pycforge/ide/search_service.py +280 -0
  197. pycforge/ide/session_history.py +150 -0
  198. pycforge/ide/source_editing.py +417 -0
  199. pycforge/ide/source_structure.py +558 -0
  200. pycforge/ide/source_structure_async.py +191 -0
  201. pycforge/ide/supervisor.py +503 -0
  202. pycforge/ide/theme.py +119 -0
  203. pycforge/ide/theme_stylesheet.py +598 -0
  204. pycforge/ide/theme_workspace_stylesheet.py +155 -0
  205. pycforge/ide/visual_tokens.py +142 -0
  206. pycforge/ide/worker_protocol.py +80 -0
  207. pycforge/ide/workspace_search.py +462 -0
  208. pycforge/ide/workspace_session.py +256 -0
  209. pycforge/laboratory/__init__.py +6 -0
  210. pycforge/laboratory/audits.py +2244 -0
  211. pycforge/laboratory/checkpoint_e.py +2685 -0
  212. pycforge/laboratory/cli.py +119 -0
  213. pycforge/laboratory/exit_codes.py +11 -0
  214. pycforge/laboratory/keyword_audit.py +661 -0
  215. pycforge/laboratory/keyword_only_audit.py +481 -0
  216. pycforge-0.15.2.dist-info/METADATA +209 -0
  217. pycforge-0.15.2.dist-info/RECORD +221 -0
  218. pycforge-0.15.2.dist-info/WHEEL +5 -0
  219. pycforge-0.15.2.dist-info/entry_points.txt +5 -0
  220. pycforge-0.15.2.dist-info/licenses/LICENSE +674 -0
  221. pycforge-0.15.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,558 @@
1
+ """Bounded source outline and breadcrumb observers for open documents.
2
+
3
+ Structure is derived from the converter's declared Python 3.11 parser and
4
+ normalizer, but remains presentation-only. Invalid source, observer failure,
5
+ or a superseded request produces no converter fact and cannot change source
6
+ eligibility.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from time import monotonic
13
+ from typing import Callable, Iterable
14
+
15
+ from pycforge.converter.frontend.normalizer import PythonNormalizer
16
+ from pycforge.converter.frontend.parser import Python311ParserAdapter
17
+ from pycforge.converter.frontend.source_document import SourceDocument
18
+ from pycforge.converter.ir.python_ir import python_ir_reference_ids
19
+
20
+
21
+ MAX_STRUCTURE_DOCUMENTS = 64
22
+ MAX_OUTLINE_SYMBOLS = 4_096
23
+ MAX_OUTLINE_DEPTH = 64
24
+ MAX_OUTLINE_NAME_CHARS = 256
25
+ MAX_OUTLINE_TEXT_CHARS = 256 * 1024
26
+ MAX_STRUCTURE_DOCUMENT_ID_CHARS = 128
27
+ MAX_STRUCTURE_MODULE_ID_CHARS = 255
28
+ MAX_STRUCTURE_LOGICAL_NAME_CHARS = 4_096
29
+ MAX_STRUCTURE_WORKSPACE_KEY_CHARS = 256
30
+
31
+ DEFAULT_OUTLINE_DEPTH = 32
32
+ DEFAULT_OUTLINE_NAME_CHARS = 160
33
+ DEFAULT_OUTLINE_TEXT_CHARS = 128 * 1024
34
+ _SYMBOL_KINDS = frozenset(
35
+ {"Module", "ClassDef", "FunctionDef", "AsyncFunctionDef"}
36
+ )
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class SourceStructureDocument:
41
+ """One already-open source document captured by an observer request."""
42
+
43
+ document_id: str
44
+ module_id: str
45
+ logical_name: str
46
+ text: str
47
+
48
+
49
+ @dataclass(frozen=True, slots=True)
50
+ class OutlineSymbol:
51
+ """One flat, parent-linked outline record with code-point positions."""
52
+
53
+ document_id: str
54
+ module_id: str
55
+ logical_name: str
56
+ node_id: str
57
+ parent_node_id: str | None
58
+ kind: str
59
+ name: str
60
+ detail: str
61
+ depth: int
62
+ start: int
63
+ end: int
64
+ start_line: int
65
+ start_column: int
66
+ end_line: int
67
+ end_column: int
68
+
69
+
70
+ @dataclass(frozen=True, slots=True)
71
+ class SourceStructureResult:
72
+ """A bounded, non-authoritative structure snapshot."""
73
+
74
+ generation: int
75
+ workspace_key: str
76
+ symbols: tuple[OutlineSymbol, ...]
77
+ total_symbol_count: int
78
+ invalid_document_ids: tuple[str, ...]
79
+ observer_failed_document_ids: tuple[str, ...]
80
+ truncated: bool
81
+ elapsed_seconds: float
82
+
83
+
84
+ @dataclass(frozen=True, slots=True)
85
+ class _Candidate:
86
+ node_id: str
87
+ raw_parent_id: str | None
88
+ kind: str
89
+ name: str
90
+ start: int
91
+ end: int
92
+ start_line: int
93
+ start_column: int
94
+ end_line: int
95
+ end_column: int
96
+
97
+
98
+ def _captured_documents(
99
+ documents: Iterable[SourceStructureDocument],
100
+ ) -> tuple[SourceStructureDocument, ...]:
101
+ records = tuple(documents)
102
+ if not 1 <= len(records) <= MAX_STRUCTURE_DOCUMENTS:
103
+ raise ValueError("structure requires between 1 and 64 open documents")
104
+ document_ids: list[str] = []
105
+ module_ids: list[str] = []
106
+ logical_names: list[str] = []
107
+ for document in records:
108
+ if not isinstance(document, SourceStructureDocument):
109
+ raise TypeError(
110
+ "structure documents must be SourceStructureDocument values"
111
+ )
112
+ for label, value, maximum in (
113
+ (
114
+ "document ID",
115
+ document.document_id,
116
+ MAX_STRUCTURE_DOCUMENT_ID_CHARS,
117
+ ),
118
+ (
119
+ "module ID",
120
+ document.module_id,
121
+ MAX_STRUCTURE_MODULE_ID_CHARS,
122
+ ),
123
+ (
124
+ "logical name",
125
+ document.logical_name,
126
+ MAX_STRUCTURE_LOGICAL_NAME_CHARS,
127
+ ),
128
+ ):
129
+ if (
130
+ not isinstance(value, str)
131
+ or not value
132
+ or len(value) > maximum
133
+ ):
134
+ raise ValueError(f"structure {label} must be non-empty text")
135
+ if not isinstance(document.text, str):
136
+ raise TypeError("structure document text must be a string")
137
+ document_ids.append(document.document_id)
138
+ module_ids.append(document.module_id)
139
+ logical_names.append(document.logical_name)
140
+ for label, values in (
141
+ ("document IDs", document_ids),
142
+ ("module IDs", module_ids),
143
+ ("logical names", logical_names),
144
+ ):
145
+ if len(values) != len(set(values)):
146
+ raise ValueError(f"structure {label} must be unique")
147
+ return records
148
+
149
+
150
+ def _bounded_int(
151
+ value: object,
152
+ *,
153
+ name: str,
154
+ maximum: int,
155
+ ) -> int:
156
+ if isinstance(value, bool) or not isinstance(value, int):
157
+ raise TypeError(f"{name} must be an integer")
158
+ if not 1 <= value <= maximum:
159
+ raise ValueError(f"{name} must be between 1 and {maximum}")
160
+ return value
161
+
162
+
163
+ def _clipped_name(value: str, budget: int) -> tuple[str, bool]:
164
+ if len(value) <= budget:
165
+ return value, False
166
+ if budget == 1:
167
+ return "\N{HORIZONTAL ELLIPSIS}", True
168
+ return value[: budget - 1] + "\N{HORIZONTAL ELLIPSIS}", True
169
+
170
+
171
+ def _span_values(
172
+ span: object,
173
+ *,
174
+ text_length: int,
175
+ ) -> tuple[int, int, int, int, int, int] | None:
176
+ if not isinstance(span, dict):
177
+ return None
178
+ start = span.get("start")
179
+ end = span.get("end")
180
+ if not isinstance(start, dict) or not isinstance(end, dict):
181
+ return None
182
+ values = (
183
+ start.get("offset"),
184
+ end.get("offset"),
185
+ start.get("line"),
186
+ start.get("column"),
187
+ end.get("line"),
188
+ end.get("column"),
189
+ )
190
+ if any(isinstance(value, bool) or not isinstance(value, int) for value in values):
191
+ return None
192
+ start_offset, end_offset, start_line, start_column, end_line, end_column = values
193
+ if (
194
+ start_offset < 0
195
+ or end_offset < start_offset
196
+ or end_offset > text_length
197
+ or start_line < 1
198
+ or end_line < start_line
199
+ or start_column < 0
200
+ or end_column < 0
201
+ ):
202
+ return None
203
+ return (
204
+ start_offset,
205
+ end_offset,
206
+ start_line,
207
+ start_column,
208
+ end_line,
209
+ end_column,
210
+ )
211
+
212
+
213
+ def _document_candidates(
214
+ document: SourceStructureDocument,
215
+ ) -> tuple[_Candidate, ...]:
216
+ source = SourceDocument.create(document.logical_name, document.text)
217
+ tree = Python311ParserAdapter().parse(source, "3.11")
218
+ normalized = PythonNormalizer().normalize(tree, source)
219
+ known = {node.node_id for node in normalized.nodes}
220
+ parents: dict[str, str] = {}
221
+ for node in normalized.nodes:
222
+ for field_name, value in node.fields:
223
+ for child_id in python_ir_reference_ids(
224
+ node.kind, field_name, value, known
225
+ ):
226
+ parents.setdefault(child_id, node.node_id)
227
+
228
+ candidates: list[_Candidate] = []
229
+ for node in normalized.nodes:
230
+ if node.kind not in _SYMBOL_KINDS:
231
+ continue
232
+ if node.kind == "Module":
233
+ candidates.append(
234
+ _Candidate(
235
+ node.node_id,
236
+ None,
237
+ node.kind,
238
+ document.module_id,
239
+ 0,
240
+ len(document.text),
241
+ 1,
242
+ 0,
243
+ document.text.count("\n") + 1,
244
+ 0,
245
+ )
246
+ )
247
+ continue
248
+ fields = dict(node.fields)
249
+ name = fields.get("name")
250
+ if not isinstance(name, str) or not name:
251
+ continue
252
+ values = _span_values(
253
+ node.provenance.source_span,
254
+ text_length=len(document.text),
255
+ )
256
+ if values is None:
257
+ continue
258
+ candidates.append(
259
+ _Candidate(
260
+ node.node_id,
261
+ parents.get(node.node_id),
262
+ node.kind,
263
+ name,
264
+ *values,
265
+ )
266
+ )
267
+ candidate_ids = {candidate.node_id for candidate in candidates}
268
+ resolved: list[_Candidate] = []
269
+ for candidate in candidates:
270
+ parent = candidate.raw_parent_id
271
+ seen: set[str] = set()
272
+ while parent is not None and parent not in candidate_ids:
273
+ if parent in seen:
274
+ parent = None
275
+ break
276
+ seen.add(parent)
277
+ parent = parents.get(parent)
278
+ resolved.append(
279
+ _Candidate(
280
+ candidate.node_id,
281
+ parent,
282
+ candidate.kind,
283
+ candidate.name,
284
+ candidate.start,
285
+ candidate.end,
286
+ candidate.start_line,
287
+ candidate.start_column,
288
+ candidate.end_line,
289
+ candidate.end_column,
290
+ )
291
+ )
292
+ return tuple(resolved)
293
+
294
+
295
+ def _nearest_symbol_parents(
296
+ candidates: tuple[_Candidate, ...],
297
+ ) -> tuple[dict[str, str | None], dict[str, int]]:
298
+ candidate_ids = {candidate.node_id for candidate in candidates}
299
+ raw_parents = {
300
+ candidate.node_id: candidate.raw_parent_id
301
+ for candidate in candidates
302
+ }
303
+ parents: dict[str, str | None] = {}
304
+ for candidate in candidates:
305
+ parent = candidate.raw_parent_id
306
+ seen: set[str] = set()
307
+ while parent is not None and parent not in candidate_ids:
308
+ if parent in seen:
309
+ parent = None
310
+ break
311
+ seen.add(parent)
312
+ parent = raw_parents.get(parent)
313
+ parents[candidate.node_id] = parent
314
+
315
+ depths: dict[str, int] = {}
316
+
317
+ def depth_for(node_id: str) -> int:
318
+ if node_id in depths:
319
+ return depths[node_id]
320
+ chain: list[str] = []
321
+ current: str | None = node_id
322
+ seen: set[str] = set()
323
+ while current is not None and current not in depths:
324
+ if current in seen:
325
+ for item in chain:
326
+ depths[item] = 0
327
+ return 0
328
+ seen.add(current)
329
+ chain.append(current)
330
+ current = parents.get(current)
331
+ depth = depths.get(current, -1) + 1
332
+ for item in reversed(chain):
333
+ depths[item] = depth
334
+ depth += 1
335
+ return depths[node_id]
336
+
337
+ for candidate in candidates:
338
+ depth_for(candidate.node_id)
339
+ return parents, depths
340
+
341
+
342
+ def build_source_structure(
343
+ documents: Iterable[SourceStructureDocument],
344
+ *,
345
+ generation: int = 0,
346
+ workspace_key: str = "",
347
+ max_symbols: int = MAX_OUTLINE_SYMBOLS,
348
+ max_depth: int = DEFAULT_OUTLINE_DEPTH,
349
+ max_name_chars: int = DEFAULT_OUTLINE_NAME_CHARS,
350
+ max_text_chars: int = DEFAULT_OUTLINE_TEXT_CHARS,
351
+ cancelled: Callable[[], bool] | None = None,
352
+ ) -> SourceStructureResult | None:
353
+ """Build an inert structure projection from captured open text only."""
354
+
355
+ started = monotonic()
356
+ records = _captured_documents(documents)
357
+ if isinstance(generation, bool) or not isinstance(generation, int):
358
+ raise TypeError("structure generation must be an integer")
359
+ if generation < 0:
360
+ raise ValueError("structure generation must be non-negative")
361
+ if not isinstance(workspace_key, str):
362
+ raise TypeError("workspace key must be a string")
363
+ if len(workspace_key) > MAX_STRUCTURE_WORKSPACE_KEY_CHARS:
364
+ raise ValueError("workspace key exceeds the observer identity limit")
365
+ max_symbols = _bounded_int(
366
+ max_symbols, name="max_symbols", maximum=MAX_OUTLINE_SYMBOLS
367
+ )
368
+ max_depth = _bounded_int(
369
+ max_depth, name="max_depth", maximum=MAX_OUTLINE_DEPTH
370
+ )
371
+ max_name_chars = _bounded_int(
372
+ max_name_chars,
373
+ name="max_name_chars",
374
+ maximum=MAX_OUTLINE_NAME_CHARS,
375
+ )
376
+ max_text_chars = _bounded_int(
377
+ max_text_chars,
378
+ name="max_text_chars",
379
+ maximum=MAX_OUTLINE_TEXT_CHARS,
380
+ )
381
+ is_cancelled = cancelled or (lambda: False)
382
+ symbols: list[OutlineSymbol] = []
383
+ invalid: list[str] = []
384
+ observer_failed: list[str] = []
385
+ total_symbols = 0
386
+ text_chars = 0
387
+ truncated = False
388
+ output_full = False
389
+
390
+ for document in records:
391
+ if is_cancelled():
392
+ return None
393
+ try:
394
+ candidates = _document_candidates(document)
395
+ except (
396
+ SyntaxError,
397
+ UnicodeError,
398
+ ValueError,
399
+ RecursionError,
400
+ ):
401
+ invalid.append(document.document_id)
402
+ continue
403
+ except Exception:
404
+ observer_failed.append(document.document_id)
405
+ continue
406
+ if is_cancelled():
407
+ return None
408
+ parents, depths = _nearest_symbol_parents(candidates)
409
+ ordered = sorted(
410
+ candidates,
411
+ key=lambda item: (
412
+ item.start,
413
+ depths[item.node_id],
414
+ item.end,
415
+ item.kind,
416
+ item.node_id,
417
+ ),
418
+ )
419
+ total_symbols += len(ordered)
420
+ stored_ids = {symbol.node_id for symbol in symbols}
421
+ for candidate in ordered:
422
+ depth = depths[candidate.node_id]
423
+ if depth > max_depth:
424
+ truncated = True
425
+ continue
426
+ name, name_truncated = _clipped_name(
427
+ candidate.name, max_name_chars
428
+ )
429
+ truncated = truncated or name_truncated
430
+ detail = {
431
+ "Module": "module",
432
+ "ClassDef": "class",
433
+ "FunctionDef": "function",
434
+ "AsyncFunctionDef": "async function",
435
+ }[candidate.kind]
436
+ parent_id = parents[candidate.node_id]
437
+ if parent_id is not None and parent_id not in stored_ids:
438
+ truncated = True
439
+ continue
440
+ required_text = len(name) + len(detail)
441
+ if (
442
+ output_full
443
+ or len(symbols) >= max_symbols
444
+ or text_chars + required_text > max_text_chars
445
+ ):
446
+ output_full = True
447
+ truncated = True
448
+ continue
449
+ symbols.append(
450
+ OutlineSymbol(
451
+ document.document_id,
452
+ document.module_id,
453
+ document.logical_name,
454
+ candidate.node_id,
455
+ parent_id,
456
+ candidate.kind,
457
+ name,
458
+ detail,
459
+ depth,
460
+ candidate.start,
461
+ candidate.end,
462
+ candidate.start_line,
463
+ candidate.start_column,
464
+ candidate.end_line,
465
+ candidate.end_column,
466
+ )
467
+ )
468
+ stored_ids.add(candidate.node_id)
469
+ text_chars += required_text
470
+
471
+ return SourceStructureResult(
472
+ generation,
473
+ workspace_key,
474
+ tuple(symbols),
475
+ total_symbols,
476
+ tuple(invalid),
477
+ tuple(observer_failed),
478
+ truncated,
479
+ monotonic() - started,
480
+ )
481
+
482
+
483
+ def breadcrumbs_for_position(
484
+ result: SourceStructureResult,
485
+ document_id: str,
486
+ position: int,
487
+ ) -> tuple[OutlineSymbol, ...]:
488
+ """Return the root-to-leaf symbol chain containing a code-point offset."""
489
+
490
+ if not isinstance(result, SourceStructureResult):
491
+ raise TypeError("result must be a SourceStructureResult")
492
+ if not isinstance(document_id, str) or not document_id:
493
+ raise ValueError("document ID must be non-empty text")
494
+ if isinstance(position, bool) or not isinstance(position, int):
495
+ raise TypeError("breadcrumb position must be an integer")
496
+ if position < 0:
497
+ raise ValueError("breadcrumb position must be non-negative")
498
+
499
+ by_id = {
500
+ symbol.node_id: symbol
501
+ for symbol in result.symbols
502
+ if symbol.document_id == document_id
503
+ }
504
+ containing = [
505
+ symbol
506
+ for symbol in by_id.values()
507
+ if (
508
+ symbol.start <= position < max(symbol.start + 1, symbol.end)
509
+ or (
510
+ symbol.kind == "Module"
511
+ and position == symbol.end
512
+ )
513
+ )
514
+ ]
515
+ if not containing:
516
+ return ()
517
+ current = max(
518
+ containing,
519
+ key=lambda item: (
520
+ item.depth,
521
+ item.start,
522
+ -(item.end - item.start),
523
+ ),
524
+ )
525
+ chain: list[OutlineSymbol] = []
526
+ seen: set[str] = set()
527
+ while current.node_id not in seen:
528
+ seen.add(current.node_id)
529
+ chain.append(current)
530
+ if current.parent_node_id is None:
531
+ break
532
+ parent = by_id.get(current.parent_node_id)
533
+ if parent is None:
534
+ break
535
+ current = parent
536
+ return tuple(reversed(chain))
537
+
538
+
539
+ def __getattr__(name: str):
540
+ if name == "AsyncSourceStructureService":
541
+ from .source_structure_async import AsyncSourceStructureService
542
+
543
+ return AsyncSourceStructureService
544
+ raise AttributeError(name)
545
+
546
+
547
+ __all__ = [
548
+ "AsyncSourceStructureService",
549
+ "MAX_OUTLINE_DEPTH",
550
+ "MAX_OUTLINE_NAME_CHARS",
551
+ "MAX_OUTLINE_SYMBOLS",
552
+ "MAX_OUTLINE_TEXT_CHARS",
553
+ "OutlineSymbol",
554
+ "SourceStructureDocument",
555
+ "SourceStructureResult",
556
+ "breadcrumbs_for_position",
557
+ "build_source_structure",
558
+ ]