meltygui 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.
Files changed (372) hide show
  1. meltygui/__init__.py +107 -0
  2. meltygui/accounts/__init__.py +0 -0
  3. meltygui/accounts/internet_accounts.py +1355 -0
  4. meltygui/chat/__init__.py +91 -0
  5. meltygui/chat/activity.py +75 -0
  6. meltygui/chat/backends.py +36 -0
  7. meltygui/chat/chat_interface.py +732 -0
  8. meltygui/chat/chat_proxy.py +352 -0
  9. meltygui/chat/codex_proxy.py +592 -0
  10. meltygui/chat/codex_settings.py +100 -0
  11. meltygui/chat/codex_transport.py +60 -0
  12. meltygui/chat/command_parser.py +204 -0
  13. meltygui/chat/images.py +227 -0
  14. meltygui/chat/messages.py +417 -0
  15. meltygui/chat/metadata.py +139 -0
  16. meltygui/chat/writer_locks.py +64 -0
  17. meltygui/code/__init__.py +0 -0
  18. meltygui/code/basic_converters.py +533 -0
  19. meltygui/code/chain_converters.py +2111 -0
  20. meltygui/code/code_checks.py +2209 -0
  21. meltygui/code/core_syntax.py +1430 -0
  22. meltygui/code/file_converters.py +1933 -0
  23. meltygui/code/fileref.py +702 -0
  24. meltygui/code/hotswap_guard.py +144 -0
  25. meltygui/code/libcst_conversion.py +9724 -0
  26. meltygui/code/live_instrument.py +392 -0
  27. meltygui/code/live_view.py +2490 -0
  28. meltygui/code/melty_scan.py +2684 -0
  29. meltygui/code/new_codecs.py +1255 -0
  30. meltygui/code/new_converters.py +3017 -0
  31. meltygui/code/project_code.py +278 -0
  32. meltygui/code/source_context.py +63 -0
  33. meltygui/code/symbol_roster.py +1588 -0
  34. meltygui/code/syntax_check.py +34 -0
  35. meltygui/code/syntax_check_worker.py +114 -0
  36. meltygui/completion/__init__.py +0 -0
  37. meltygui/completion/fim.py +1232 -0
  38. meltygui/completion/fim_context.py +481 -0
  39. meltygui/completion/providers/__init__.py +0 -0
  40. meltygui/completion/providers/anthropic_oauth.py +446 -0
  41. meltygui/completion/providers/anthropic_requests.py +69 -0
  42. meltygui/completion/providers/claude.py +203 -0
  43. meltygui/completion/providers/claude_usage.py +499 -0
  44. meltygui/completion/providers/codex_accounts.py +180 -0
  45. meltygui/completion/providers/copilot.py +617 -0
  46. meltygui/completion/providers/oauth_popup.py +220 -0
  47. meltygui/completion/providers/ollama.py +320 -0
  48. meltygui/completion/providers/profiles.py +23 -0
  49. meltygui/core/README.md +88 -0
  50. meltygui/core/__init__.py +1 -0
  51. meltygui/core/automation/__init__.py +1 -0
  52. meltygui/core/automation/action_core.py +153 -0
  53. meltygui/core/automation/collection_action.py +45 -0
  54. meltygui/core/automation/mcp_eval.py +167 -0
  55. meltygui/core/automation/mcp_hotswap.py +107 -0
  56. meltygui/core/automation/mcp_query.py +552 -0
  57. meltygui/core/automation/mcp_server.py +657 -0
  58. meltygui/core/automation/orchestration_core.py +2636 -0
  59. meltygui/core/automation/query_core.py +124 -0
  60. meltygui/core/automation/search_core.py +26 -0
  61. meltygui/core/automation/selector_core.py +340 -0
  62. meltygui/core/automation/value_core.py +1752 -0
  63. meltygui/core/cache/__init__.py +1 -0
  64. meltygui/core/cache/cache_diagnostics.py +0 -0
  65. meltygui/core/cache/invalidation_decoration.py +153 -0
  66. meltygui/core/cache/invalidation_tracker.py +43 -0
  67. meltygui/core/cache/tile_cache.py +5968 -0
  68. meltygui/core/conversion/__init__.py +1 -0
  69. meltygui/core/conversion/bubbling.py +599 -0
  70. meltygui/core/conversion/cache_tree.py +188 -0
  71. meltygui/core/conversion/chain.py +113 -0
  72. meltygui/core/conversion/converter_register.py +145 -0
  73. meltygui/core/conversion/data_decoration.py +67 -0
  74. meltygui/core/conversion/dict_conversion.py +1815 -0
  75. meltygui/core/conversion/dict_conversion_util.py +177 -0
  76. meltygui/core/conversion/dynamic_obj.py +89 -0
  77. meltygui/core/conversion/graph_compare.py +183 -0
  78. meltygui/core/conversion/load_save_v2.py +1032 -0
  79. meltygui/core/conversion/missing_saved_class.py +39 -0
  80. meltygui/core/conversion/path_finder.py +606 -0
  81. meltygui/core/conversion/render_host.py +1083 -0
  82. meltygui/core/core_render.py +6537 -0
  83. meltygui/core/definition_hotswap.py +231 -0
  84. meltygui/core/diagnostics/__init__.py +1 -0
  85. meltygui/core/diagnostics/attribute_churn.py +38 -0
  86. meltygui/core/diagnostics/fps_counter.py +39 -0
  87. meltygui/core/diagnostics/gpu_frame_timer.py +103 -0
  88. meltygui/core/diagnostics/inspection_core.py +169 -0
  89. meltygui/core/diagnostics/monitor_core.py +105 -0
  90. meltygui/core/diagnostics/notifications.py +706 -0
  91. meltygui/core/diagnostics/perf_trace.py +281 -0
  92. meltygui/core/diagnostics/profile_decoration.py +88 -0
  93. meltygui/core/diagnostics/resize_trace.py +62 -0
  94. meltygui/core/diagnostics/screenshot_core.py +247 -0
  95. meltygui/core/diagnostics/session_status.py +98 -0
  96. meltygui/core/diagnostics/trace_core.py +445 -0
  97. meltygui/core/files/__init__.py +1 -0
  98. meltygui/core/files/file_core.py +208 -0
  99. meltygui/core/files/file_explorer_core.py +104 -0
  100. meltygui/core/files/file_tree_core.py +198 -0
  101. meltygui/core/files/file_watch_core.py +43 -0
  102. meltygui/core/files/import_graph_core.py +43 -0
  103. meltygui/core/files/metadata_core.py +51 -0
  104. meltygui/core/graphics/__init__.py +1 -0
  105. meltygui/core/graphics/cuda_context_core.py +166 -0
  106. meltygui/core/graphics/cuda_interop_core.py +136 -0
  107. meltygui/core/graphics/cuda_kernel_core.py +91 -0
  108. meltygui/core/graphics/framebuffer_recorder.py +337 -0
  109. meltygui/core/graphics/gl_state.py +658 -0
  110. meltygui/core/graphics/lut_core.py +52 -0
  111. meltygui/core/graphics/overlay_renderer.py +984 -0
  112. meltygui/core/graphics/scene_target.py +180 -0
  113. meltygui/core/graphics/screenshot.py +439 -0
  114. meltygui/core/graphics/shader_func.py +478 -0
  115. meltygui/core/graphics/tensor_core.py +45 -0
  116. meltygui/core/graphics/text_texture.py +329 -0
  117. meltygui/core/graphics/wayland_color.py +635 -0
  118. meltygui/core/input/__init__.py +1 -0
  119. meltygui/core/input/collision.py +165 -0
  120. meltygui/core/input/drag_drop_core.py +1525 -0
  121. meltygui/core/input/hypr_left_drag.py +323 -0
  122. meltygui/core/input/input_core.py +245 -0
  123. meltygui/core/input/input_handler.py +1101 -0
  124. meltygui/core/input/mouse_cursor.py +355 -0
  125. meltygui/core/input/pynput_backend.py +1054 -0
  126. meltygui/core/input/space_mouse.py +338 -0
  127. meltygui/core/input/touchpad_backend.py +393 -0
  128. meltygui/core/input/view_selection.py +177 -0
  129. meltygui/core/layout/__init__.py +1 -0
  130. meltygui/core/layout/column_core.py +2153 -0
  131. meltygui/core/layout/cursor_core.py +161 -0
  132. meltygui/core/layout/dropdown_core.py +278 -0
  133. meltygui/core/layout/edge_constraints.py +155 -0
  134. meltygui/core/layout/grid_core.py +117 -0
  135. meltygui/core/layout/header_core.py +23 -0
  136. meltygui/core/layout/header_runtime.py +67 -0
  137. meltygui/core/layout/layout_core.py +87 -0
  138. meltygui/core/layout/tile_manager_core.py +591 -0
  139. meltygui/core/melty.py +6726 -0
  140. meltygui/core/module_map.json +896 -0
  141. meltygui/core/module_names.py +19 -0
  142. meltygui/core/rendering/__init__.py +1 -0
  143. meltygui/core/rendering/core_decoration.py +431 -0
  144. meltygui/core/rendering/core_render_helpers.py +328 -0
  145. meltygui/core/rendering/func_metadata.py +398 -0
  146. meltygui/core/rendering/mode.py +818 -0
  147. meltygui/core/rendering/mode_defaults.py +41 -0
  148. meltygui/core/rendering/modes.py +136 -0
  149. meltygui/core/rendering/parameter_core.py +1665 -0
  150. meltygui/core/rendering/render_dispatch.py +1891 -0
  151. meltygui/core/rendering/render_funcs.py +273 -0
  152. meltygui/core/rendering/shaped.py +312 -0
  153. meltygui/core/rendering/window_decoration.py +25 -0
  154. meltygui/core/runtime/__init__.py +1 -0
  155. meltygui/core/runtime/app.py +735 -0
  156. meltygui/core/runtime/app_session.py +140 -0
  157. meltygui/core/runtime/background.py +564 -0
  158. meltygui/core/runtime/extensions.py +53 -0
  159. meltygui/core/runtime/gc_manager.py +1163 -0
  160. meltygui/core/runtime/lifecycle.py +21 -0
  161. meltygui/core/runtime/paths.py +27 -0
  162. meltygui/core/runtime/settings.py +14 -0
  163. meltygui/core/runtime/singleton.py +16 -0
  164. meltygui/core/runtime/thread_safe_bool.py +24 -0
  165. meltygui/core/runtime/thread_signal.py +30 -0
  166. meltygui/core/runtime/toggles.py +3115 -0
  167. meltygui/core/services/__init__.py +1 -0
  168. meltygui/core/services/account_core.py +10 -0
  169. meltygui/core/services/chat_core.py +19 -0
  170. meltygui/core/services/claude_terminal_core.py +346 -0
  171. meltygui/core/services/terminal_core.py +458 -0
  172. meltygui/core/services/terminal_runtime.py +78 -0
  173. meltygui/core/styling/__init__.py +1 -0
  174. meltygui/core/styling/color_core.py +46 -0
  175. meltygui/core/styling/fonts.py +639 -0
  176. meltygui/core/styling/global_style.py +338 -0
  177. meltygui/core/styling/style.py +198 -0
  178. meltygui/core/styling/style_core.py +522 -0
  179. meltygui/core/styling/warm_start.py +149 -0
  180. meltygui/core/windowing/__init__.py +1 -0
  181. meltygui/core/windowing/backends/PYIMGUI_LICENSE +28 -0
  182. meltygui/core/windowing/backends/__init__.py +1 -0
  183. meltygui/core/windowing/backends/imgui_renderer.py +138 -0
  184. meltygui/core/windowing/backends/native_wayland.py +850 -0
  185. meltygui/core/windowing/backends/protocols/xdg-decoration-unstable-v1.xml +156 -0
  186. meltygui/core/windowing/backends/protocols/xdg-shell.xml +1420 -0
  187. meltygui/core/windowing/backends/wayland_protocol.py +101 -0
  188. meltygui/core/windowing/dock_core.py +182 -0
  189. meltygui/core/windowing/frame_geometry.py +42 -0
  190. meltygui/core/windowing/geometry_feed.py +864 -0
  191. meltygui/core/windowing/glfw_utils.py +1343 -0
  192. meltygui/core/windowing/os_frame.py +1552 -0
  193. meltygui/core/windowing/surface.py +643 -0
  194. meltygui/core/windowing/titlebar.py +1560 -0
  195. meltygui/core/windowing/titlebar_buttons.py +281 -0
  196. meltygui/core/windowing/wayland_move.py +932 -0
  197. meltygui/core/windowing/window_api.py +62 -0
  198. meltygui/core/windowing/window_constants.py +339 -0
  199. meltygui/core/windowing/window_visibility.py +162 -0
  200. meltygui/debug/__init__.py +0 -0
  201. meltygui/debug/app_view_utils.py +9 -0
  202. meltygui/editor/__init__.py +0 -0
  203. meltygui/editor/bash_syntax.py +30 -0
  204. meltygui/editor/code_line_fast.py +152 -0
  205. meltygui/editor/diff.py +139 -0
  206. meltygui/editor/external_changes.py +159 -0
  207. meltygui/editor/file_header.py +41 -0
  208. meltygui/editor/live_usage.py +169 -0
  209. meltygui/editor/live_view_views.py +1803 -0
  210. meltygui/editor/pending_save.py +1351 -0
  211. meltygui/editor/roster_tints.py +547 -0
  212. meltygui/editor/source_preview.py +16 -0
  213. meltygui/editor/source_tools.py +10 -0
  214. meltygui/editor/source_ui.py +70 -0
  215. meltygui/editor/spell_check.py +77 -0
  216. meltygui/editor/text_editor.py +9076 -0
  217. meltygui/editor/usage_picker.py +538 -0
  218. meltygui/events/__init__.py +0 -0
  219. meltygui/events/example.py +118 -0
  220. meltygui/examples/__init__.py +0 -0
  221. meltygui/examples/columns_demo.py +33 -0
  222. meltygui/examples/columns_window_demo.py +60 -0
  223. meltygui/examples/context_menu_demo.py +24 -0
  224. meltygui/examples/context_menu_window_demo.py +49 -0
  225. meltygui/examples/gui_playground.py +127 -0
  226. meltygui/examples/live_view_playground.py +256 -0
  227. meltygui/examples/lora.py +43 -0
  228. meltygui/examples/lora_data.py +59 -0
  229. meltygui/examples/lora_policies.py +67 -0
  230. meltygui/examples/mode_demo.py +49 -0
  231. meltygui/examples/modifies_demo.py +119 -0
  232. meltygui/examples/scalar_policies.py +60 -0
  233. meltygui/examples/style_layouts.py +149 -0
  234. meltygui/examples/tile_manager_demo.py +74 -0
  235. meltygui/examples/tint_demo.py +160 -0
  236. meltygui/examples/tint_functions.py +56 -0
  237. meltygui/examples/trace_demo.py +88 -0
  238. meltygui/examples/two_windows.py +36 -0
  239. meltygui/files/__init__.py +0 -0
  240. meltygui/files/fast_file_explorer.py +439 -0
  241. meltygui/gnome_extension/lsd-window-geometry@latent-descent/extension.js +237 -0
  242. meltygui/gnome_extension/lsd-window-geometry@latent-descent/lsd-window-geometry@latent-descent.iml +9 -0
  243. meltygui/gnome_extension/lsd-window-geometry@latent-descent/metadata.json +7 -0
  244. meltygui/graphics/__init__.py +6 -0
  245. meltygui/graphics/base.py +85 -0
  246. meltygui/graphics/examples.py +507 -0
  247. meltygui/graphics/executor.py +520 -0
  248. meltygui/graphics/filter.py +667 -0
  249. meltygui/graphics/filter.pyi +856 -0
  250. meltygui/graphics/generate_stubs.py +22 -0
  251. meltygui/graphics/registry.py +203 -0
  252. meltygui/graphics/shader_compiler.py +155 -0
  253. meltygui/graphics/shaders.py +1302 -0
  254. meltygui/graphics/stub_generator.py +250 -0
  255. meltygui/graphics/texture_manager.py +170 -0
  256. meltygui/graphics/texture_min_max.py +295 -0
  257. meltygui/hdr_color.py +757 -0
  258. meltygui/image_load.py +308 -0
  259. meltygui/model/__init__.py +1 -0
  260. meltygui/model/account_model.py +142 -0
  261. meltygui/model/camera_model.py +159 -0
  262. meltygui/model/chat_model.py +86 -0
  263. meltygui/model/code_model.py +62 -0
  264. meltygui/model/code_proxy_model.py +876 -0
  265. meltygui/model/collection_model.py +41 -0
  266. meltygui/model/color_model.py +127 -0
  267. meltygui/model/cuda_tensor_model.py +36 -0
  268. meltygui/model/cuda_texture_model.py +149 -0
  269. meltygui/model/dropdown_model.py +137 -0
  270. meltygui/model/file_metadata_model.py +73 -0
  271. meltygui/model/file_model.py +289 -0
  272. meltygui/model/format_model.py +390 -0
  273. meltygui/model/graph_model.py +98 -0
  274. meltygui/model/icon_model.py +1024 -0
  275. meltygui/model/import_graph_model.py +468 -0
  276. meltygui/model/layout_model.py +15 -0
  277. meltygui/model/lut_model.py +266 -0
  278. meltygui/model/search_model.py +173 -0
  279. meltygui/model/tensor_model.py +402 -0
  280. meltygui/model/terminal_model.py +329 -0
  281. meltygui/model/texture_model.py +146 -0
  282. meltygui/model/tile_model.py +24 -0
  283. meltygui/model/trace_model.py +198 -0
  284. meltygui/model/trace_report_model.py +146 -0
  285. meltygui/models/__init__.py +0 -0
  286. meltygui/models/file_meta.py +613 -0
  287. meltygui/models/function_console.py +184 -0
  288. meltygui/models/orchestration.py +48 -0
  289. meltygui/pbr.py +1576 -0
  290. meltygui/png_unfilter.c +49 -0
  291. meltygui/resources/JetBrainsMono-Regular.ttf +0 -0
  292. meltygui/resources/THIRD_PARTY_NOTICES.md +21 -0
  293. meltygui/resources/dejavu/DejaVuSans-Bold.ttf +0 -0
  294. meltygui/resources/dejavu/DejaVuSans-ExtraLight.ttf +0 -0
  295. meltygui/resources/dejavu/DejaVuSans.ttf +0 -0
  296. meltygui/resources/dejavu/LICENSE.txt +78 -0
  297. meltygui/resources/fontawesome-LICENSE.txt +121 -0
  298. meltygui/resources/fontawesome-webfont.ttf +0 -0
  299. meltygui/resources/hdri/studio_small_09_1k.hdr +0 -0
  300. meltygui/resources/jetbrains-weights/JetBrainsMono-Bold.ttf +0 -0
  301. meltygui/resources/jetbrains-weights/JetBrainsMono-ExtraBold.ttf +0 -0
  302. meltygui/resources/jetbrains-weights/JetBrainsMono-ExtraLight.ttf +0 -0
  303. meltygui/resources/jetbrains-weights/JetBrainsMono-Light.ttf +0 -0
  304. meltygui/resources/jetbrains-weights/JetBrainsMono-Medium.ttf +0 -0
  305. meltygui/resources/jetbrains-weights/JetBrainsMono-SemiBold.ttf +0 -0
  306. meltygui/resources/jetbrains-weights/JetBrainsMono-Thin.ttf +0 -0
  307. meltygui/resources/jetbrains-weights/OFL.txt +93 -0
  308. meltygui/resources/jetbrains-weights/README.md +2 -0
  309. meltygui/state/__init__.py +0 -0
  310. meltygui/state/account_state.py +22 -0
  311. meltygui/state/animation_state.py +97 -0
  312. meltygui/state/annotation_state.py +22 -0
  313. meltygui/state/chat_state.py +36 -0
  314. meltygui/state/code_state.py +10 -0
  315. meltygui/state/core_enums.py +59 -0
  316. meltygui/state/core_markers.py +115 -0
  317. meltygui/state/core_undo.py +1075 -0
  318. meltygui/state/file_state.py +75 -0
  319. meltygui/state/graph_state.py +26 -0
  320. meltygui/state/inspection_state.py +47 -0
  321. meltygui/state/menu_state.py +10 -0
  322. meltygui/state/model_enums.py +11 -0
  323. meltygui/state/new_core_model.py +2394 -0
  324. meltygui/state/orchestration_state.py +14 -0
  325. meltygui/state/query_state.py +24 -0
  326. meltygui/state/tensor_state.py +10 -0
  327. meltygui/state/terminal_state.py +23 -0
  328. meltygui/state/trace_state.py +32 -0
  329. meltygui/state/voxel_state.py +12 -0
  330. meltygui/text_index.py +816 -0
  331. meltygui/utils/__init__.py +0 -0
  332. meltygui/utils/jump_to_code.py +344 -0
  333. meltygui/utils/pkl_inspect.py +90 -0
  334. meltygui/utils/render_utils.py +1419 -0
  335. meltygui/view/__init__.py +1 -0
  336. meltygui/view/account_view.py +524 -0
  337. meltygui/view/action_view.py +73 -0
  338. meltygui/view/chat_decoration_view.py +112 -0
  339. meltygui/view/chat_view.py +1703 -0
  340. meltygui/view/code_view.py +2677 -0
  341. meltygui/view/collection_view.py +1185 -0
  342. meltygui/view/color_view.py +824 -0
  343. meltygui/view/control_view.py +559 -0
  344. meltygui/view/decoration_view.py +439 -0
  345. meltygui/view/diagnostic_view.py +177 -0
  346. meltygui/view/dropdown_view.py +1082 -0
  347. meltygui/view/file_view.py +1599 -0
  348. meltygui/view/graph_cuda_view.py +105 -0
  349. meltygui/view/graph_view.py +882 -0
  350. meltygui/view/header_view.py +848 -0
  351. meltygui/view/input_view.py +238 -0
  352. meltygui/view/inspection_view.py +1783 -0
  353. meltygui/view/layout_view.py +596 -0
  354. meltygui/view/lut_view.py +45 -0
  355. meltygui/view/menu_view.py +212 -0
  356. meltygui/view/orchestration_view.py +658 -0
  357. meltygui/view/query_view.py +118 -0
  358. meltygui/view/search_view.py +342 -0
  359. meltygui/view/tab_view.py +258 -0
  360. meltygui/view/tensor_view.py +430 -0
  361. meltygui/view/terminal_view.py +355 -0
  362. meltygui/view/text_view.py +8031 -0
  363. meltygui/view/texture_view.py +480 -0
  364. meltygui/view/tile_view.py +38 -0
  365. meltygui/view/trace_view.py +923 -0
  366. meltygui/view/voxel_cuda_view.py +824 -0
  367. meltygui/view/voxel_view.py +1860 -0
  368. meltygui/view/window_view.py +111 -0
  369. meltygui-0.1.0.dist-info/METADATA +143 -0
  370. meltygui-0.1.0.dist-info/RECORD +372 -0
  371. meltygui-0.1.0.dist-info/WHEEL +4 -0
  372. meltygui-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,1588 @@
1
+ """Symbol roster — text-derived definitions + metadata, the source of truth for
2
+ inline code tints and Ctrl+B, with NO dependence on live objects.
3
+
4
+ The old pipelines (definition tints in `_collect_def_tints`, the Ctrl+B usage
5
+ graph in `libcst_conversion._symbol_refs_index`) resolved symbols against
6
+ IMPORTED objects (`vars(module)`, `id(obj)`, `co_firstlineno`), so a def you
7
+ had only just typed — or edited without a hotswap — was invisible or stale,
8
+ and every line came in DISK coordinates that had to be bridged to the
9
+ editor's pending text. The roster instead reads the same text the editor
10
+ shows: PendingSave's in-memory file (disk + every unsaved span edit) for
11
+ every file, and the LIVE buffer for the file being drawn. Add a tinted class
12
+ to one file and the references in another wash on the next frame; nothing
13
+ is saved, recompiled or hotswapped.
14
+
15
+ Three layers, all plain text:
16
+
17
+ FileTable per .py file: every class / def (any nesting, closures included)
18
+ and every module- or class-level assignment, as Entry rows with
19
+ a QUALNAME built from the indent stack, the block extent, and
20
+ the tint its source declares (decorator kwarg / `# [tint=...]`
21
+ comment / class-body `tint = (...)`, via the editor's own
22
+ `_scan_def_tint_lines`), plus the file's import table.
23
+ Cached per file on a content-free key: (identity of the
24
+ FileWatch disk string, pending generation) — or the live
25
+ buffer's identity when the editor hands one in.
26
+ resolve() textual name resolution of a dotted chain in a file's context:
27
+ the file's own table → its imports (module dotted path mapped
28
+ onto the src tree, relative imports included) → a unique
29
+ same-named definition anywhere in the roster (so a brand-new
30
+ class paints before its import line exists). No scope
31
+ analysis: `self.x` / attribute access on values is None.
32
+ usages_of() the reverse index: trigram candidates (text_index.candidate_paths)
33
+ → code-only identifier scan → each occurrence resolved IN ITS
34
+ OWN FILE'S context and kept only when it resolves back to the
35
+ same entry. Member names (`Foo.bar`) additionally accept
36
+ `<anything>.bar` when `bar` is the only member of that name in
37
+ the roster (flagged `probable`).
38
+
39
+ `set_tint` is the lens write-through: a tint edit stamps the entry before the
40
+ source round-trips, so a drag never waits for re-extraction; the override is
41
+ dropped as soon as the re-extracted table agrees (or ages out).
42
+
43
+ Everything here is pure Python over strings — standalone-testable. Editor
44
+ integration lives in `view/core_views/roster_tints.py`.
45
+ """
46
+ from __future__ import annotations
47
+
48
+ import keyword
49
+ import os
50
+ import re
51
+ import sys
52
+ import threading
53
+ import time
54
+ from pathlib import Path
55
+
56
+ from meltygui.code.source_context import analysis_project
57
+
58
+ # ── Entry / table shapes ─────────────────────────────────────────────────────
59
+
60
+ class Entry:
61
+ """One definition. `line`/`end` are 1-based inclusive FILE lines (end =
62
+ last line before the dedent, the block the editor's wash covers); `col`
63
+ is the 0-based column of the NAME token on `line`. `kind` is "class" /
64
+ "def" / "var". `qualname` is the indent-derived dotted path ("Toggles.
65
+ TextEditor.enable_live_view", "draw_text._try_usage_jump")."""
66
+ __slots__ = ("path", "qualname", "name", "kind", "line", "end", "indent",
67
+ "col", "tint", "parent", "sig")
68
+
69
+ def __init__(self, path, qualname, name, kind, line, end, indent, col,
70
+ tint, parent, sig):
71
+ self.path = path
72
+ self.qualname = qualname
73
+ self.name = name
74
+ self.kind = kind
75
+ self.line = line
76
+ self.end = end
77
+ self.indent = indent
78
+ self.col = col
79
+ self.tint = tint
80
+ self.parent = parent
81
+ self.sig = sig
82
+
83
+ @property
84
+ def ident(self):
85
+ return (self.path, self.qualname)
86
+
87
+ def __repr__(self):
88
+ return (f"Entry({self.kind} {self.qualname} @ {os.path.basename(self.path)}:"
89
+ f"{self.line}-{self.end} tint={self.tint})")
90
+
91
+
92
+ class FileTable:
93
+ __slots__ = ("path", "key", "entries", "by_qualname", "by_name", "imports",
94
+ "star_imports", "nlines", "_scope_index")
95
+
96
+ def __init__(self, path, key, entries, imports, star_imports, nlines):
97
+ self._scope_index = None
98
+ self.path = path
99
+ self.key = key
100
+ self.entries = tuple(entries)
101
+ self.by_qualname = {}
102
+ self.by_name = {}
103
+ for e in entries:
104
+ self.by_qualname.setdefault(e.qualname, e)
105
+ self.by_name.setdefault(e.name, []).append(e)
106
+ self.imports = imports # alias -> (module_dotted, attr | None)
107
+ self.star_imports = star_imports # [module_dotted]
108
+ self.nlines = nlines
109
+
110
+ def scope_at(self, line):
111
+ """Innermost class/def Entry whose block contains 1-based `line`, or
112
+ None at module level. Per-line index built lazily (one pass over the
113
+ entries' blocks) so a pass resolving thousands of chains pays O(1)."""
114
+ idx = self._scope_index
115
+ if idx is None:
116
+ idx = self._scope_index = self._build_scope_index()
117
+ if 1 <= line <= len(idx):
118
+ ix = idx[line - 1]
119
+ return self.entries[ix] if ix >= 0 else None
120
+ return None
121
+
122
+ def _build_scope_index(self):
123
+ n = self.nlines
124
+ idx = [-1] * n
125
+ for i, e in enumerate(self.entries):
126
+ if e.kind == "var":
127
+ continue
128
+ lo, hi = max(0, e.line - 1), min(n, e.end)
129
+ for k in range(lo, hi):
130
+ idx[k] = i # later (inner) entries overwrite outer ones
131
+ return idx
132
+
133
+ def visible_scopes(self, scope):
134
+ """Qualnames whose members a bare name in `scope` (an Entry or None)
135
+ can see, innermost first — Python's rule: the innermost scope itself,
136
+ then enclosing FUNCTIONS (closures see them); enclosing CLASS bodies
137
+ are skipped (a method can't see its class's names unqualified)."""
138
+ out = []
139
+ first = True
140
+ while scope is not None:
141
+ if first or scope.kind != "class":
142
+ out.append(scope.qualname)
143
+ first = False
144
+ scope = self.by_qualname.get(scope.parent) if scope.parent else None
145
+ return out
146
+
147
+ def enclosing_class(self, scope):
148
+ """Qualname of the nearest enclosing class of `scope` (for self/cls)."""
149
+ while scope is not None:
150
+ if scope.kind == "class":
151
+ return scope.qualname
152
+ scope = self.by_qualname.get(scope.parent) if scope.parent else None
153
+ return None
154
+
155
+
156
+ _DEF_RE = re.compile(r"^(\s*)(?:async\s+)?(class|def)\s+([A-Za-z_]\w*)")
157
+ _ASSIGN_RE = re.compile(r"^(\s*)([A-Za-z_]\w*)\s*(?::[^=\n]+)?=(?!=)")
158
+ _IMPORT_FROM_RE = re.compile(r"^\s*from\s+([\w.]+)\s+import\s+(.*)$")
159
+ _IMPORT_RE = re.compile(r"^\s*import\s+(.*)$")
160
+ # Code-only identifier-chain scan: strings and comments are escaped by the
161
+ # leading alternatives (and ignored); group 5 is a dotted chain.
162
+ _CODE_RE = re.compile(
163
+ r'("""|\'\'\')(?:\\.|(?!\1).)*?\1'
164
+ r'|"(?:\\.|[^"\\\n])*"'
165
+ r"|'(?:\\.|[^'\\\n])*'"
166
+ r'|#[^\n]*'
167
+ r'|([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)', re.S) # group 2 = the chain
168
+ _KEYWORDS = frozenset(keyword.kwlist) | {"self", "cls", "True", "False", "None"}
169
+
170
+
171
+ def _scan_tint(lines, line_no, name, tint_lines=None, lookback=40):
172
+ """Explicit source tint of the definition/assignment at 1-based line_no
173
+ via the editor's resolver; None standalone (tests) or when untinted.
174
+ `tint_lines` (sorted 0-based indices of lines containing "tint", from
175
+ tint_line_index) is a prefilter: a tint can only come from the line
176
+ itself or the comment/decorator run above it, so a def with no "tint"
177
+ within `lookback` lines above is skipped without the (regex-heavy) scan."""
178
+ if tint_lines is not None:
179
+ import bisect
180
+ i = line_no - 1
181
+ k = bisect.bisect_right(tint_lines, i)
182
+ if k == 0:
183
+ return None
184
+ if lookback is None:
185
+ # Comment scan mode (assignments): the tint could sit anywhere in
186
+ # the `#` run directly above - an 8-line `# [tint=..., cam_zoom=
187
+ # ..., light_pos=(...)]` override is common - so back up over it.
188
+ j = i - 1
189
+ while j >= 0 and j > i - 64 and lines[j].lstrip().startswith("#"):
190
+ j -= 1
191
+ if tint_lines[k - 1] <= j:
192
+ return None
193
+ elif tint_lines[k - 1] < i - lookback:
194
+ return None
195
+ try:
196
+ from meltygui.editor.text_editor import _scan_def_tint_lines
197
+ except ImportError:
198
+ return None
199
+ try:
200
+ res = _scan_def_tint_lines(lines, line_no, name)
201
+ return tuple(res[0][:3]) if res else None
202
+ except Exception:
203
+ return None
204
+
205
+
206
+ _TINT_ASSIGN_RE = re.compile(r"tint\s*=\s*\(")
207
+
208
+
209
+ def tint_line_index(lines):
210
+ """Sorted 0-based indices of lines that could DECLARE a tint — a
211
+ `tint=(` assignment form (decorator kwarg, `# [tint=(...)]` comment,
212
+ class-body `tint = (...)`) — the cheap prefilter for every explicit-tint
213
+ scan over a buffer. Prose merely mentioning "tint" doesn't count."""
214
+ return [i for i, l in enumerate(lines) if "tint" in l and _TINT_ASSIGN_RE.search(l)]
215
+
216
+
217
+ # Identity-keyed scan that pins its table; stored separately from the
218
+ # published tables so this optimization adds no slots to live FileTables.
219
+ _EXTRACT_SCANS = {}
220
+
221
+
222
+ def extract_table(path, text, key=None, with_tints=True, previous=None):
223
+ """Build a FileTable from `text` in ONE indent-stack pass (same shape as
224
+ text_index._extract_symbols, plus qualnames, assignments and imports).
225
+ Defs nest by indent: a def inside a def is an entry ("outer.inner" — a
226
+ closure the live-object roster never had). Assignments are recorded at
227
+ module level and in class bodies only (locals are the usage graph's
228
+ business), first binding per qualname wins."""
229
+ lines = text.split("\n")
230
+ tl = tint_line_index(lines) if with_tints else None
231
+ entries = []
232
+ open_ix = [] # indices into `entries` of the open class/def stack
233
+ imports, stars = {}, []
234
+ pending_import = None # a multi-line import statement being collected
235
+ paren_depth = 0
236
+ start = 0
237
+ checkpoints = []
238
+ scan = _EXTRACT_SCANS.get(id(previous)) if with_tints else None
239
+ if scan is not None and scan[0] is previous:
240
+ old_text = scan[1]
241
+ # The caller already found a generation/identity miss. Locate the
242
+ # prefix to resume a scan; this comparison is not cache invalidation.
243
+ prefix = 0
244
+ limit = min(len(old_text), len(text))
245
+ for step in (65536, 4096, 256, 16, 1):
246
+ while prefix + step <= limit and old_text[prefix:prefix + step] == text[prefix:prefix + step]:
247
+ prefix += step
248
+ # Tint lookup can look ahead 40 lines. Start earlier so edits to
249
+ # a class-body tint also refresh the class's preceding checkpoints.
250
+ before = max(0, text.count('\n', 0, prefix) - 64)
251
+ for checkpoint in scan[2]:
252
+ if checkpoint[0] > before:
253
+ break
254
+ checkpoints.append(checkpoint)
255
+ if checkpoints:
256
+ start, count, saved_imports, saved_stars = checkpoints.pop()
257
+ entries = list(previous.entries[:count])
258
+ imports, stars = dict(saved_imports), list(saved_stars)
259
+ for i in range(start, len(lines)):
260
+ ln = lines[i]
261
+ s = ln.strip()
262
+ if pending_import is not None:
263
+ pending_import += " " + s.rstrip("\\").strip()
264
+ paren_depth += s.count("(") - s.count(")")
265
+ if paren_depth <= 0 and not s.endswith("\\"):
266
+ _parse_import(pending_import, path, imports, stars)
267
+ pending_import = None
268
+ continue
269
+ if not s or s.startswith("#"):
270
+ continue
271
+ indent = len(ln) - len(ln.lstrip())
272
+ while open_ix and entries[open_ix[-1]].indent >= indent:
273
+ entries[open_ix.pop()].end = i # 1-based inclusive: i+1 is outside
274
+ if with_tints and indent == 0 and not open_ix:
275
+ checkpoints.append((i, len(entries), dict(imports), tuple(stars)))
276
+ m = _DEF_RE.match(ln) if s.startswith(('def', 'class', 'async')) else None
277
+ if m is not None:
278
+ name = m.group(3)
279
+ parent = entries[open_ix[-1]] if open_ix else None
280
+ qn = f"{parent.qualname}.{name}" if parent is not None else name
281
+ e = Entry(path, qn, name, m.group(2), i + 1, len(lines), indent,
282
+ m.start(3), _scan_tint(lines, i + 1, name, tl) if with_tints else None,
283
+ parent.qualname if parent is not None else None,
284
+ ln.rstrip()[:160])
285
+ entries.append(e)
286
+ open_ix.append(len(entries) - 1)
287
+ continue
288
+ if s.startswith(("import ", "from ")) and (s.startswith("import ")
289
+ or " import" in s):
290
+ paren_depth = s.count("(") - s.count(")")
291
+ if paren_depth > 0 or s.endswith("\\"):
292
+ pending_import = s.rstrip("\\").strip()
293
+ else:
294
+ _parse_import(s, path, imports, stars)
295
+ continue
296
+ # Local assignments never enter the roster. Avoid running the
297
+ # assignment regexp over each expression in a nested function.
298
+ if open_ix and any(entries[ix].kind == "def" for ix in open_ix):
299
+ continue
300
+ m = _ASSIGN_RE.match(ln) if '=' in s else None
301
+ if m is not None:
302
+ # Module level or directly in a class body (no def on the stack).
303
+ parent = entries[open_ix[-1]] if open_ix else None
304
+ name = m.group(2)
305
+ qn = f"{parent.qualname}.{name}" if parent is not None else name
306
+ if any(e.qualname == qn for e in entries[-64:]):
307
+ continue # re-binding: first binding wins per name (cheap win)
308
+ entries.append(Entry(path, qn, name, "var", i + 1, i + 1, indent,
309
+ m.start(2),
310
+ _scan_tint(lines, i + 1, None, tl, None) if with_tints else None,
311
+ parent.qualname if parent is not None else None,
312
+ ln.rstrip()[:160]))
313
+ if pending_import is not None:
314
+ _parse_import(pending_import, path, imports, stars)
315
+ # Entries still open at EOF end on the last non-blank line.
316
+ last = len(lines)
317
+ while last > 1 and not lines[last - 1].strip():
318
+ last -= 1
319
+ for ix in open_ix:
320
+ entries[ix].end = last
321
+ table = FileTable(path, key, entries, imports, stars, len(lines))
322
+ if with_tints:
323
+ if len(_EXTRACT_SCANS) >= 64:
324
+ del _EXTRACT_SCANS[next(iter(_EXTRACT_SCANS))]
325
+ _EXTRACT_SCANS[id(table)] = (table, text, checkpoints)
326
+ return table
327
+
328
+
329
+ def _parse_import(stmt, path, imports, stars):
330
+ """Fill `imports` {alias: (module, attr|None)} from one import statement
331
+ (multi-line already joined). `from X import a as b, c` → b:(X,a), c:(X,c);
332
+ `import a.b.c as z` → z:(a.b.c, None); `import a.b.c` → a:(a, None).
333
+ Relative `from .x import y` resolves against the file's package."""
334
+ stmt = stmt.replace("(", " ").replace(")", " ")
335
+ m = _IMPORT_FROM_RE.match(stmt)
336
+ if m is not None:
337
+ mod = m.group(1) # Relative imports resolve in the consumer's package context.
338
+ if mod is None:
339
+ return
340
+ names = m.group(2)
341
+ for part in names.split(","):
342
+ part = part.strip()
343
+ if not part:
344
+ continue
345
+ if part == "*":
346
+ stars.append(mod)
347
+ continue
348
+ bits = part.split()
349
+ name = bits[0]
350
+ alias = bits[2] if len(bits) >= 3 and bits[1] == "as" else name
351
+ if re.fullmatch(r"[A-Za-z_]\w*", name) and re.fullmatch(r"[A-Za-z_]\w*", alias):
352
+ imports[alias] = (mod, name)
353
+ return
354
+ m = _IMPORT_RE.match(stmt)
355
+ if m is not None:
356
+ for part in m.group(1).split(","):
357
+ bits = part.split()
358
+ if not bits:
359
+ continue
360
+ mod = bits[0]
361
+ if len(bits) >= 3 and bits[1] == "as":
362
+ imports[bits[2]] = (mod, None)
363
+ else:
364
+ top = mod.split(".", 1)[0]
365
+ imports.setdefault(top, (top, None))
366
+
367
+
368
+ def _absolutize(mod, path, project=None):
369
+ """Resolve a relative import in its package, including src layouts."""
370
+ if not mod.startswith("."):
371
+ return mod
372
+ project = analysis_project(project, path)
373
+ dots = len(mod) - len(mod.lstrip("."))
374
+ parent = Path(path).parent
375
+ # Prefer the deepest import root containing the file. The root/src
376
+ # pair supports both `pkg` and the checkout's explicit `src.pkg` imports.
377
+ roots = [Path(root) for root in project.import_paths
378
+ if parent.is_relative_to(root) and parent != Path(root)]
379
+ if not roots:
380
+ return None
381
+ root = max(roots, key=lambda value: len(value.parts))
382
+ package = list(parent.relative_to(root).parts)
383
+ if dots > len(package):
384
+ return None
385
+ package = package[:len(package) - dots + 1]
386
+ rest = mod.lstrip(".")
387
+ return ".".join(package + ([rest] if rest else [])) or None
388
+
389
+
390
+ def _src_root():
391
+ from meltygui.code.libcst_conversion import _SRC_PREFIX
392
+ return _SRC_PREFIX.rstrip("/")
393
+
394
+
395
+ _mod_path_cache = {}
396
+
397
+
398
+ def module_to_path(dotted, project=None):
399
+ """Resolve a module against this project's ordered source/venv paths."""
400
+ if not dotted:
401
+ return None
402
+ project = analysis_project(project)
403
+ key = (project.key, dotted)
404
+ hit = _mod_path_cache.get(key)
405
+ if hit is not None:
406
+ # Misses expire so a newly created module becomes resolvable.
407
+ if hit[0] or time.monotonic() - hit[1] < 2.0:
408
+ return hit[0] or None
409
+ found = None
410
+ parts = dotted.split(".")
411
+ for base in project.import_paths:
412
+ path = Path(base).joinpath(*parts)
413
+ for candidate in (path / "__init__.py", path.with_suffix(".py"), path.with_suffix(".pyi")):
414
+ if candidate.is_file():
415
+ found = str(candidate.resolve())
416
+ break
417
+ if found:
418
+ break
419
+ if len(_mod_path_cache) > 4096:
420
+ _mod_path_cache.clear()
421
+ _mod_path_cache[key] = (found, time.monotonic())
422
+ return found
423
+
424
+
425
+ # ── Table management ──────────────────────────────────────────────────────────────
426
+
427
+ _STATE_DEFAULTS = {
428
+ "tables": dict, # str(path) -> FileTable (pending text)
429
+ "texts": dict, # str(path) -> (key, pending text) - see file_text
430
+ "live": dict, # str(path) -> (FileTable from an old save, pending key)
431
+ "overrides": dict, # (path, qualname) -> (tint, stamp_time)
432
+ "gen": int,
433
+ "lock": threading.RLock,
434
+ "universe": lambda: None, # [str(path)] of every .py under src, or None
435
+ "universe_at": float,
436
+ "by_name": lambda: None, # name -> [Entry] module-level defs, lazily built
437
+ "by_name_gen": lambda: -1,
438
+ "by_leaf": lambda: None, # leaf name -> [Entry] members (qualname has '.')
439
+ "loading": bool,
440
+ "frozen": int,
441
+ "pass_names": lambda: None,
442
+ "last_sweep": float,
443
+ "last_full_sweep": float,
444
+ "consumers": dict, # draw_state -> gen it last drew with (see register_consumer)
445
+ "notify_timer": lambda: None,
446
+ "universe_kicked": bool,
447
+ "disk_gen": int, # bumps on every disk write / sync-frame move (see World)
448
+ }
449
+
450
+
451
+ def _state():
452
+ """Process-wide roster state, adopted through `sys` so it survives
453
+ restart-in-place / module hotswap; keys added later are backfilled."""
454
+ st = getattr(sys, "_lsd_symbol_roster", None)
455
+ if not isinstance(st, dict):
456
+ st = sys._lsd_symbol_roster = {}
457
+ for k, mk in _STATE_DEFAULTS.items():
458
+ if k not in st:
459
+ st[k] = mk()
460
+ return st
461
+
462
+
463
+ def _project_state(project):
464
+ project = analysis_project(project)
465
+ records = _state().setdefault("projects", {})
466
+ key = project.key
467
+ if key not in records:
468
+ records[key] = {"project": project, "gen": 0, "names": None,
469
+ "names_gen": -1, "universe": None, "universe_at": 0,
470
+ "loading": False, "complete": False, "paths": set(), "observed": set()}
471
+ return records[key]
472
+
473
+
474
+ def generation(project=None):
475
+ return _state()["gen"] if project is None else _project_state(project)["gen"]
476
+
477
+
478
+ def disk_generation():
479
+ """Generation of the NON-studio worlds (disk / sync frame): bumped by
480
+ FileWatch on every fs event and by ExternalChanges when a sync frame
481
+ advances. Tint caches over a World key on it — the studio generation
482
+ only moves for pending / live tables."""
483
+ return _state()["disk_gen"]
484
+
485
+
486
+ def bump_disk_generation():
487
+ st = _state()
488
+ with st["lock"]:
489
+ st["disk_gen"] += 1
490
+
491
+
492
+ # ── Worlds ───────────────────────────────────────────────────────────────────
493
+ # The roster's default tables are the STUDIO's truth: pending text, shadowed
494
+ # by a live editor's hold. A read-only view into some OTHER version of a file
495
+ # - the merge window's external (disk) and original (sync-frame) panes -
496
+ # must resolve its cross-file references against the same version of the
497
+ # other file, or `Toggles.Foo` in b.py's disk text paints in the studio's
498
+ # view while it carries another. A World is that consistent set: tables
499
+ # built from a text source, DETACHED (never installed as pending or live,
500
+ # never bumping the studio generation), memoized on the source text's
501
+ # identity (a disk write changes the code_cache string; a new baseline is
502
+ # a new object). Resolution's last-resort name index stays the studio's.
503
+
504
+ class World:
505
+ __slots__ = ("name", "text_of", "_tables")
506
+
507
+ def __init__(self, name, text_of):
508
+ self.name = name
509
+ self.text_of = text_of # resolved path str -> text or None
510
+ self._tables = {} # path -> (text, FileTable)
511
+
512
+ def table(self, path):
513
+ """The FileTable of `path` in this world; the studio's table when
514
+ the world has no text for it (unreadable / never read)."""
515
+ p = _norm(path)
516
+ text = self.text_of(p)
517
+ if not isinstance(text, str):
518
+ return table_for(p)
519
+ held = self._tables.get(p)
520
+ if held is not None and held[0] is text:
521
+ return held[1]
522
+ tbl = extract_table(p, text, (self.name, id(text)))
523
+ if len(self._tables) > 256:
524
+ self._tables.clear()
525
+ self._tables[p] = (text, tbl)
526
+ return tbl
527
+
528
+ def generation(self):
529
+ return disk_generation()
530
+
531
+
532
+ _detached = {} # path -> (text, FileTable) - see detached_table
533
+
534
+
535
+ def detached_table(path, text):
536
+ """A FileTable for `text` as the content of `path`, built OUTSIDE the
537
+ roster's caches: not installed as the file's pending table, not held as
538
+ its live override, no generation bump. For a read-only pane whose text
539
+ is neither the studio's pending truth nor a live editor buffer (the
540
+ merge window's staged result) — it resolves against the studio's other
541
+ files while its own blocks / scopes come from what it shows. Memoized
542
+ on the text's identity (the text is held, so the id can't recycle)."""
543
+ p = _norm(path)
544
+ held = _detached.get(p)
545
+ if held is not None and held[0] is text:
546
+ return held[1]
547
+ tbl = extract_table(p, text, ("detached", id(text)))
548
+ if len(_detached) > 64:
549
+ _detached.clear()
550
+ _detached[p] = (text, tbl)
551
+ return tbl
552
+
553
+
554
+ # ── Consumer notification ───────────────────────────────────────────────────
555
+ # Editors are cached tiles: their body (where _def_tints runs) only re-runs
556
+ # when invalidated, so a roster change made elsewhere - a tint change in
557
+ # another file, the background universe load finishing, a lens write - must
558
+ # invalidate the editors that drew roster tints that once per pass;
559
+ # fan-out is coalesced (one timer, ~60ms) so the first universe load (one
560
+ # gen bump per file) doesn't invalidate every editor per table. Same
561
+ # mechanics as RenderHost._notify_consumers_now (safe off-thread).
562
+
563
+ def register_consumer(draw_state, project=None, path=None):
564
+ """Mark `draw_state` as having drawn roster-derived content this pass."""
565
+ if draw_state is None:
566
+ return
567
+ st = _state()
568
+ if project is None:
569
+ st["consumers"][draw_state] = st["gen"]
570
+ else:
571
+ project = analysis_project(project)
572
+ record = _project_state(project)
573
+ if path is not None:
574
+ record["observed"].add(str(path))
575
+ st["consumers"][draw_state] = (project.key, record["gen"])
576
+ if len(st["consumers"]) > 128:
577
+ st["consumers"] = {ds: g for ds, g in st["consumers"].items()
578
+ if not getattr(ds, "closed", False)}
579
+
580
+
581
+ def _schedule_notify():
582
+ st = _state()
583
+ with st["lock"]:
584
+ t = st.get("notify_timer")
585
+ if t is not None:
586
+ return # already pending: coalesce
587
+ t = threading.Timer(0.06, _notify_consumers)
588
+ t.daemon = True
589
+ st["notify_timer"] = t
590
+ t.start()
591
+
592
+
593
+ def _notify_consumers():
594
+ st = _state()
595
+ with st["lock"]:
596
+ st["notify_timer"] = None
597
+ targets = [(ds, g) for ds, g in list(st["consumers"].items())
598
+ if (g[1] < st.get("projects", {}).get(g[0], {}).get("gen", g[1])
599
+ if isinstance(g, tuple) else g < st["gen"])]
600
+ if not targets:
601
+ return
602
+ try:
603
+ from meltygui.core.melty import Melty
604
+ from meltygui.core.windowing.glfw_utils import request_render
605
+ from meltygui.core.cache.invalidation_tracker import Note
606
+ except Exception:
607
+ return
608
+ for ds, _g in targets:
609
+ tid = getattr(ds, "_tile_id", None)
610
+ if tid is None or getattr(ds, "closed", False):
611
+ continue
612
+ try:
613
+ Melty.cache.invalidate_up(tid, force=True, max_depth=8,
614
+ note=Note(name="symbol roster changed",
615
+ tint=(0.9, 0.7, 0.3), draw_state=ds))
616
+ except Exception:
617
+ pass
618
+ try:
619
+ request_render()
620
+ except Exception:
621
+ pass
622
+
623
+
624
+ def _gen_bump(st, path=None):
625
+ """Bump the generation (caller holds the lock) and wake the consumers."""
626
+ st["gen"] += 1
627
+ st["by_name_gen"] = -1
628
+ for record in list(st.get("projects", {}).values()):
629
+ if path is None or record["project"].resolves(path) or path in record["observed"]:
630
+ record["gen"] += 1
631
+ if st["consumers"]:
632
+ _schedule_notify()
633
+
634
+
635
+ class pass_scope:
636
+ """Freeze per-file key checks + name indexes for the duration of one
637
+ consumer pass (a tint collect, a Ctrl+B lookup): inside the scope a table
638
+ already in the cache is served without re-deriving its key (Melty.read_code
639
+ + pending gen + a path resolve per call — ~50µs, but a pass resolves
640
+ thousands of chains), and the roster-wide name indexes rebuild at most
641
+ once even as newly touched files land. Re-entrant."""
642
+ def __enter__(self):
643
+ st = _state()
644
+ st["frozen"] = st.get("frozen", 0) + 1
645
+ if st["frozen"] == 1:
646
+ st["pass_names"] = None
647
+ st["pass_project_names"] = {}
648
+ return self
649
+
650
+ def __exit__(self, *exc):
651
+ st = _state()
652
+ st["frozen"] -= 1
653
+ if st["frozen"] == 0:
654
+ st["pass_names"] = None
655
+ st["pass_project_names"] = {}
656
+ return False
657
+
658
+
659
+ def _bump():
660
+ st = _state()
661
+ with st["lock"]:
662
+ _gen_bump(st)
663
+
664
+
665
+ def _file_key(path):
666
+ """Content-free identity of a file's CURRENT text: (identity of the
667
+ FileWatch-cached disk string, pending generation). Never hashes."""
668
+ try:
669
+ from meltygui.core.melty import Melty
670
+ disk = Melty.read_code(path)
671
+ did = id(disk) if disk is not None else None
672
+ except Exception:
673
+ did = None
674
+ try:
675
+ from meltygui.editor.text_editor import _pending_gen_of
676
+ gen = _pending_gen_of(path)
677
+ except Exception:
678
+ gen = 0
679
+ if did is None:
680
+ try:
681
+ st = os.stat(path)
682
+ did = (st.st_mtime_ns, st.st_size)
683
+ except OSError:
684
+ did = 0
685
+ return (did, gen)
686
+
687
+
688
+ def _current_text(path):
689
+ try:
690
+ from meltygui.editor.pending_save import PendingSave
691
+ t = PendingSave.current_file_text(Path(path))
692
+ if t is not None:
693
+ return t
694
+ except Exception:
695
+ pass
696
+ try:
697
+ return Path(path).read_text(errors="replace")
698
+ except OSError:
699
+ return None
700
+
701
+
702
+ def file_text(path):
703
+ """The CURRENT text of `path` (pending truth), cached on the same
704
+ content-free key as its table so repeated readers share one string
705
+ (identity-stable — memo keys can use id())."""
706
+ st = _state()
707
+ p = _norm(path)
708
+ key = _file_key(p)
709
+ ent = st["texts"].get(p)
710
+ if ent is not None and ent[0] == key:
711
+ return ent[1]
712
+ text = _current_text(p) or ""
713
+ if len(st["texts"]) > 256:
714
+ st["texts"].clear()
715
+ st["texts"][p] = (key, text)
716
+ return text
717
+
718
+
719
+ def table_for(path, live_text=None, line_offset=0):
720
+ """The FileTable for `path` (resolved str or Path).
721
+
722
+ Without `live_text`: the PENDING table (PendingSave.current_file_text),
723
+ rebuilt when its content-free key moved — unless a fresher LIVE table
724
+ for the file is held (see below), which then wins.
725
+
726
+ `live_text` is an editor buffer for this file — the truth AHEAD of
727
+ pending while typing / dragging: with line_offset 0 it IS the file; a
728
+ span view (offset > 0) is spliced over the pending table (its entries
729
+ replace the pending ones inside the span's range, shifted to file
730
+ coordinates). The result is held as the file's live override until the
731
+ pending key moves (the edits landed, or another writer touched the
732
+ file), so every consumer — the other editors' tint passes, Ctrl+B from
733
+ another file — resolves against what this editor shows."""
734
+ st = _state()
735
+ p = _norm(path)
736
+ if live_text is not None:
737
+ # Several live buffers of ONE file can be open at once - the whole
738
+ # file in the editor plus a def→call span per stack-trace pane, or
739
+ # four panes of meltygui.py in one trace. Each is a PART
740
+ # (st["live_parts"][p]: part key → part) and the file's live table
741
+ # is their MERGE; the table used to be the single last-keyed
742
+ # buffer, so panes of one file replaced each other's spans every
743
+ # frame and bumped the generation each time (`_tables_differ`:
744
+ # different spans, different entries) - every editor's def-tint
745
+ # key missed every frame (2–4 ms each, 09-01).
746
+ lk = ("live", id(live_text), line_offset)
747
+ held = st["live"].get(p)
748
+ parts = st.setdefault("live_parts", {}).setdefault(p, {})
749
+ if (held is not None and lk in parts
750
+ and held[0].key == ("live", frozenset(parts))):
751
+ return held[0]
752
+ if line_offset == 0:
753
+ previous = next((part[2] for part in parts.values() if part[0] == 0), None)
754
+ whole = extract_table(p, live_text, lk, previous=previous)
755
+ part = (0, whole.nlines, whole, live_text)
756
+ # One whole whole buffer at a time (a single editor buffer).
757
+ for k in [k for k in parts if k[2] == 0]:
758
+ parts.pop(k, None)
759
+ else:
760
+ n = live_text.count("\n") + 1
761
+ lo, hi = line_offset + 1, line_offset + n
762
+ span_tbl = extract_table(p, live_text, ("span", id(live_text)))
763
+ for e in span_tbl.entries:
764
+ e.line += line_offset
765
+ e.end += line_offset
766
+ part = (lo, hi, span_tbl, live_text) # the text pins the id
767
+ # A re-keyed buffer at the same offset is the same pane after
768
+ # an edit: its previous buffer is stale.
769
+ for k in [k for k in parts if k[2] == line_offset]:
770
+ parts.pop(k, None)
771
+ parts[lk] = part
772
+ whole_part = next((pt for pt in parts.values() if pt[0] == 0), None)
773
+ if whole_part is not None:
774
+ base = whole_part[2]
775
+ pkey = held[1] if held is not None else _file_key(p)
776
+ else:
777
+ base = _pending_table(st, p)
778
+ pkey = base.key
779
+ spans = [pt for pt in parts.values() if pt[0] != 0]
780
+ if spans:
781
+ merged = [e for e in base.entries
782
+ if not any(lo <= e.line <= hi for lo, hi, _t, _x in spans)]
783
+ imports = dict(base.imports)
784
+ nlines = base.nlines
785
+ for lo, hi, span_tbl, _x in spans:
786
+ merged.extend(span_tbl.entries)
787
+ imports.update(span_tbl.imports)
788
+ nlines = max(nlines, hi)
789
+ merged.sort(key=lambda e: e.line)
790
+ tbl = FileTable(p, ("live", frozenset(parts)), merged, imports,
791
+ base.star_imports, nlines)
792
+ else:
793
+ tbl = FileTable(p, ("live", frozenset(parts)), base.entries,
794
+ base.imports, base.star_imports, base.nlines)
795
+ prev = held[0] if held is not None else st["tables"].get(p)
796
+ with st["lock"]:
797
+ st["live"][p] = (tbl, pkey)
798
+ _apply_overrides(st, p, tbl)
799
+ if prev is None or _tables_differ(prev, tbl):
800
+ _gen_bump(st, p)
801
+ return tbl
802
+ ent = _pending_table(st, p)
803
+ held = st["live"].get(p)
804
+ if held is not None:
805
+ if held[1] == ent.key:
806
+ return held[0] # live still ahead of (or equal to) pending
807
+ with st["lock"]:
808
+ st["live"].pop(p, None) # pending moved on: the live hold is stale
809
+ st.get("live_parts", {}).pop(p, None)
810
+ return ent
811
+
812
+
813
+ def _pending_table(st, p):
814
+ ent = st["tables"].get(p)
815
+ if ent is not None and st.get("frozen"):
816
+ return ent # inside a pass: no key re-derivation
817
+ key = _file_key(p)
818
+ if ent is None or ent.key != key:
819
+ text = _current_text(p)
820
+ tbl = extract_table(p, text or "", key, previous=ent)
821
+ _install(st, p, tbl)
822
+ ent = tbl
823
+ return ent
824
+
825
+
826
+ def _install(st, p, tbl):
827
+ with st["lock"]:
828
+ prev = st["tables"].get(p)
829
+ st["tables"][p] = tbl
830
+ _apply_overrides(st, p, tbl)
831
+ if prev is None or _tables_differ(prev, tbl):
832
+ _gen_bump(st, p)
833
+
834
+
835
+ def _apply_overrides(st, p, tbl):
836
+ """Drop lens overrides the fresh text now agrees with (or that aged
837
+ out); re-stamp the rest onto the new table."""
838
+ now = time.monotonic()
839
+ for (op, oq), (ot, stamp) in list(st["overrides"].items()):
840
+ if op != p:
841
+ continue
842
+ e = tbl.by_qualname.get(oq)
843
+ if (e is not None and e.tint == ot) or now - stamp > 3.0:
844
+ del st["overrides"][(op, oq)]
845
+ elif e is not None:
846
+ e.tint = ot
847
+
848
+
849
+ def sweep(force=False, project=None):
850
+ """Notice edits in OTHER files cheaply: re-key every file with queued
851
+ pending edits (O(edited files), every call ≥100ms apart) and every cached
852
+ table every 2s (catches external disk changes through FileWatch's
853
+ replaced string). Changed files re-extract and bump the generation, which
854
+ is what re-keys the editors' tint caches. Call outside a pass."""
855
+ st = _state()
856
+ now = time.monotonic()
857
+ if project is not None:
858
+ ensure_universe(blocking=False, project=project)
859
+ if project is None and not st["universe_kicked"]:
860
+ # First consumer: load every src file's table in the background so the
861
+ # by-name fallback sees the whole tree; consumers are notified as
862
+ # tables land (coalesced), so tints fill in without a full pass.
863
+ st["universe_kicked"] = True
864
+ try:
865
+ ensure_universe(blocking=False)
866
+ except Exception:
867
+ pass
868
+ dirty = st.setdefault("dirty_paths", set())
869
+ if not force and not dirty and now - st.get("last_sweep", 0.0) < 0.1:
870
+ return
871
+ st["last_sweep"] = now
872
+ full = force or now - st.get("last_full_sweep", 0.0) > 2.0
873
+ if full:
874
+ st["last_full_sweep"] = now
875
+ paths = set(dirty)
876
+ dirty.difference_update(paths)
877
+ try:
878
+ from meltygui.editor.pending_save import PendingSave
879
+ for pp in list(PendingSave._pending_gen):
880
+ paths.add(_norm(pp))
881
+ except Exception:
882
+ pass
883
+ if full:
884
+ paths.update(st["tables"])
885
+ for p in paths:
886
+ ent = st["tables"].get(p)
887
+ if ent is None:
888
+ continue
889
+ if ent.key != _file_key(p):
890
+ _pending_table(st, p) # re-extracts + bumps gen if it matters
891
+ held = st["live"].get(p)
892
+ if held is not None and held[1] != st["tables"][p].key:
893
+ with st["lock"]:
894
+ st["live"].pop(p, None)
895
+ st.get("live_parts", {}).pop(p, None)
896
+
897
+
898
+ def _tables_differ(a, b):
899
+ """Would cross-file consumers see a difference? Names, kinds, tints and
900
+ imports — NOT lines: a keystroke inside a body shifts every entry below
901
+ it, and re-keying every other editor's tint cache for that is churn (an
902
+ editor's own buffer is in its own key)."""
903
+ if len(a.entries) != len(b.entries):
904
+ return True
905
+ for x, y in zip(a.entries, b.entries):
906
+ if x.qualname != y.qualname or x.tint != y.tint or x.kind != y.kind:
907
+ return True
908
+ return a.imports != b.imports
909
+
910
+
911
+ def _norm(path):
912
+ try:
913
+ return str(Path(path).resolve())
914
+ except (OSError, ValueError):
915
+ return str(path)
916
+
917
+
918
+ def set_tint(path, qualname, tint):
919
+ """Lens write-through: make `qualname` in `path` read `tint` NOW (before
920
+ the source round-trips through PendingSave). Dropped once the
921
+ re-extracted table agrees, or after a few seconds."""
922
+ st = _state()
923
+ p = _norm(path)
924
+ t = tuple(tint[:3]) if tint is not None else None
925
+ with st["lock"]:
926
+ st["overrides"][(p, qualname)] = (t, time.monotonic())
927
+ held = st["live"].get(p)
928
+ for tbl in (st["tables"].get(p), held[0] if held is not None else None):
929
+ if tbl is not None:
930
+ e = tbl.by_qualname.get(qualname)
931
+ if e is not None:
932
+ e.tint = t
933
+ _gen_bump(st, p)
934
+
935
+
936
+ # ── Universe (every project .py) ───────────────────────────────────────────
937
+
938
+ _UNIVERSE_TTL = 30.0
939
+
940
+
941
+ def universe_paths(project=None):
942
+ """Every project .py, excluding environments and generated directories."""
943
+ project = analysis_project(project)
944
+ record = _project_state(project)
945
+ now = time.monotonic()
946
+ if record["universe"] is not None and now - record["universe_at"] < _UNIVERSE_TTL:
947
+ return record["universe"]
948
+ from meltygui.text_index import _walk_rel_files
949
+ paths = [_norm(os.path.join(project.root, relative))
950
+ for relative in _walk_rel_files(project.root) if relative.endswith(".py")]
951
+ paths = [path for path in paths if project.owns(path)]
952
+ record["universe"], record["universe_at"] = paths, now
953
+ record["paths"] = set(paths)
954
+ return paths
955
+
956
+
957
+ def ensure_universe(blocking=False, project=None):
958
+ """Warm this project's name index, off the render thread by default."""
959
+ project = analysis_project(project)
960
+ record = _project_state(project)
961
+ state = _state()
962
+ if not blocking and record["loading"]:
963
+ return False
964
+
965
+ def load():
966
+ try:
967
+ _watch_project(project)
968
+ for path in universe_paths(project):
969
+ if path not in state["tables"]:
970
+ table_for(path)
971
+ if not blocking:
972
+ time.sleep(0.0005)
973
+ record["complete"] = True
974
+ finally:
975
+ record["loading"] = False
976
+ if blocking:
977
+ load()
978
+ return True
979
+ # A cached universe is enough; the walk itself happens on the worker.
980
+ if (record["universe"] is not None
981
+ and time.monotonic() - record["universe_at"] < _UNIVERSE_TTL
982
+ and record["complete"]):
983
+ return True
984
+ record["loading"] = True
985
+ threading.Thread(target=load, daemon=True, name="project-symbol-roster").start()
986
+ return False
987
+
988
+
989
+ def _name_indexes(project=None):
990
+ """Project-local fallback names; unrelated repos cannot introduce ambiguity."""
991
+ project = analysis_project(project)
992
+ state = _state()
993
+ record = _project_state(project)
994
+ pass_names = state.setdefault("pass_project_names", {})
995
+ if state.get("frozen") and project.key in pass_names:
996
+ return pass_names[project.key]
997
+ if record["names"] is None or record["names_gen"] != record["gen"]:
998
+ by_name, by_leaf = {}, {}
999
+ effective = dict(state["tables"])
1000
+ effective.update((path, held[0]) for path, held in list(state["live"].items()))
1001
+ for path, table in effective.items():
1002
+ if not project.owns(path):
1003
+ continue
1004
+ for entry in table.entries:
1005
+ index = by_leaf if "." in entry.qualname else by_name
1006
+ index.setdefault(entry.name, []).append(entry)
1007
+ record["names"] = (by_name, by_leaf)
1008
+ record["names_gen"] = record["gen"]
1009
+ if state.get("frozen"):
1010
+ pass_names[project.key] = record["names"]
1011
+ return record["names"]
1012
+
1013
+
1014
+ # ── Resolution ───────────────────────────────────────────────────────────────
1015
+
1016
+ def _walk_qualname(tbl, parts):
1017
+ """Longest prefix of `parts` that is a qualname in `tbl` → (entry, n)."""
1018
+ best, qn = None, None
1019
+ for k, p in enumerate(parts):
1020
+ qn = p if qn is None else f"{qn}.{p}"
1021
+ e = tbl.by_qualname.get(qn)
1022
+ if e is None:
1023
+ break
1024
+ best = (e, k + 1)
1025
+ return best
1026
+
1027
+
1028
+ def resolve(path, chain, table=None, allow_fallback=True, scope=None,
1029
+ world=None, project=None):
1030
+ """The Entry a dotted `chain` (str or list of parts) denotes in the
1031
+ context of file `path`, or None. Returns the entry for the WHOLE chain
1032
+ only (use `resolve_prefixes` for per-prefix). Order: enclosing scopes
1033
+ (`scope` = the innermost Entry the reference sits in; closures see their
1034
+ enclosing functions, class bodies only themselves) → own-file module
1035
+ level → import table (module path mapped textually, greedy over
1036
+ submodules) → unique same-named module-level definition anywhere in the
1037
+ roster. `self.x` / `cls.x` inside a class resolve to that class's member."""
1038
+ n = len(chain.split(".")) if isinstance(chain, str) else len(chain)
1039
+ r = resolve_prefixes(path, chain, table, allow_fallback, scope, world=world, project=project)
1040
+ return r[-1][0] if r and r[-1][1] == n else None
1041
+
1042
+
1043
+ def resolve_prefixes(path, chain, table=None, allow_fallback=True, scope=None,
1044
+ world=None, project=None):
1045
+ """[(entry, n_parts)] for every prefix of `chain` that resolves, shortest
1046
+ first (`Toggles`, `Toggles.TextEditor`, `Toggles.TextEditor.x`). The
1047
+ prefixes beyond the first resolved one walk qualnames inside the first
1048
+ hit's table. `scope`: the innermost Entry the reference sits in (None =
1049
+ module level) — see `resolve`. `world`: the World whose tables the
1050
+ OTHER files resolve through (imports, star imports); None = the
1051
+ studio's (pending + live holds). The last-resort unique-name fallback
1052
+ uses the owning project’s pending/live index."""
1053
+ project = analysis_project(project, path)
1054
+ parts = chain.split(".") if isinstance(chain, str) else list(chain)
1055
+ if not parts:
1056
+ return []
1057
+ tables = world.table if world is not None else table_for
1058
+ if table is not None:
1059
+ tbl = table # caller's table: no path resolve / call
1060
+ else:
1061
+ tbl = tables(_norm(path)) if path is not None else None
1062
+ first = parts[0]
1063
+ # 0. self / cls inside a class: the enclosing class's members.
1064
+ if first in ("self", "cls") and tbl is not None and scope is not None and len(parts) > 1:
1065
+ cq = tbl.enclosing_class(scope)
1066
+ if cq is not None:
1067
+ w = _walk_qualname(tbl, cq.split(".") + parts[1:])
1068
+ if w is not None and w[1] > cq.count(".") + 1:
1069
+ skip = cq.count(".") + 1 # the class's prefix parts
1070
+ return [(e, n - skip + 1) for e, n in _expand(w, tbl, cq.split(".") + parts[1:])
1071
+ if n > skip]
1072
+ return []
1073
+ if first in _KEYWORDS:
1074
+ return []
1075
+ # 1. enclosing scopes, innermost first (closure / class-body visibility)
1076
+ if tbl is not None and scope is not None:
1077
+ for sq in tbl.visible_scopes(scope):
1078
+ qn = f"{sq}.{first}"
1079
+ if qn in tbl.by_qualname:
1080
+ sparts = sq.split(".")
1081
+ w = _walk_qualname(tbl, sparts + parts)
1082
+ skip = len(sparts)
1083
+ return [(e, n - skip) for e, n in _expand(w, tbl, sparts + parts)
1084
+ if n > skip]
1085
+ # 2. own file's module level
1086
+ if tbl is not None and first in tbl.by_qualname:
1087
+ return _expand(_walk_qualname(tbl, parts), tbl, parts)
1088
+ # 3. imports
1089
+ if tbl is not None and first in tbl.imports:
1090
+ mod, attr = tbl.imports[first]
1091
+ mod = _absolutize(mod, tbl.path, project)
1092
+ if mod is None:
1093
+ return []
1094
+ if attr is None:
1095
+ # `import a.b.c [as z]`: greedy longest module prefix that is a file.
1096
+ best = None
1097
+ for k in range(len(parts), 0, -1):
1098
+ dotted = mod if k == 1 else mod + "." + ".".join(parts[1:k])
1099
+ mp = module_to_path(dotted, project)
1100
+ if mp is not None:
1101
+ best = (mp, k)
1102
+ break
1103
+ if best is not None:
1104
+ mp, k = best
1105
+ t2 = tables(mp)
1106
+ if k == len(parts):
1107
+ return [] # the chain IS a path, not a symbol
1108
+ w = _walk_qualname(t2, parts[k:])
1109
+ if w is not None:
1110
+ return [(e, n + k) for e, n in _expand(w, t2, parts[k:])]
1111
+ return []
1112
+ mp = module_to_path(mod, project)
1113
+ if mp is not None:
1114
+ t2 = tables(mp)
1115
+ if attr in t2.by_qualname:
1116
+ w = _walk_qualname(t2, [attr] + parts[1:])
1117
+ return [(e, n) for e, n in _expand(w, t2, [attr] + parts[1:])]
1118
+ # `from pkg import submodule`
1119
+ mp2 = module_to_path(f"{mod}.{attr}", project)
1120
+ if mp2 is not None and len(parts) > 1:
1121
+ t2 = tables(mp2)
1122
+ w = _walk_qualname(t2, parts[1:])
1123
+ if w is not None:
1124
+ return [(e, n + 1) for e, n in _expand(w, t2, parts[1:])]
1125
+ return []
1126
+ if tbl is not None and tbl.star_imports:
1127
+ for mod in tbl.star_imports:
1128
+ mod = _absolutize(mod, tbl.path, project)
1129
+ mp = module_to_path(mod, project)
1130
+ if mp is None:
1131
+ continue
1132
+ t2 = tables(mp)
1133
+ if first in t2.by_qualname:
1134
+ return _expand(_walk_qualname(t2, parts), t2, parts)
1135
+ # 4. unique definition anywhere
1136
+ if allow_fallback:
1137
+ by_name, _leaf = _name_indexes(project)
1138
+ cands = by_name.get(first)
1139
+ if cands and len(cands) == 1:
1140
+ st = _state()
1141
+ held = st["live"].get(cands[0].path)
1142
+ t2 = held[0] if held is not None else st["tables"].get(cands[0].path)
1143
+ if t2 is not None:
1144
+ return _expand(_walk_qualname(t2, parts), t2, parts)
1145
+ return []
1146
+
1147
+
1148
+ def _expand(walked, tbl, parts):
1149
+ """Turn the longest-prefix walk into the per-prefix list."""
1150
+ if walked is None:
1151
+ return []
1152
+ out, qn = [], None
1153
+ for k in range(walked[1]):
1154
+ qn = parts[k] if qn is None else f"{qn}.{parts[k]}"
1155
+ e = tbl.by_qualname.get(qn)
1156
+ if e is not None:
1157
+ out.append((e, k + 1))
1158
+ return out
1159
+
1160
+
1161
+ def tint_of(path, chain, table=None):
1162
+ e = resolve(path, chain, table)
1163
+ return e.tint if e is not None else None
1164
+
1165
+
1166
+ # ── Occurrence scan + reverse lookup ─────────────────────────────────────────
1167
+
1168
+ def iter_chains(text, start=0, end=None):
1169
+ """Yield (start, end, chain) for every identifier chain in CODE — strings
1170
+ and comments skipped — in one regex pass (~8ms per 13k lines; pass a
1171
+ [start, end) slice to scan a window — start it on a string-clean line)."""
1172
+ if end is None:
1173
+ end = len(text)
1174
+ for m in _CODE_RE.finditer(text, start, end):
1175
+ if m.group(2) is not None:
1176
+ yield m.start(2), m.end(2), m.group(2)
1177
+
1178
+
1179
+ class Usage:
1180
+ __slots__ = ("path", "line", "col", "scope", "probable")
1181
+
1182
+ def __init__(self, path, line, col, scope, probable=False):
1183
+ self.path = path
1184
+ self.line = line
1185
+ self.col = col
1186
+ self.scope = scope
1187
+ self.probable = probable
1188
+
1189
+ def __repr__(self):
1190
+ return f"Usage({os.path.basename(self.path)}:{self.line}:{self.col} in {self.scope}{' ?' if self.probable else ''})"
1191
+
1192
+
1193
+ def usages_of(entry, live=None, max_files=400, include_probable=True, project=None):
1194
+ """Every code occurrence in the owning project that resolves to `entry`
1195
+ (excluding its own definition line). `live` = {path: (text, line_offset)}
1196
+ of editor buffers to read instead of pending text. Runs the trigram
1197
+ candidate query (builds the text index on first call — call off the
1198
+ render thread the first time)."""
1199
+ with pass_scope():
1200
+ return _usages_of(entry, live, max_files, include_probable, project)
1201
+
1202
+
1203
+ def _usages_of(entry, live, max_files, include_probable, project=None):
1204
+ project = analysis_project(project, entry.path)
1205
+ name = entry.name
1206
+ try:
1207
+ from meltygui.text_index import candidate_paths
1208
+ cands = candidate_paths(name, root=project.root)
1209
+ except Exception:
1210
+ cands = list(universe_paths(project))
1211
+ cands = [_norm(c) for c in cands if project.owns(str(c))]
1212
+ # New/live files may not yet be in the disk index.
1213
+ cands.extend(path for path in list(_state()["live"])
1214
+ if project.owns(path) and path not in cands)
1215
+ if entry.path not in cands:
1216
+ cands.insert(0, entry.path)
1217
+ if live:
1218
+ for lp in live:
1219
+ lp = _norm(lp)
1220
+ if project.owns(lp) and lp not in cands:
1221
+ cands.insert(0, lp)
1222
+ _by_name, by_leaf = _name_indexes(project)
1223
+ is_member = "." in entry.qualname
1224
+ leaf_unique = is_member and len(by_leaf.get(name, ())) == 1
1225
+ word = re.compile(r"(?<![\w.])" + re.escape(name) + r"(?!\w)")
1226
+ out = []
1227
+ for ap in cands[:max_files]:
1228
+ lt = (live or {}).get(ap)
1229
+ text, off = None, 0
1230
+ if lt is not None:
1231
+ ltext, loff = lt
1232
+ tbl = table_for(ap, live_text=ltext, line_offset=loff)
1233
+ if not loff:
1234
+ text = ltext # whole-file buffer: scan it directly
1235
+ # (a span buffer is scanned via the pending whole file text)
1236
+ else:
1237
+ tbl = table_for(ap)
1238
+ if text is None:
1239
+ parts = _state().get("live_parts", {}).get(ap, {})
1240
+ whole = next((part for part in list(parts.values()) if part[0] == 0), None)
1241
+ text = whole[3] if whole is not None else _current_text(ap)
1242
+ if not text or name not in text:
1243
+ continue
1244
+ if not word.search(text) and ("." + name) not in text:
1245
+ continue
1246
+ starts = None
1247
+ memo = {}
1248
+ for s, e, chain in iter_chains(text):
1249
+ if name not in chain:
1250
+ continue
1251
+ parts = chain.split(".")
1252
+ try:
1253
+ k = parts.index(name)
1254
+ except ValueError:
1255
+ continue
1256
+ prefix = ".".join(parts[:k + 1])
1257
+ if starts is None:
1258
+ starts = _line_starts(text)
1259
+ import bisect
1260
+ ln = bisect.bisect_right(starts, s) - 1
1261
+ file_line = ln + 1 + off
1262
+ sc = tbl.scope_at(file_line)
1263
+ mkey = (prefix, sc.qualname if sc is not None else None)
1264
+ got = memo.get(mkey)
1265
+ if got is None:
1266
+ res = resolve_prefixes(ap, parts[:k + 1], tbl, scope=sc, project=project)
1267
+ hit = None
1268
+ for ent, n in res:
1269
+ if n == k + 1:
1270
+ hit = ent
1271
+ probable = False
1272
+ if hit is None and k > 0 and leaf_unique:
1273
+ hit, probable = entry, True
1274
+ got = memo[mkey] = (hit, probable)
1275
+ hit, probable = got
1276
+ if hit is None or hit.ident != entry.ident:
1277
+ continue
1278
+ if probable and not include_probable:
1279
+ continue
1280
+ col = s - starts[ln] + (len(prefix) - len(name))
1281
+ if file_line == entry.line and ap == entry.path:
1282
+ continue # skip definition itself
1283
+ out.append(Usage(ap, file_line, col, sc.qualname if sc else "<module>",
1284
+ probable))
1285
+ return out
1286
+
1287
+
1288
+ def _line_starts(text):
1289
+ starts = [0]
1290
+ pos = text.find("\n")
1291
+ while pos != -1:
1292
+ starts.append(pos + 1)
1293
+ pos = text.find("\n", pos + 1)
1294
+ return starts
1295
+
1296
+
1297
+ def chain_at(text, pos):
1298
+ """(start, end, chain, part_index) of the identifier chain under buffer
1299
+ index `pos`, or None — the caret's symbol for Ctrl+B."""
1300
+ if pos < 0 or pos > len(text):
1301
+ return None
1302
+ ls = text.rfind("\n", 0, pos) + 1
1303
+ le = text.find("\n", pos)
1304
+ if le == -1:
1305
+ le = len(text)
1306
+ line = text[ls:le]
1307
+ rel = pos - ls
1308
+ for m in re.finditer(r"[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*", line):
1309
+ if m.start() <= rel <= m.end():
1310
+ chain = m.group(0)
1311
+ # which part holds the caret
1312
+ off = m.start()
1313
+ for i, part in enumerate(chain.split(".")):
1314
+ if off <= rel <= off + len(part):
1315
+ return ls + m.start(), ls + m.end(), chain, i
1316
+ off += len(part) + 1
1317
+ return ls + m.start(), ls + m.end(), chain, len(chain.split(".")) - 1
1318
+ return None
1319
+
1320
+
1321
+ # ── Function-local bindings (non scopes) ───────────────────────────────────────
1322
+ # Locals are first-classic table entries, but scoped: a binding appears in the
1323
+ # innermost DEF whose block holds it and is visible from that def and its
1324
+ # nested functions (closures), never from an enclosing class body or the
1325
+ # module. One per line - params (paren-balanced signature), assignment /
1326
+ # annotated-assignment targets incl. tuple targets, for/comprehension
1327
+ # targets, `with ... as`, `except ... as`, walrus - honouring global/nonlocal.
1328
+
1329
+ class LocalBinding:
1330
+ __slots__ = ("scope", "name", "line", "col", "kind")
1331
+
1332
+ def __init__(self, scope, name, line, col, kind):
1333
+ self.scope = scope # qualname of the def that owns the local
1334
+ self.name = name
1335
+ self.line = line # 1-based file line of the binding token
1336
+ self.col = col # 0-based column of the name on that line
1337
+ self.kind = kind # "param" / "assign" / "for" / "with" / "except" / "walrus"
1338
+
1339
+ def __repr__(self):
1340
+ return f"LocalBinding({self.scope}:{self.name} @{self.line}:{self.col} {self.kind})"
1341
+
1342
+
1343
+ _L_ASSIGN_RE = re.compile(
1344
+ r"^\s*([A-Za-z_]\w*(?:\s*,\s*[A-Za-z_]\w*)*)\s*(?::[^=\n]+)?=(?!=)")
1345
+ _L_FOR_RE = re.compile(r"\bfor\s+([A-Za-z_][\w\s,()]*?)\s+in\b")
1346
+ _L_AS_RE = re.compile(r"\bas\s+([A-Za-z_]\w*)")
1347
+ _L_WALRUS_RE = re.compile(r"\b([A-Za-z_]\w*)\s*:=")
1348
+ _L_GLOBAL_RE = re.compile(r"^\s*(?:global|nonlocal)\s+(.+)$")
1349
+ _L_NAME_RE = re.compile(r"[A-Za-z_]\w*")
1350
+ _L_CODE_SPLIT_RE = re.compile(r'#.*$')
1351
+
1352
+
1353
+ def _string_end(ln, j):
1354
+ """(end, open_quote) for the string literal opening at ln[j] (a quote
1355
+ char): `end` is the index just past it, honouring backslash escapes;
1356
+ `open_quote` is None when it closes on this line, else the quote the
1357
+ literal continues with on the next line (a triple, or a plain quote
1358
+ left unterminated mid-edit)."""
1359
+ q = ln[j]
1360
+ if ln.startswith(q * 3, j):
1361
+ e = ln.find(q * 3, j + 3)
1362
+ return (len(ln), q * 3) if e == -1 else (e + 3, None)
1363
+ k = j + 1
1364
+ n = len(ln)
1365
+ while k < n:
1366
+ c = ln[k]
1367
+ if c == "\\":
1368
+ k += 2
1369
+ continue
1370
+ if c == q:
1371
+ return k + 1, None
1372
+ k += 1
1373
+ return n, q
1374
+
1375
+
1376
+ def _code_part(line):
1377
+ """The line without a trailing comment — string-aware, so a '#' inside
1378
+ a literal (`prompt="# [tint=...]"`) doesn't truncate the code."""
1379
+ j, n = 0, len(line)
1380
+ while j < n:
1381
+ c = line[j]
1382
+ if c == "#":
1383
+ return line[:j]
1384
+ if c in "\"'":
1385
+ j, _open = _string_end(line, j)
1386
+ continue
1387
+ j += 1
1388
+ return line
1389
+
1390
+
1391
+ def _signature_params(lines, i):
1392
+ """([(name, line_idx, col)], last_line_idx) — the parameters of the def
1393
+ starting at 0-based line `i`, walking a paren-balanced, possibly
1394
+ multi-line signature, and the line index its closing paren sits on.
1395
+ Splits at depth-0 commas; each piece's leading identifier is the param
1396
+ (`*`/`**` stripped; `self`, `cls`, bare `*` / `/` skipped)."""
1397
+ out = []
1398
+ first = lines[i]
1399
+ p = first.find("(")
1400
+ if p == -1:
1401
+ return out, i
1402
+ depth = 0
1403
+ piece_start = None # (line_idx, col) of the first non-space char of the piece
1404
+ li, j, ln = i, p + 1, first
1405
+ guard = 0
1406
+ open_q = None # quote of a string literal spanning lines
1407
+ while li < len(lines) and guard < 400:
1408
+ guard += 1
1409
+ if open_q is not None:
1410
+ e = ln.find(open_q)
1411
+ if e == -1:
1412
+ li += 1
1413
+ if li < len(lines):
1414
+ ln = lines[li]
1415
+ continue
1416
+ j = e + len(open_q)
1417
+ open_q = None
1418
+ while j < len(ln):
1419
+ ch = ln[j]
1420
+ if ch == "#":
1421
+ break # rest of line is a comment
1422
+ if ch in "\"'":
1423
+ # A default value's string literal: skip it, so a '#',
1424
+ # bracket or comma inside it can't end the walk or change depth.
1425
+ if piece_start is None and depth == 0:
1426
+ piece_start = (li, j)
1427
+ j, open_q = _string_end(ln, j)
1428
+ continue
1429
+ if ch in "([{":
1430
+ depth += 1
1431
+ elif ch in ")]}":
1432
+ if depth == 0:
1433
+ if piece_start is not None:
1434
+ out.append(piece_start)
1435
+ return _params_from_pieces(lines, out), li
1436
+ depth -= 1
1437
+ elif ch == "," and depth == 0:
1438
+ if piece_start is not None:
1439
+ out.append(piece_start)
1440
+ piece_start = None
1441
+ elif piece_start is None and not ch.isspace() and depth == 0:
1442
+ piece_start = (li, j)
1443
+ j += 1
1444
+ li += 1
1445
+ if li < len(lines):
1446
+ ln = lines[li]
1447
+ j = 0
1448
+ if piece_start is not None:
1449
+ out.append(piece_start)
1450
+ return _params_from_pieces(lines, out), min(li, len(lines) - 1)
1451
+
1452
+
1453
+ def _params_from_pieces(lines, starts):
1454
+ out = []
1455
+ for (li, col) in starts:
1456
+ seg = lines[li][col:]
1457
+ m = re.match(r"\*{0,2}\s*([A-Za-z_]\w*)", seg)
1458
+ if m is None:
1459
+ continue
1460
+ name = m.group(1)
1461
+ if name in ("self", "cls"):
1462
+ continue
1463
+ out.append((name, li, col + m.start(1)))
1464
+ return out
1465
+
1466
+
1467
+ _L_NON_BINDING_STARTS = ("for ", "if ", "elif ", "while ", "return ", "yield ",
1468
+ "del ", "assert ", "raise ", "import ", "from ", "def ",
1469
+ "class ", "@", "pass", "break", "continue", "try",
1470
+ "else", "finally", "lambda", "print(")
1471
+
1472
+
1473
+ def local_bindings(text, table, line_offset=0, lines=None, scopes=None):
1474
+ """{(scope_qualname, name): [LocalBinding, …] (line order)} for the
1475
+ function scopes of `table` — all of them, or only the Entry objects in
1476
+ `scopes` (plus the defs nested inside them; the editor passes the scopes
1477
+ intersecting its viewport). `text`/`lines` are the BUFFER; bindings are
1478
+ reported in FILE lines (buffer line + 1 + line_offset)."""
1479
+ if lines is None:
1480
+ lines = text.split("\n")
1481
+ n = len(lines)
1482
+ if scopes is None:
1483
+ ranges = [(e.line, e.end) for e in table.entries if e.kind == "def"]
1484
+ else:
1485
+ ranges = [(e.line, e.end) for e in scopes if e.kind == "def"]
1486
+ merged = []
1487
+ for lo, hi in sorted(ranges):
1488
+ if merged and lo <= merged[-1][1] + 1:
1489
+ merged[-1][1] = max(merged[-1][1], hi)
1490
+ else:
1491
+ merged.append([lo, hi])
1492
+ out = {}
1493
+ declared = set() # (scope, name) declared global/nonlocal
1494
+
1495
+ def add(scope, name, li, col, kind):
1496
+ k = (scope, name)
1497
+ if k in declared:
1498
+ return
1499
+ out.setdefault(k, []).append(
1500
+ LocalBinding(scope, name, li + 1 + line_offset, col, kind))
1501
+
1502
+ for lo, hi in merged:
1503
+ b0 = max(0, lo - 1 - line_offset)
1504
+ b1 = min(n - 1, hi - 1 - line_offset)
1505
+ skip_to = -1 # last line of a multi-line signature
1506
+ for i in range(b0, b1 + 1):
1507
+ if i <= skip_to:
1508
+ continue
1509
+ raw = lines[i]
1510
+ s = raw.strip()
1511
+ if not s or s.startswith("#"):
1512
+ continue
1513
+ e = table.scope_at(i + 1 + line_offset)
1514
+ if e is None or e.kind != "def":
1515
+ continue
1516
+ scope = e.qualname
1517
+ if e.line == i + 1 + line_offset:
1518
+ params, skip_to = _signature_params(lines, i)
1519
+ for name, li, col in params:
1520
+ add(scope, name, li, col, "param")
1521
+ continue
1522
+ code = _code_part(raw)
1523
+ mg = _L_GLOBAL_RE.match(code)
1524
+ if mg is not None:
1525
+ for nm in _L_NAME_RE.findall(mg.group(1)):
1526
+ declared.add((scope, nm))
1527
+ out.pop((scope, nm), None)
1528
+ continue
1529
+ st = code.lstrip()
1530
+ if "for " in code:
1531
+ for m in _L_FOR_RE.finditer(code):
1532
+ for nm in _L_NAME_RE.finditer(m.group(1)):
1533
+ add(scope, nm.group(0), i, m.start(1) + nm.start(), "for")
1534
+ if st.startswith(("with ", "async with ")):
1535
+ for m in _L_AS_RE.finditer(code):
1536
+ add(scope, m.group(1), i, m.start(1), "with")
1537
+ elif st.startswith("except"):
1538
+ m = _L_AS_RE.search(code)
1539
+ if m is not None:
1540
+ add(scope, m.group(1), i, m.start(1), "except")
1541
+ elif not st.startswith(_L_NON_BINDING_STARTS):
1542
+ m = _L_ASSIGN_RE.match(code)
1543
+ if m is not None:
1544
+ for nm in _L_NAME_RE.finditer(m.group(1)):
1545
+ add(scope, nm.group(0), i, m.start(1) + nm.start(), "assign")
1546
+ if ":=" in code:
1547
+ for m in _L_WALRUS_RE.finditer(code):
1548
+ add(scope, m.group(1), i, m.start(1), "walrus")
1549
+ return out
1550
+
1551
+
1552
+ def local_key(table, scope, name, bindings):
1553
+ """The (scope_qualname, name) binding key a bare `name` refers to from
1554
+ `scope` (an Entry or None): the innermost visible FUNCTION scope that
1555
+ binds it, else None. Class bodies and the module never own locals."""
1556
+ if scope is None or not bindings:
1557
+ return None
1558
+ for sq in table.visible_scopes(scope):
1559
+ k = (sq, name)
1560
+ if k in bindings:
1561
+ return k
1562
+ return None
1563
+
1564
+
1565
+ def _project_file_changed(path):
1566
+ if not str(path).endswith((".py", ".pyi", ".pth", "pyvenv.cfg")):
1567
+ return
1568
+ path = _norm(path)
1569
+ state = _state()
1570
+ with state["lock"]:
1571
+ state.setdefault("dirty_paths", set()).add(path)
1572
+ for record in list(state.get("projects", {}).values()):
1573
+ if record["project"].resolves(path) and (path not in record["paths"] or not os.path.exists(path)):
1574
+ record["universe"] = None
1575
+ record["complete"] = False
1576
+ _gen_bump(state, path)
1577
+ # A formerly missing import may now exist.
1578
+ for key, value in list(_mod_path_cache.items()):
1579
+ if value[0] is None or value[0] == path:
1580
+ _mod_path_cache.pop(key, None)
1581
+
1582
+
1583
+ def _watch_project(project):
1584
+ from meltygui.core.melty import FileWatch
1585
+ FileWatch.global_listeners[:] = [listener for listener in FileWatch.global_listeners
1586
+ if getattr(listener, "__name__", "") != "_project_file_changed"]
1587
+ FileWatch.global_listeners.append(_project_file_changed)
1588
+ FileWatch.watch_recursive(project.root)