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,229 @@
1
+ """Deterministic validation for the import-safe PyCForge action contract."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Mapping
7
+
8
+ from .action_contract import (
9
+ ActionSpec,
10
+ PlacementKind,
11
+ SurfaceKind,
12
+ SurfaceSpec,
13
+ )
14
+
15
+
16
+ def validate_action_contract(
17
+ *,
18
+ action_specs: Mapping[str, ActionSpec],
19
+ surface_specs: Mapping[str, SurfaceSpec],
20
+ dynamic_groups: Mapping[str, str],
21
+ ) -> tuple[str, ...]:
22
+ """Return stable contract errors without importing optional Qt."""
23
+
24
+ errors: list[str] = []
25
+ action_pattern = re.compile(
26
+ r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$"
27
+ )
28
+ surface_pattern = re.compile(
29
+ r"^(?:menu|toolbar|context)\.[a-z][a-z0-9_]*$"
30
+ )
31
+ shortcuts: dict[tuple[str, str, str], str] = {}
32
+ for key, spec in sorted(action_specs.items()):
33
+ if key != spec.action_id:
34
+ errors.append(f"action mapping key mismatch: {key}")
35
+ if not action_pattern.fullmatch(spec.action_id):
36
+ errors.append(f"invalid action id: {spec.action_id}")
37
+ if not spec.menu_text or not spec.tooltip or not spec.accessible_name:
38
+ errors.append(f"incomplete action metadata: {spec.action_id}")
39
+ mnemonics = _mnemonics(spec.menu_text)
40
+ if not spec.dynamic and len(mnemonics) != 1:
41
+ errors.append(
42
+ f"action must declare one mnemonic: {spec.action_id}"
43
+ )
44
+ if "&" in spec.accessible_name or "&" in spec.toolbar_text:
45
+ errors.append(f"presentation mnemonic leaked: {spec.action_id}")
46
+ if spec.standard_shortcut and spec.shortcut:
47
+ errors.append(
48
+ f"ambiguous shortcut declaration: {spec.action_id}"
49
+ )
50
+ if spec.shortcut_context not in {"window", "widget"}:
51
+ errors.append(f"invalid shortcut context: {spec.action_id}")
52
+ if spec.tone not in {"normal", "primary", "danger"}:
53
+ errors.append(f"invalid action tone: {spec.action_id}")
54
+ sequence = spec.standard_shortcut or spec.shortcut
55
+ if sequence:
56
+ shortcut_key = (
57
+ spec.shortcut_context,
58
+ "standard" if spec.standard_shortcut else "literal",
59
+ sequence.casefold(),
60
+ )
61
+ previous = shortcuts.get(shortcut_key)
62
+ if previous:
63
+ errors.append(
64
+ f"duplicate shortcut: {previous}, {spec.action_id}"
65
+ )
66
+ shortcuts[shortcut_key] = spec.action_id
67
+
68
+ for group_id, action_id in sorted(dynamic_groups.items()):
69
+ spec = action_specs.get(action_id)
70
+ if not group_id or spec is None:
71
+ errors.append(f"invalid dynamic group: {group_id}")
72
+ elif not spec.dynamic:
73
+ errors.append(
74
+ f"dynamic template is not marked dynamic: {action_id}"
75
+ )
76
+
77
+ for key, surface in sorted(surface_specs.items()):
78
+ _validate_surface(
79
+ key,
80
+ surface,
81
+ action_specs,
82
+ surface_specs,
83
+ dynamic_groups,
84
+ surface_pattern,
85
+ errors,
86
+ )
87
+ return tuple(errors)
88
+
89
+
90
+ def _validate_surface(
91
+ key: str,
92
+ surface: SurfaceSpec,
93
+ action_specs: Mapping[str, ActionSpec],
94
+ surface_specs: Mapping[str, SurfaceSpec],
95
+ dynamic_groups: Mapping[str, str],
96
+ surface_pattern: re.Pattern[str],
97
+ errors: list[str],
98
+ ) -> None:
99
+ if key != surface.surface_id:
100
+ errors.append(f"surface mapping key mismatch: {key}")
101
+ if not surface_pattern.fullmatch(surface.surface_id):
102
+ errors.append(f"invalid surface id: {surface.surface_id}")
103
+ if not surface.accessible_name:
104
+ errors.append(
105
+ f"missing surface accessible name: {surface.surface_id}"
106
+ )
107
+ if (
108
+ surface.kind is SurfaceKind.MENU
109
+ and len(_mnemonics(surface.title)) != 1
110
+ ):
111
+ errors.append(f"menu must declare one mnemonic: {surface.surface_id}")
112
+
113
+ placements = surface.placements
114
+ targets: set[tuple[PlacementKind, str]] = set()
115
+ mnemonics: dict[str, str] = {}
116
+ for index, placement in enumerate(placements):
117
+ if placement.target:
118
+ placement_key = (placement.kind, placement.target)
119
+ if placement_key in targets:
120
+ errors.append(
121
+ f"duplicate placement {placement.target} "
122
+ f"in {surface.surface_id}"
123
+ )
124
+ targets.add(placement_key)
125
+ _validate_placement(
126
+ placement,
127
+ surface,
128
+ action_specs,
129
+ surface_specs,
130
+ dynamic_groups,
131
+ mnemonics,
132
+ errors,
133
+ )
134
+ if placement.kind is PlacementKind.SEPARATOR and (
135
+ index == 0
136
+ or index == len(placements) - 1
137
+ or placements[index - 1].kind is PlacementKind.SEPARATOR
138
+ ):
139
+ errors.append(f"misplaced separator in {surface.surface_id}")
140
+
141
+
142
+ def _validate_placement(
143
+ placement,
144
+ surface: SurfaceSpec,
145
+ action_specs: Mapping[str, ActionSpec],
146
+ surface_specs: Mapping[str, SurfaceSpec],
147
+ dynamic_groups: Mapping[str, str],
148
+ mnemonics: dict[str, str],
149
+ errors: list[str],
150
+ ) -> None:
151
+ if placement.kind is PlacementKind.ACTION:
152
+ spec = action_specs.get(placement.target)
153
+ if spec is None:
154
+ errors.append(
155
+ f"unknown action {placement.target} in {surface.surface_id}"
156
+ )
157
+ elif spec.dynamic:
158
+ errors.append(
159
+ f"dynamic template placed directly: {placement.target}"
160
+ )
161
+ elif surface.kind is not SurfaceKind.TOOLBAR:
162
+ _record_mnemonic(
163
+ mnemonics,
164
+ _mnemonics(spec.menu_text),
165
+ placement.target,
166
+ surface.surface_id,
167
+ errors,
168
+ )
169
+ elif placement.kind is PlacementKind.SUBMENU:
170
+ submenu = surface_specs.get(placement.target)
171
+ if submenu is None or submenu.kind is not SurfaceKind.MENU:
172
+ errors.append(
173
+ f"unknown submenu {placement.target} in {surface.surface_id}"
174
+ )
175
+ else:
176
+ _record_mnemonic(
177
+ mnemonics,
178
+ _mnemonics(submenu.title),
179
+ placement.target,
180
+ surface.surface_id,
181
+ errors,
182
+ )
183
+ elif placement.kind is PlacementKind.DYNAMIC:
184
+ if placement.target not in dynamic_groups:
185
+ errors.append(
186
+ f"unknown dynamic group {placement.target} "
187
+ f"in {surface.surface_id}"
188
+ )
189
+ elif placement.target:
190
+ errors.append(f"separator has a target in {surface.surface_id}")
191
+
192
+
193
+ def _mnemonics(text: str) -> tuple[str, ...]:
194
+ markers: list[str] = []
195
+ index = 0
196
+ while index < len(text):
197
+ if text[index] != "&":
198
+ index += 1
199
+ continue
200
+ if index + 1 < len(text) and text[index + 1] == "&":
201
+ index += 2
202
+ continue
203
+ if index + 1 < len(text):
204
+ markers.append(text[index + 1].casefold())
205
+ index += 1
206
+ return tuple(markers)
207
+
208
+
209
+ def _record_mnemonic(
210
+ owners: dict[str, str],
211
+ mnemonics: tuple[str, ...],
212
+ target: str,
213
+ surface_id: str,
214
+ errors: list[str],
215
+ ) -> None:
216
+ if len(mnemonics) != 1:
217
+ return
218
+ mnemonic = mnemonics[0]
219
+ previous = owners.get(mnemonic)
220
+ if previous is not None:
221
+ errors.append(
222
+ f"duplicate mnemonic {mnemonic!r} in {surface_id}: "
223
+ f"{previous}, {target}"
224
+ )
225
+ else:
226
+ owners[mnemonic] = target
227
+
228
+
229
+ __all__ = ["validate_action_contract"]
@@ -0,0 +1,223 @@
1
+ """Deterministic, bounded command-palette projection over declared actions.
2
+
3
+ The palette returns inert metadata and stable action IDs only. It does not
4
+ accept handlers, evaluate command strings, or provide an execution mechanism;
5
+ the optional Qt adapter must resolve a selected ID through the sole declared
6
+ action registry.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Mapping
12
+ from dataclasses import dataclass
13
+
14
+ from .action_contract import ACTION_SPECS, ActionSpec, ActionState
15
+
16
+
17
+ MAX_COMMAND_PALETTE_RESULTS = 50
18
+ MAX_COMMAND_PALETTE_QUERY_CHARS = 256
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class CommandPaletteItem:
23
+ """One inert projection of a visible static action."""
24
+
25
+ action_id: str
26
+ label: str
27
+ tooltip: str
28
+ accessible_name: str
29
+ icon_name: str | None
30
+ shortcut: str | None
31
+ shortcut_kind: str | None
32
+ enabled: bool
33
+ checked: bool | None
34
+ tone: str
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class CommandPaletteProjection:
39
+ """A bounded result set plus its untruncated matching count."""
40
+
41
+ query: str
42
+ items: tuple[CommandPaletteItem, ...]
43
+ total_count: int
44
+ limit: int
45
+
46
+ @property
47
+ def truncated(self) -> bool:
48
+ return self.total_count > len(self.items)
49
+
50
+
51
+ def strip_mnemonics(text: str) -> str:
52
+ """Remove Qt mnemonic markers while preserving escaped ampersands."""
53
+
54
+ if not isinstance(text, str):
55
+ raise TypeError("action label must be text")
56
+ rendered: list[str] = []
57
+ index = 0
58
+ while index < len(text):
59
+ character = text[index]
60
+ if character != "&":
61
+ rendered.append(character)
62
+ index += 1
63
+ continue
64
+ if index + 1 < len(text) and text[index + 1] == "&":
65
+ rendered.append("&")
66
+ index += 2
67
+ continue
68
+ index += 1
69
+ return "".join(rendered)
70
+
71
+
72
+ def _shortcut(spec: ActionSpec) -> tuple[str | None, str | None]:
73
+ if spec.standard_shortcut:
74
+ return spec.standard_shortcut, "standard"
75
+ if spec.shortcut:
76
+ return spec.shortcut, "literal"
77
+ return None, None
78
+
79
+
80
+ def _rank(
81
+ query_terms: tuple[str, ...],
82
+ label: str,
83
+ spec: ActionSpec,
84
+ shortcut: str | None,
85
+ declaration_index: int,
86
+ ) -> tuple[int, int, str, str] | None:
87
+ if not query_terms:
88
+ return 0, declaration_index, "", spec.action_id
89
+ normalized_label = label.casefold()
90
+ normalized_id = spec.action_id.replace(".", " ").replace("_", " ").casefold()
91
+ searchable = " ".join(
92
+ (
93
+ normalized_label,
94
+ normalized_id,
95
+ spec.tooltip.casefold(),
96
+ spec.accessible_name.casefold(),
97
+ (shortcut or "").casefold(),
98
+ )
99
+ )
100
+ if any(term not in searchable for term in query_terms):
101
+ return None
102
+ joined = " ".join(query_terms)
103
+ if normalized_label == joined:
104
+ quality = 0
105
+ elif normalized_label.startswith(joined):
106
+ quality = 1
107
+ elif all(
108
+ any(word.startswith(term) for word in normalized_label.split())
109
+ for term in query_terms
110
+ ):
111
+ quality = 2
112
+ elif joined in normalized_label:
113
+ quality = 3
114
+ elif joined in normalized_id:
115
+ quality = 4
116
+ else:
117
+ quality = 5
118
+ return quality, declaration_index, normalized_label, spec.action_id
119
+
120
+
121
+ def project_command_palette(
122
+ query: str = "",
123
+ *,
124
+ states: Mapping[str, ActionState] | None = None,
125
+ action_specs: Mapping[str, ActionSpec] = ACTION_SPECS,
126
+ limit: int = MAX_COMMAND_PALETTE_RESULTS,
127
+ ) -> CommandPaletteProjection:
128
+ """Project visible static actions under a deterministic absolute limit."""
129
+
130
+ if not isinstance(query, str):
131
+ raise TypeError("palette query must be text")
132
+ if len(query) > MAX_COMMAND_PALETTE_QUERY_CHARS:
133
+ raise ValueError(
134
+ "palette query exceeds "
135
+ f"{MAX_COMMAND_PALETTE_QUERY_CHARS} characters"
136
+ )
137
+ if (
138
+ isinstance(limit, bool)
139
+ or not isinstance(limit, int)
140
+ or not 1 <= limit <= MAX_COMMAND_PALETTE_RESULTS
141
+ ):
142
+ raise ValueError(
143
+ f"palette result limit must be between 1 and "
144
+ f"{MAX_COMMAND_PALETTE_RESULTS}"
145
+ )
146
+ if not isinstance(action_specs, Mapping):
147
+ raise TypeError("action specs must be a mapping")
148
+ projected_states = {} if states is None else dict(states)
149
+ unknown = set(projected_states).difference(action_specs)
150
+ if unknown:
151
+ raise KeyError(f"state supplied for unknown actions: {sorted(unknown)}")
152
+ if any(
153
+ not isinstance(state, ActionState)
154
+ for state in projected_states.values()
155
+ ):
156
+ raise TypeError("palette states must be ActionState values")
157
+
158
+ normalized_query = " ".join(query.split())
159
+ query_terms = tuple(normalized_query.casefold().split())
160
+ ranked: list[
161
+ tuple[tuple[int, int, str, str], CommandPaletteItem]
162
+ ] = []
163
+ for declaration_index, (action_id, spec) in enumerate(
164
+ action_specs.items()
165
+ ):
166
+ if not isinstance(spec, ActionSpec):
167
+ raise TypeError("palette action specs must be ActionSpec values")
168
+ if action_id != spec.action_id:
169
+ raise ValueError(f"action mapping key mismatch: {action_id}")
170
+ if spec.dynamic:
171
+ continue
172
+ state = projected_states.get(action_id, ActionState())
173
+ if not state.visible:
174
+ continue
175
+ label = strip_mnemonics(spec.menu_text)
176
+ shortcut, shortcut_kind = _shortcut(spec)
177
+ rank = _rank(
178
+ query_terms,
179
+ label,
180
+ spec,
181
+ shortcut,
182
+ declaration_index,
183
+ )
184
+ if rank is None:
185
+ continue
186
+ ranked.append(
187
+ (
188
+ rank,
189
+ CommandPaletteItem(
190
+ action_id=action_id,
191
+ label=label,
192
+ tooltip=spec.tooltip,
193
+ accessible_name=spec.accessible_name,
194
+ icon_name=spec.icon_name,
195
+ shortcut=shortcut,
196
+ shortcut_kind=shortcut_kind,
197
+ enabled=state.enabled,
198
+ checked=state.checked,
199
+ tone=spec.tone,
200
+ ),
201
+ )
202
+ )
203
+ ranked.sort(key=lambda record: record[0])
204
+ return CommandPaletteProjection(
205
+ normalized_query,
206
+ tuple(item for _rank_value, item in ranked[:limit]),
207
+ len(ranked),
208
+ limit,
209
+ )
210
+
211
+
212
+ filter_command_palette = project_command_palette
213
+
214
+
215
+ __all__ = [
216
+ "MAX_COMMAND_PALETTE_QUERY_CHARS",
217
+ "MAX_COMMAND_PALETTE_RESULTS",
218
+ "CommandPaletteItem",
219
+ "CommandPaletteProjection",
220
+ "filter_command_palette",
221
+ "project_command_palette",
222
+ "strip_mnemonics",
223
+ ]