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,1430 @@
1
+ """core_syntax — the libcst-free cst_dict: a syntax dict whose round-trip is
2
+ text surgery on the ORIGINAL source, not code generation from the dict.
3
+
4
+ text ──parse_to_dict──► GeneralParse (same types as libcst_conversion)
5
+ + gp["__origin__"] = Origin(text, items, seqs)
6
+ user mutates the gp in place (draw_collection, focus, live_apply, …)
7
+ general_parse_to_str(gp) = apply_edits(origin.text, diff(gp, origin))
8
+
9
+ The dict alone can't rebuild the code (it drops everything it doesn't surface),
10
+ so it never tries to. The reverse path diffs the LIVE dict against the flat
11
+ `Origin` tables — one `Item` per surfaced site (its value span, the statement
12
+ extent that moves/deletes with it, its indent, the value it was parsed as) and
13
+ one `Seq` per ordered container (a body, a parameter list, a call's arguments,
14
+ a literal's elements) — and emits `TextEdit`s into the original string:
15
+
16
+ * a changed leaf → replace its value span (`render`, styled on the old text)
17
+ * an added key → a synthesized statement / `k=v` / element at its dict position
18
+ * a removed key → delete its extent (leading comments included, like libcst)
19
+ * a reordered Seq → ONE region edit that re-concatenates the members' source
20
+ slices verbatim in the new order (gaps stay in their slots)
21
+
22
+ Everything not touched is copied byte-for-byte, so "unchanged code comes back
23
+ exactly" holds by construction.
24
+
25
+ FRONT ENDS (the forward half lives in `melty_scan`, stdlib-only on purpose):
26
+ "scan" the tokenize-based cst-lite parser, in-process — small files
27
+ (Toggles.TextEditor.melty_scanner)
28
+ "worker" the same scanner + extractor run in a 3.12 SUBINTERPRETER with its
29
+ own GIL, so a big file's parse never stalls the render thread; the
30
+ result crosses back as pickled neutral data that `materialize_parse`
31
+ turns into the studio's parse classes, resolving names against the
32
+ src scope and binding positional args to runtime signatures here
33
+ (files ≥ Toggles.TextEditor.melty_async_min_chars)
34
+ "ast" Python's parser — the oracle the other two are tested against
35
+
36
+ `reparse_reusing` is the live path: a fresh parse whose result reuses every
37
+ unchanged value object of the previous parse by identity, so draw_states stay
38
+ stable — the previous tree is never mutated (the studio's held tree is
39
+ bubbling-wrapped: a dict mutation there reads as a user edit).
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import ast
45
+ import atexit
46
+ import enum
47
+ import os
48
+ import pickle
49
+ import queue
50
+ import threading
51
+ from dataclasses import dataclass
52
+ from pathlib import Path
53
+ from typing import Any
54
+
55
+ from meltygui.code.libcst_conversion import GeneralParse
56
+ from meltygui.code.libcst_conversion import ClassParse
57
+ from meltygui.code.libcst_conversion import EnumParse
58
+ from meltygui.code.libcst_conversion import FunctionParse
59
+ from meltygui.code.libcst_conversion import CallParse
60
+ from meltygui.code.libcst_conversion import DecorationParse
61
+ from meltygui.code.libcst_conversion import Comment
62
+ from meltygui.code.libcst_conversion import CodeLine
63
+ from meltygui.code.libcst_conversion import Conditional
64
+ from meltygui.code.libcst_conversion import Loop
65
+ from meltygui.code.libcst_conversion import Try
66
+ from meltygui.code.libcst_conversion import Except
67
+ from meltygui.code.libcst_conversion import NO_DEFAULT
68
+ from meltygui.code.libcst_conversion import NoDefault
69
+ from meltygui.code.libcst_conversion import Span
70
+ from meltygui.code.libcst_conversion import _SKIP_PARAMS
71
+ from meltygui.code.libcst_conversion import _UNREADABLE
72
+ from meltygui.code.libcst_conversion import _float_to_str
73
+ from meltygui.code.libcst_conversion import _floats_match
74
+ from meltygui.code.libcst_conversion import _is_dunder
75
+ from meltygui.code.libcst_conversion import _override_changed
76
+ from meltygui.code.libcst_conversion import _format_override_comment
77
+ from meltygui.code.libcst_conversion import _reformat_override_comment
78
+ from meltygui.code.libcst_conversion import _resolve_as_enum
79
+ from meltygui.code.libcst_conversion import _resolve_callable_by_name
80
+ from meltygui.code.libcst_conversion import _resolve_callable_by_parts
81
+ from meltygui.code.libcst_conversion import _cached_signature
82
+ from meltygui.code.melty_scan import Item
83
+ from meltygui.code.melty_scan import Seq
84
+ from meltygui.code.melty_scan import Origin
85
+ from meltygui.code.melty_scan import Base
86
+ from meltygui.code.melty_scan import ZERO_BASE
87
+ from meltygui.code.melty_scan import Types
88
+ from meltygui.code.melty_scan import UNRESOLVED
89
+ from meltygui.code.melty_scan import _Src
90
+ from meltygui.code.melty_scan import extract as _extract
91
+ from meltygui.code.melty_scan import scan_comments as _scan_comments
92
+ from meltygui.code.melty_scan import NGeneralParse
93
+ from meltygui.code.melty_scan import NClassParse
94
+ from meltygui.code.melty_scan import NEnumParse
95
+ from meltygui.code.melty_scan import NFunctionParse
96
+ from meltygui.code.melty_scan import NCallParse
97
+ from meltygui.code.melty_scan import NDecorationParse
98
+ from meltygui.code.melty_scan import NConditional
99
+ from meltygui.code.melty_scan import NLoop
100
+ from meltygui.code.melty_scan import NTry
101
+ from meltygui.code.melty_scan import NExcept
102
+ from meltygui.code.melty_scan import NComment
103
+ from meltygui.code.melty_scan import NCodeLine
104
+ from meltygui.code.melty_scan import NameRef
105
+ from meltygui.code.melty_scan import NNoDefault
106
+ from meltygui.code.melty_scan import NSpan
107
+
108
+ ORIGIN_KEY = "__origin__"
109
+
110
+
111
+ class CoreSyntaxError(ValueError):
112
+ """The edited text no longer parses. `.text` is the produced text so a
113
+ caller can still show it (the libcst path wraps this as a ParseError)."""
114
+
115
+ def __init__(self, message, text, lineno=0, offset=0):
116
+ super().__init__(message)
117
+ self.text = text
118
+ self.lineno = lineno
119
+ self.offset = offset
120
+
121
+
122
+ @dataclass
123
+ class TextEdit:
124
+ start: int
125
+ end: int
126
+ replacement: str
127
+
128
+
129
+ @dataclass
130
+ class RegionEdit:
131
+ """A rebuilt region: `pieces` are literal strings or (start, end) source
132
+ slices copied verbatim (with any nested edits inside them applied)."""
133
+ start: int
134
+ end: int
135
+ pieces: list
136
+
137
+
138
+ # ╔══════════════════════════════════════════════════════════════════════════════╗
139
+ # ║ Forward: text → GeneralParse + Origin ║
140
+ # ╚══════════════════════════════════════════════════════════════════════════════╝
141
+
142
+ def _resolve_parts(parts):
143
+ """A dotted name → the live callable / enum member it names in the src scope
144
+ (builtins for a bare name), else UNRESOLVED. Same resolvers as libcst."""
145
+ if len(parts) == 1:
146
+ resolved = _resolve_callable_by_name(parts[0])
147
+ else:
148
+ resolved = _resolve_as_enum(parts)
149
+ if resolved is _UNREADABLE:
150
+ resolved = _resolve_callable_by_parts(parts)
151
+ return UNRESOLVED if resolved is _UNREADABLE else resolved
152
+
153
+
154
+ def _positional_names_for(parts):
155
+ """Ordered positional parameter names of the callee `parts` names (leading
156
+ self/cls dropped, stops at *args), or None when it can't be resolved or
157
+ inspected — mirrors libcst_conversion._call_positional_param_names."""
158
+ obj = _resolve_callable_by_name(parts[0]) if len(parts) == 1 else _resolve_callable_by_parts(parts)
159
+ if obj is _UNREADABLE or not callable(obj):
160
+ return None
161
+ try:
162
+ sig = _cached_signature(obj)
163
+ except TypeError:
164
+ sig = None
165
+ if sig is None:
166
+ return None
167
+ names = []
168
+ for p in sig.parameters.values():
169
+ if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD):
170
+ if not names and p.name in _SKIP_PARAMS:
171
+ continue
172
+ names.append(p.name)
173
+ elif p.kind == p.VAR_POSITIONAL:
174
+ break
175
+ return names
176
+
177
+
178
+ class RelSpan(Span):
179
+ """A `Span` whose LINE numbers are relative to a `Base` cell (the enclosing
180
+ top-level statement), so an edit above the statement moves every span in
181
+ it by touching the cell — the incremental reparse never walks the tree to
182
+ renumber. Reads exactly like a Span (`start_line`, `end_line`, …)."""
183
+ __slots__ = ("base", "rel_start_line", "rel_end_line")
184
+
185
+ def __init__(self, base, rel_start_line, start_col, rel_end_line, end_col):
186
+ self.base = base
187
+ self.rel_start_line = rel_start_line
188
+ self.start_col = start_col
189
+ self.rel_end_line = rel_end_line
190
+ self.end_col = end_col
191
+
192
+ @property
193
+ def start_line(self):
194
+ return self.rel_start_line + self.base.line
195
+
196
+ @property
197
+ def end_line(self):
198
+ return self.rel_end_line + self.base.line
199
+
200
+ def __reduce__(self):
201
+ return (RelSpan, (self.base, self.rel_start_line, self.start_col, self.rel_end_line, self.end_col))
202
+
203
+
204
+ REAL_TYPES = Types(
205
+ GeneralParse=GeneralParse, ClassParse=ClassParse, EnumParse=EnumParse, FunctionParse=FunctionParse,
206
+ CallParse=CallParse, DecorationParse=DecorationParse, Comment=Comment, CodeLine=CodeLine,
207
+ Conditional=Conditional, Loop=Loop, Try=Try, Except=Except, NO_DEFAULT=NO_DEFAULT, Span=RelSpan,
208
+ resolve=_resolve_parts, positional_names=_positional_names_for)
209
+
210
+
211
+ def _default_frontend(n_chars):
212
+ from meltygui.core.runtime.toggles import Toggles # lazy: avoid an import cycle
213
+ if not Toggles.TextEditor.melty_scanner:
214
+ return "ast"
215
+ if n_chars >= Toggles.TextEditor.melty_async_min_chars and _worker.available():
216
+ return "worker"
217
+ return "scan"
218
+
219
+
220
+ def parse_to_dict(text, *, file_path=None, line_offset=0, frontend=None) -> GeneralParse:
221
+ """Parse `text` into a GeneralParse with `gp["__origin__"]` attached.
222
+ `frontend`: "scan" | "worker" | "ast" (None = by Toggles and size).
223
+ Raises SyntaxError when the text doesn't parse."""
224
+ if frontend is None:
225
+ frontend = _default_frontend(len(text))
226
+ gp = origin = None
227
+ if frontend == "worker":
228
+ result = _worker.scan_extract(text)
229
+ if result is None:
230
+ frontend = "scan" # worker unavailable: same parser, in-process
231
+ elif result[0] == "error":
232
+ _, msg, lineno, offset = result
233
+ raise SyntaxError(msg, (str(file_path or "<text>"), lineno, offset, ""))
234
+ else:
235
+ gp, origin = materialize_parse(result[1], result[2])
236
+ if gp is None:
237
+ # No ast.parse fallback pass here (Lukas 09-01): a second full parse
238
+ # of a buffer per chain? - the scanner's ScanError covers what the
239
+ # tokenizer / syntax parser doesn't take, the rest is on the reverse
240
+ # path's converter (general_parse_to_str) and the hotswap compile.
241
+ gp, origin = _extract(text, frontend=frontend, types=REAL_TYPES,
242
+ file_path=file_path, line_offset=line_offset)
243
+ origin.file_path = file_path
244
+ origin.line_offset = line_offset
245
+ gp.file_path = file_path
246
+ gp.line_offset = line_offset
247
+ gp[ORIGIN_KEY] = origin
248
+ return gp
249
+
250
+
251
+ # ─── materialize: the worker's neutral tree → the studio's parse classes ─────────
252
+
253
+ def _walk_paths(node, path, fn):
254
+ """fn(path, node) over every dict node of a parse tree (list/tuple elements
255
+ included, indexed like origin paths). Bookkeeping keys are skipped by NAME
256
+ — a dunder-named def (`__missing__`) is a real node and is walked."""
257
+ if isinstance(node, dict):
258
+ fn(path, node)
259
+ for k, v in node.items():
260
+ if k in ("__origin__", "__pos_names__", "__symbol_usages__", "__cst__"):
261
+ continue
262
+ if isinstance(v, (dict, list, tuple)):
263
+ _walk_paths(v, path + (k,), fn)
264
+ elif isinstance(node, (list, tuple)):
265
+ for i, v in enumerate(node):
266
+ _walk_paths(v, path + (i,), fn)
267
+
268
+
269
+ def _bind_pending_positionals(ngp, origin):
270
+ """The worker keys positional args `argN`; here the callee's runtime
271
+ signature names them (`live_view(x)` → `value`) exactly as the in-process
272
+ extractor would have, renaming the dict keys and every origin path under
273
+ the call in one pass. Positions past the signature are dropped, like the
274
+ in-process path never surfaced them."""
275
+ renames = {}
276
+
277
+ def visit(path, node):
278
+ parts = node.__dict__.pop("_pos_pending", None) if hasattr(node, "__dict__") else None
279
+ if parts is None:
280
+ return
281
+ names = _positional_names_for(parts)
282
+ if names is None:
283
+ return
284
+ pos_keys = [k for k in node if isinstance(k, str) and k.startswith("arg") and k[3:].isdigit()]
285
+ mapping = {k: (names[i] if i < len(names) else None) for i, k in enumerate(pos_keys)}
286
+ items = []
287
+ for k, v in node.items():
288
+ if k in mapping:
289
+ if mapping[k] is None:
290
+ continue
291
+ items.append((mapping[k], v))
292
+ elif k == "__pos_names__":
293
+ continue
294
+ else:
295
+ items.append((k, v))
296
+ node.clear()
297
+ node.update(items)
298
+ if names:
299
+ node["__pos_names__"] = list(names)
300
+ renames[path] = mapping
301
+
302
+ _walk_paths(ngp, (), visit)
303
+ if not renames:
304
+ return
305
+
306
+ def rename_path(path):
307
+ for L in range(len(path)):
308
+ m = renames.get(path[:L])
309
+ if m is not None and L < len(path) and path[L] in m:
310
+ new = m[path[L]]
311
+ if new is None:
312
+ return None
313
+ path = path[:L] + (new,) + path[L + 1:]
314
+ return path
315
+
316
+ items = {}
317
+ dropped = set()
318
+ for path, item in origin.items.items():
319
+ np = rename_path(path)
320
+ if np is None:
321
+ dropped.add(id(item))
322
+ continue
323
+ item.path = np
324
+ item.key = np[-1]
325
+ items[np] = item
326
+ origin.items = items
327
+ text = origin.text
328
+ for seq in origin.seqs.values():
329
+ seq.owner = rename_path(seq.owner) or seq.owner
330
+ kept = [it for it in seq.items if id(it) not in dropped]
331
+ if len(kept) != len(seq.items):
332
+ seq.items = kept # `region` follows the items; only the separator is re-read
333
+ if len(kept) >= 2:
334
+ seq.sep = text[kept[0].extent[1]:kept[1].extent[0]]
335
+ origin.default_seq = {rename_path(p) or p: v for p, v in origin.default_seq.items()}
336
+ origin.owned = {rename_path(p) or p: v for p, v in origin.owned.items()}
337
+ origin.loose = {rename_path(p) or p: [it for it in v if id(it) not in dropped]
338
+ for p, v in origin.loose.items()}
339
+
340
+
341
+ _NODE_ATTR_DEFAULTS = {"file_path": None, "line_offset": 0, "usages": None, "_bg_hash_cache": None,
342
+ "source_ref": None, "symbol_usage": None}
343
+
344
+
345
+ def materialize_parse(gp, origin):
346
+ """Finish a worker parse that `_ParseUnpickler` already loaded as the
347
+ studio's classes: give nodes the attributes their `__init__` would have set
348
+ (pickle bypasses it), resolve `NameRef`s against the live src scope, and
349
+ bind positional args to runtime signatures (renaming dict keys and origin
350
+ paths). `Item.orig` identity with the dict values survives the pickle
351
+ round trip on its own (one dumps → shared references)."""
352
+ from meltygui.code.libcst_conversion import _yield_to_ui
353
+ _yield_to_ui()
354
+ _bind_pending_positionals(gp, origin)
355
+
356
+ visited = 0
357
+ def fix(path, node):
358
+ nonlocal visited
359
+ visited += 1
360
+ if visited % 128 == 0:
361
+ _yield_to_ui()
362
+ d = getattr(node, "__dict__", None)
363
+ if d is not None:
364
+ if isinstance(node, GeneralParse):
365
+ for attr, default in _NODE_ATTR_DEFAULTS.items():
366
+ d.setdefault(attr, default)
367
+ if "address" not in d:
368
+ d["address"] = None
369
+ if "usages" not in d or d["usages"] is None:
370
+ d["usages"] = {}
371
+ if "symbol_usage" not in d or d["symbol_usage"] is None:
372
+ d["symbol_usage"] = [None]
373
+ elif isinstance(node, Loop):
374
+ d.setdefault("_bg_hash_cache", None)
375
+ for k in list(node.keys()):
376
+ v = node[k]
377
+ if isinstance(v, (NameRef, list, tuple)):
378
+ node[k] = _resolve_refs(v, path + (k,), origin)
379
+
380
+ _walk_paths(gp, (), fix)
381
+ return gp, origin
382
+
383
+
384
+ def _resolve_refs(v, path, origin):
385
+ """`v` with every NameRef (at any container depth) resolved against the src
386
+ scope or turned into a CodeLine; `Item.orig` at each touched path follows,
387
+ so the residual holds what the dict holds. Tuples are rebuilt, lists
388
+ patched in place."""
389
+ if isinstance(v, NameRef):
390
+ r = _resolve_parts(v.parts)
391
+ new = r if r is not UNRESOLVED else CodeLine(str(v))
392
+ elif isinstance(v, (list, tuple)):
393
+ if not any(isinstance(x, (NameRef, list, tuple)) for x in v):
394
+ return v
395
+ fixed = [_resolve_refs(x, path + (i,), origin) for i, x in enumerate(v)]
396
+ if isinstance(v, tuple):
397
+ new = tuple(fixed)
398
+ else:
399
+ v[:] = fixed
400
+ return v
401
+ else:
402
+ return v
403
+ item = origin.items.get(path)
404
+ if item is not None:
405
+ item.orig = new
406
+ return new
407
+
408
+
409
+
410
+
411
+ class _ParseUnpickler(pickle.Unpickler):
412
+ """Loads the worker's neutral classes AS the studio's classes: no second
413
+ tree, no per-node conversion — `Comment(text, inline)`, `CodeLine`,
414
+ `Span(...)`, the NO_DEFAULT singleton, and the parse dict subclasses come
415
+ out of `loads` directly (their `__init__` is bypassed; materialize_parse
416
+ fills the attributes it would have set)."""
417
+ _MAP = {"NGeneralParse": GeneralParse, "NClassParse": ClassParse, "NEnumParse": EnumParse,
418
+ "NFunctionParse": FunctionParse, "NCallParse": CallParse, "NDecorationParse": DecorationParse,
419
+ "NConditional": Conditional, "NLoop": Loop, "NTry": Try, "NExcept": Except,
420
+ "NComment": Comment, "NCodeLine": CodeLine, "NSpan": RelSpan, "NNoDefault": lambda: NO_DEFAULT}
421
+
422
+ def find_class(self, module, name):
423
+ if module == "_melty_scan" or module.endswith(".melty_scan"):
424
+ real = self._MAP.get(name)
425
+ if real is not None:
426
+ return real
427
+ if module == "_melty_scan":
428
+ from meltygui.code import melty_scan
429
+ return getattr(melty_scan, name)
430
+ return super().find_class(module, name)
431
+
432
+
433
+ # ── the parse worker: a subinterpreter with its own GIL ───────────────────────────
434
+
435
+ _WORKER_SCRIPT = r"""
436
+ import sys
437
+ import importlib.util
438
+ import pickle
439
+ import _xxinterpchannels as _ch
440
+ # Importing the package also initializes app/threading state in this
441
+ # interpreter. It can then hang at shutdown waiting for the original
442
+ # caller thread. The scanner is stdlib-only: load it without the GUI.
443
+ if '_melty_scan' not in sys.modules:
444
+ _spec = importlib.util.spec_from_file_location('_melty_scan', SCANNER)
445
+ _ms = importlib.util.module_from_spec(_spec)
446
+ sys.modules['_melty_scan'] = _ms
447
+ _spec.loader.exec_module(_ms)
448
+ else:
449
+ _ms = sys.modules['_melty_scan']
450
+ import gc as _gc
451
+ _gc.disable() # ~20% of the scan was gen-2 collections over the fresh tree
452
+ try:
453
+ _res = _ms.scan_extract(TEXT, validate=False) # no ast validation pass (09-01)
454
+ except Exception as _e: # never raise across the boundary: report as data
455
+ _res = ("error", f"{type(_e).__name__}: {_e}", 0, 0)
456
+ _blob = pickle.dumps(_res, protocol=pickle.HIGHEST_PROTOCOL)
457
+ del _res
458
+ _gc.enable()
459
+ _gc.collect()
460
+ _ch.send(CID, _blob)
461
+ """
462
+
463
+
464
+ class _ScanWorker:
465
+ """One 3.12 subinterpreter (`_xxsubinterpreters`, per-interpreter GIL) that
466
+ runs melty_scan.scan_extract. A dedicated owner thread executes inside the
467
+ subinterpreter, under ITS GIL — the main interpreter's GIL is free for the
468
+ render thread the whole time (measured: a 130 ms parse and 130 ms of main-
469
+ thread Python overlap to 130 ms wall). One run at a time; a second caller
470
+ waits on the lock. Any failure to boot marks the worker unavailable and
471
+ parses fall back to the in-process scanner."""
472
+
473
+ def __init__(self):
474
+ self._lock = threading.Lock()
475
+ self._interp = None
476
+ self._cid = None
477
+ self._broken = False
478
+ self._requests = queue.Queue()
479
+ self._thread = None
480
+
481
+ def _run(self):
482
+ try:
483
+ while True:
484
+ request = self._requests.get()
485
+ if request is None:
486
+ return
487
+ text, reply = request
488
+ try:
489
+ reply.put(self._execute(text))
490
+ except BaseException as error:
491
+ reply.put(error)
492
+ finally:
493
+ if self._interp is not None:
494
+ import _xxsubinterpreters as si
495
+ import _xxinterpchannels as ch
496
+ si.destroy(self._interp)
497
+ ch.destroy(self._cid)
498
+ self._interp = self._cid = None
499
+
500
+ def close(self):
501
+ with self._lock:
502
+ thread = self._thread
503
+ if thread is not None:
504
+ self._requests.put(None)
505
+ if thread is not None:
506
+ thread.join()
507
+ self._thread = None
508
+
509
+ def available(self):
510
+ if self._broken:
511
+ return False
512
+ try:
513
+ import _xxsubinterpreters # noqa: F401
514
+ import _xxinterpchannels # noqa: F401
515
+ except ImportError:
516
+ self._broken = True
517
+ return False
518
+ return True
519
+
520
+ def scan_extract(self, text):
521
+ """("ok", gp, origin) | ("error", msg, lineno, offset) — or None when the
522
+ worker can't run (the caller parses in-process)."""
523
+ if not self.available():
524
+ return None
525
+ reply = queue.Queue(maxsize=1)
526
+ with self._lock:
527
+ if self._thread is None:
528
+ self._thread = threading.Thread(target=self._run, name='syntax-scanner', daemon=True)
529
+ self._thread.start()
530
+ self._requests.put((text, reply))
531
+ blob = reply.get()
532
+ if isinstance(blob, BaseException):
533
+ raise blob
534
+ if blob is None:
535
+ return None
536
+ import io
537
+ from meltygui.code.libcst_conversion import _yield_to_ui
538
+ _yield_to_ui() # deserialization is back under the application's GIL
539
+ return _ParseUnpickler(io.BytesIO(blob)).load()
540
+
541
+ def _execute(self, text):
542
+ import _xxsubinterpreters as si
543
+ import _xxinterpchannels as ch
544
+ scanner = str(Path(__file__).with_name('melty_scan.py'))
545
+ with self._lock:
546
+ try:
547
+ if self._interp is None:
548
+ self._interp = si.create()
549
+ self._cid = ch.create()
550
+ si.run_string(self._interp, _WORKER_SCRIPT,
551
+ shared={"TEXT": str(text), "SCANNER": scanner, "CID": int(self._cid)})
552
+ blob = ch.recv(self._cid)
553
+ except Exception as e:
554
+ print(f"core_syntax: scan worker failed ({type(e).__name__}: {str(e)[:200]}); "
555
+ "parsing in-process from now on")
556
+ self._broken = True
557
+ return None
558
+ return blob
559
+
560
+
561
+ _worker = _ScanWorker()
562
+ atexit.register(_worker.close)
563
+
564
+
565
+ # ╔══════════════════════════════════════════════════════════════════════════════╗
566
+ # ║ Value codec: equality + rendering styled on the old text ║
567
+ # ╚══════════════════════════════════════════════════════════════════════════════╝
568
+
569
+ def _parse_kind(obj):
570
+ """The parse class of a node, seen through a bubbling subclass (the studio's
571
+ held tree is reclassed in place to `Bubbling_<Base>`)."""
572
+ t = type(obj)
573
+ # Generated `Bubbling_<Base>` reclasses AND the static `_BubblingDict` /
574
+ # `_BubblingList` copies that replace plain container containers.
575
+ if t.__module__.endswith(".bubbling") or t.__name__.startswith("Bubbling_"):
576
+ from meltygui.core.conversion.bubbling import base_of_bubbling
577
+ return base_of_bubbling(t)
578
+ return t
579
+
580
+
581
+ def _same_kind(a, b):
582
+ """Same container family (a bubbling list IS a list), else same type."""
583
+ if isinstance(a, list) or isinstance(b, list):
584
+ return isinstance(a, list) and isinstance(b, list)
585
+ if isinstance(a, tuple) or isinstance(b, tuple):
586
+ return isinstance(a, tuple) and isinstance(b, tuple)
587
+ if isinstance(a, dict) or isinstance(b, dict):
588
+ return isinstance(a, dict) and isinstance(b, dict)
589
+ return type(a) is type(b)
590
+
591
+
592
+ def values_equal(a, b):
593
+ """Semantic equality for leaf values: the float32-noise rule for floats,
594
+ type-strict for bools/ints/strs (1 vs 1.0 vs True are different code),
595
+ identity for enum members and callables."""
596
+ if a is b:
597
+ return True
598
+ if isinstance(a, NoDefault) or isinstance(b, NoDefault):
599
+ return isinstance(a, NoDefault) and isinstance(b, NoDefault) # TODO: any instance (pickle)
600
+ if isinstance(a, bool) or isinstance(b, bool):
601
+ return type(a) is type(b) and a == b
602
+ if isinstance(a, float) and isinstance(b, float):
603
+ return _floats_match(a, b)
604
+ if isinstance(a, (int, float)) or isinstance(b, (int, float)):
605
+ return type(a) is type(b) and a == b
606
+ if isinstance(a, CodeLine) or isinstance(b, CodeLine):
607
+ return type(a) is type(b) and str(a) == str(b)
608
+ if isinstance(a, str) and isinstance(b, str):
609
+ return a == b
610
+ if isinstance(a, (list, tuple)) and _same_kind(a, b):
611
+ return len(a) == len(b) and all(values_equal(x, y) for x, y in zip(a, b))
612
+ if isinstance(a, dict) and isinstance(b, dict):
613
+ ka = [k for k in a if not _is_dunder(k)]
614
+ kb = [k for k in b if not _is_dunder(k)]
615
+ return ka == kb and all(values_equal(a[k], b[k]) for k in ka)
616
+ if isinstance(a, set) and isinstance(b, set):
617
+ return a == b
618
+ if isinstance(a, enum.Enum) or callable(a) or isinstance(b, enum.Enum) or callable(b):
619
+ return False
620
+ try:
621
+ return bool(a == b)
622
+ except Exception:
623
+ return False
624
+
625
+
626
+ def _qualname_text(obj):
627
+ qualname = getattr(obj, "__qualname__", None) or getattr(obj, "__name__", None)
628
+ if not qualname:
629
+ return None
630
+ parts = [p for p in qualname.split(".") if not p.startswith("<")]
631
+ return ".".join(parts) if parts else None
632
+
633
+
634
+ def _render_str(value, old_text):
635
+ if (old_text and len(old_text) >= 2 and old_text[0] in "'\"" and old_text[-1] == old_text[0]
636
+ and not old_text.startswith(("'''", '"""'))):
637
+ q = old_text[0]
638
+ escaped = (value.replace("\\", "\\\\").replace("\n", "\\n").replace("\r", "\\r")
639
+ .replace("\t", "\\t").replace(q, "\\" + q))
640
+ return f"{q}{escaped}{q}"
641
+ return repr(value)
642
+
643
+
644
+ def _container_style(old_text):
645
+ """(open, close, sep, trailing_comma) reusing the old literal's layout: a
646
+ multi-line literal keeps its line breaks and continuation indent."""
647
+ if not old_text:
648
+ return None
649
+ opener, closer = old_text[0], old_text[-1]
650
+ if opener not in "([{" or closer not in ")]}":
651
+ return None
652
+ inner = old_text[1:-1]
653
+ trailing = inner.rstrip().endswith(",")
654
+ sep = ", "
655
+ if "\n" in inner:
656
+ after = inner.split("\n", 1)[1]
657
+ indent = after[:len(after) - len(after.lstrip())]
658
+ sep = ",\n" + indent
659
+ return opener, closer, sep, trailing
660
+
661
+
662
+ def render(value, old_text=None, orig=None):
663
+ """Source text for `value`, styled on `old_text` (the text it replaces)."""
664
+ if isinstance(value, CodeLine):
665
+ return str(value)
666
+ if isinstance(value, bool):
667
+ return "True" if value else "False"
668
+ if value is None:
669
+ return "None"
670
+ if isinstance(value, float):
671
+ return _float_to_str(value, old_text if isinstance(orig, float) else None)
672
+ if isinstance(value, int):
673
+ return str(int(value))
674
+ if isinstance(value, Comment):
675
+ return str(value)
676
+ if isinstance(value, str):
677
+ return _render_str(value, old_text if isinstance(orig, str) and not isinstance(orig, CodeLine) else None)
678
+ if isinstance(value, enum.Enum):
679
+ if isinstance(orig, enum.Enum) and type(orig) is type(value) and old_text and "." in old_text:
680
+ return old_text.rsplit(".", 1)[0] + "." + value.name
681
+ return f"{type(value).__qualname__}.{value.name}"
682
+ if isinstance(value, CallParse):
683
+ args = ", ".join(f"{k}={render(v)}" for k, v in value.items() if not _is_dunder(k))
684
+ return f"{value.func_name or 'call'}({args})"
685
+ if isinstance(value, dict):
686
+ style = _container_style(old_text) if isinstance(orig, dict) else None
687
+ opener, closer, sep, trailing = style or ("{", "}", ", ", False)
688
+ parts = [f"{render(k)}: {render(v)}" for k, v in value.items() if not _is_dunder(k)]
689
+ return opener + sep.join(parts) + ("," if trailing and parts else "") + closer
690
+ if isinstance(value, (list, tuple)):
691
+ is_tuple = isinstance(value, tuple)
692
+ style = _container_style(old_text) if _same_kind(orig, value) else None
693
+ if style is None:
694
+ if is_tuple and old_text and isinstance(orig, tuple) and old_text[0] != "(":
695
+ opener, closer = "", "" # a bare `x = 1, 2` tuple keeps its bareness
696
+ else:
697
+ opener, closer = ("(", ")") if is_tuple else ("[", "]")
698
+ sep, trailing = ", ", False
699
+ else:
700
+ opener, closer, sep, trailing = style
701
+ parts = [render(v) for v in value]
702
+ if is_tuple and len(parts) == 1:
703
+ return opener + parts[0] + "," + closer
704
+ return opener + sep.join(parts) + ("," if trailing and parts else "") + closer
705
+ if isinstance(value, (set, frozenset)):
706
+ return "{" + ", ".join(render(v) for v in sorted(value, key=repr)) + "}" if value else "set()"
707
+ if callable(value):
708
+ name = _qualname_text(value)
709
+ if name is not None:
710
+ return name
711
+ return repr(value)
712
+
713
+
714
+ # ╔══════════════════════════════════════════════════════════════════════════════╗
715
+ # ║ Reverse: diff the live dict against the Origin → text edits ║
716
+ # ╚══════════════════════════════════════════════════════════════════════════════╝
717
+
718
+ def _managed_keys(node):
719
+ if isinstance(node, dict):
720
+ return [k for k in node if not _is_dunder(k)]
721
+ if isinstance(node, (list, tuple)):
722
+ return list(range(len(node)))
723
+ return []
724
+
725
+
726
+ def diff(gp, origin=None):
727
+ """All edits that turn `origin.text` into the text `gp` now describes."""
728
+ if origin is None:
729
+ origin = gp[ORIGIN_KEY]
730
+ edits = []
731
+ _walk(gp, (), origin, edits)
732
+ return edits
733
+
734
+
735
+ def _walk(node, path, origin, edits):
736
+ keys = _managed_keys(node)
737
+ if isinstance(node, CallParse) and node.get("__callee__") is not None:
738
+ item = origin.items.get(path + ("__callee__",))
739
+ if item is not None and str(node["__callee__"]) != item.orig:
740
+ edits.append(TextEdit(*item.value_span, str(node["__callee__"])))
741
+ if isinstance(node, dict):
742
+ # Dunder-named DEFS are editable too - `__init__` methods, the chain's
743
+ # synthetic `__melty_*_wrap__` snippet wrappers - just never reordered.
744
+ keys += [k for k in node if _is_dunder(k)
745
+ and getattr(origin.items.get(path + (k,)), "kind", None) == "def"]
746
+ present_by_seq: dict[int, list] = {}
747
+ new_keys = []
748
+ for k in keys:
749
+ item = origin.items.get(path + (k,))
750
+ if item is None:
751
+ new_keys.append(k)
752
+ continue
753
+ if item.seq is not None and not _is_dunder(k):
754
+ present_by_seq.setdefault(item.seq, []).append(k)
755
+ if item.kind in ("comment", "trailing"):
756
+ _diff_comment(node[k], item, origin, edits)
757
+ elif item.kind == "override":
758
+ continue
759
+ else:
760
+ _diff_value(node[k], item, path + (k,), origin, edits)
761
+ if isinstance(node, dict) and type(node) is not dict:
762
+ _diff_overrides(node, path, origin, edits)
763
+ # Comments / trailing comments live outside the Seqs: a key that drops the dict is a delete.
764
+ for item in origin.loose.get(path, ()):
765
+ if (item.kind in ("comment", "trailing") and not item.shadowed
766
+ and not (isinstance(node, dict) and item.key in node)):
767
+ edits.append(TextEdit(*item.extent, ""))
768
+
769
+ for seq_id in origin.owned.get(path, []):
770
+ seq = origin.seqs[seq_id]
771
+ present = present_by_seq.get(seq_id, [])
772
+ present_set = set(present)
773
+ # Source order of the keys, each at its first binding (a re-bound key
774
+ # keeps its first slot in dict order too); dunders never take part.
775
+ old = []
776
+ for it in seq.items:
777
+ if not _is_dunder(it.key) and it.key not in old:
778
+ old.append(it.key)
779
+ removed = [k for k in old if k not in present_set]
780
+ adds = [k for k in new_keys if origin.default_seq.get(path) == seq_id
781
+ and not isinstance(node[k], GeneralParse)]
782
+ reordered = [k for k in old if k in present_set] != present
783
+ if not (removed or adds or reordered):
784
+ continue
785
+ if seq.kind in ("body", "decorators") and not reordered:
786
+ for k in removed:
787
+ edits.append(TextEdit(*origin.items[path + (k,)].extent, ""))
788
+ for k in adds:
789
+ edits.append(_insert_member(node, k, path, seq, origin))
790
+ else:
791
+ edits.append(_rebuild_seq(node, keys, path, seq, present_set, set(adds), origin))
792
+
793
+
794
+ def _fixed(item):
795
+ """Members the diff never touches and a rebuild keeps in their slot: dunder
796
+ keys (`__all__`, the libcst patcher's _is_dunder rule) and bindings a later
797
+ statement shadowed."""
798
+ return item.shadowed or _is_dunder(item.key)
799
+
800
+
801
+ def _diff_value(new, item, path, origin, edits):
802
+ orig = item.orig
803
+ seq_id = origin.default_seq.get(path)
804
+ if isinstance(new, dict) and isinstance(orig, dict) and seq_id is not None:
805
+ _walk(new, path, origin, edits)
806
+ return
807
+ if isinstance(new, (list, tuple)) and _same_kind(new, orig) and seq_id is not None:
808
+ _walk(new, path, origin, edits)
809
+ return
810
+ if item.kind in ("def", "block", "pseudo"):
811
+ if isinstance(new, dict):
812
+ _walk(new, path, origin, edits)
813
+ return
814
+ if item.value_span is None:
815
+ if item.kind == "param" and not isinstance(new, NoDefault):
816
+ edits.append(TextEdit(item.slot, item.slot, "=" + render(new)))
817
+ return
818
+ if item.kind == "param" and isinstance(new, NoDefault):
819
+ edits.append(TextEdit(item.slot, item.value_span[1], ""))
820
+ return
821
+ if new is orig or values_equal(new, orig):
822
+ return
823
+ old_text = origin.text[item.value_span[0]:item.value_span[1]]
824
+ edits.append(TextEdit(*item.value_span, render(new, old_text, orig)))
825
+
826
+
827
+ def _diff_comment(new, item, origin, edits):
828
+ text = str(new)
829
+ if text == item.orig:
830
+ return
831
+ nl = origin.src.newline
832
+ edits.append(TextEdit(*item.value_span, (nl + item.indent).join(text.split("\n"))))
833
+
834
+
835
+ def _diff_overrides(node, path, origin, edits):
836
+ ov = node.get("__overrides__")
837
+ if not isinstance(ov, dict):
838
+ ov = {}
839
+ plain = {k: v for k, v in ov.items() if not _is_dunder(k)}
840
+ _diff_override_comment(plain, path + ("__overrides__",), node, path, origin, edits, field_key=None)
841
+ for k, v in ov.items():
842
+ if _is_dunder(k) and isinstance(v, dict) and len(k) > 4:
843
+ _diff_override_comment(v, path + ("__overrides__", k), node, path, origin, edits,
844
+ field_key=k[2:-2])
845
+ # A field override whose comment was removed from the dict entirely.
846
+ for item in origin.loose.get(path + ("__overrides__",), ()):
847
+ if item.kind == "override" and item.key not in ov and not item.shadowed:
848
+ edits.append(TextEdit(*item.extent, ""))
849
+
850
+
851
+ def _diff_override_comment(pairs, ipath, node, path, origin, edits, field_key):
852
+ item = origin.items.get(ipath)
853
+ if item is not None and item.kind != "override":
854
+ item = None # a dict literal keyed "__overrides__" is not an override comment
855
+ nl = origin.src.newline
856
+ if item is not None:
857
+ if item.comment_key is not None:
858
+ current = node.get(item.comment_key)
859
+ if current is not None and str(current) != item.orig and str(current) != str(item.comment_key):
860
+ return # the raw comment text was edited - it wins
861
+ if not pairs:
862
+ if item.comment_key is None:
863
+ edits.append(TextEdit(*item.extent, ""))
864
+ return
865
+ if _override_changed(pairs, item.orig):
866
+ old_text = origin.text[item.value_span[0]:item.value_span[1]]
867
+ old_joined = "\n".join(ln.strip() for ln in old_text.split("\n"))
868
+ text = _reformat_override_comment(old_joined, pairs)
869
+ edits.append(TextEdit(*item.value_span, (nl + item.indent).join(text.split("\n"))))
870
+ return
871
+ if not pairs:
872
+ return
873
+ # No comment yet → insert one above the owner (a dict's own line, or the field's statement).
874
+ if field_key is not None:
875
+ target = origin.items.get(path + (field_key,))
876
+ else:
877
+ target = origin.items.get(path)
878
+ if target is None:
879
+ if path == () and field_key is None:
880
+ edits.append(TextEdit(0, 0, _format_override_comment(pairs) + nl))
881
+ return
882
+ at = _owner_line_start(target, origin)
883
+ edits.append(TextEdit(at, at, target.indent + _format_override_comment(pairs) + nl))
884
+
885
+
886
+ def _owner_line_start(item, origin):
887
+ """Start of the line the item's own code begins on (below its leading
888
+ comments): where a new override comment goes."""
889
+ if item.code_end is None:
890
+ return item.core[0]
891
+ # Walk back from the core/code to the line start of the statement's first line.
892
+ start = item.core[0]
893
+ text = origin.text
894
+ lines_start = start
895
+ # The statement's first code line is the first non-comment, non-blank line in the core.
896
+ pos = start
897
+ while pos < item.core[1]:
898
+ line_end = text.find("\n", pos)
899
+ if line_end == -1:
900
+ line_end = len(text)
901
+ stripped = text[pos:line_end].strip()
902
+ if stripped and not stripped.startswith("#"):
903
+ return pos
904
+ pos = line_end + 1
905
+ return lines_start
906
+
907
+
908
+ def _render_member(node, key, seq, origin, indent):
909
+ """Source text for a NEW member of `seq` — a statement line for a body,
910
+ `k=v` for args, `k: v` for pairs, the value for elements/params."""
911
+ value = node[key] if isinstance(node, dict) else node[key]
912
+ nl = origin.src.newline
913
+ kind = seq.kind
914
+ if kind == "body":
915
+ if isinstance(value, Comment):
916
+ return "".join(indent + ln + nl for ln in str(value).split("\n"))
917
+ if isinstance(value, CallParse):
918
+ return indent + render(value) + nl
919
+ return f"{indent}{key} = {render(value)}{nl}"
920
+ if kind == "decorators":
921
+ if isinstance(value, str) and not isinstance(value, CodeLine) and value == key:
922
+ return f"{indent}@{value}{nl}"
923
+ if isinstance(value, CallParse):
924
+ return f"{indent}@{render(value)}{nl}"
925
+ return f"{indent}@{value}{nl}"
926
+ if kind == "params":
927
+ return str(key) if isinstance(value, NoDefault) else f"{key}={render(value)}"
928
+ if kind == "args":
929
+ pos_names = node.get("__pos_names__") if isinstance(node, dict) else None
930
+ if pos_names and key in pos_names:
931
+ return render(value)
932
+ return f"{key}={render(value)}"
933
+ if kind == "pairs":
934
+ return f"{render(key)}: {render(value)}"
935
+ return render(value)
936
+
937
+
938
+ def _insert_member(node, key, path, seq, origin):
939
+ """A body/decorator insert as its own zero-width edit at the dict position."""
940
+ keys = _managed_keys(node)
941
+ i = keys.index(key)
942
+ prev = None
943
+ for k in reversed(keys[:i]):
944
+ it = origin.items.get(path + (k,))
945
+ if it is not None and it.seq == seq.id:
946
+ prev = it
947
+ break
948
+ value = node[key]
949
+ if isinstance(value, Comment) and value.inline is not None:
950
+ owner = origin.items.get(path + (value.inline,))
951
+ if owner is not None and owner.code_end is not None:
952
+ return TextEdit(owner.code_end, owner.code_end, " " + str(value))
953
+ if prev is not None:
954
+ at, indent = prev.extent[1], prev.indent
955
+ else:
956
+ nxt = None
957
+ for k in keys[i + 1:]:
958
+ it = origin.items.get(path + (k,))
959
+ if it is not None and it.seq == seq.id:
960
+ nxt = it
961
+ break
962
+ at = nxt.core[0] if nxt is not None else seq.insert_at
963
+ indent = nxt.indent if nxt is not None else seq.indent
964
+ text = _render_member(node, key, seq, origin, indent)
965
+ if at >= len(origin.text) and origin.text and not origin.text.endswith("\n"):
966
+ text = origin.src.newline + text
967
+ return TextEdit(at, at, text)
968
+
969
+
970
+ def _rebuild_seq(node, keys, path, seq, present_set, adds_set, origin):
971
+ """One RegionEdit re-concatenating the members in the dict's order. The old
972
+ members are SLOTS: a fixed slot (dunder / shadowed binding) keeps its own
973
+ text; the movable slots are filled with the movable keys in the dict's new
974
+ order — kept members as verbatim source slices (nested edits inside them
975
+ still apply), new ones rendered. Surplus movable keys append; surplus slots
976
+ (removed keys) vanish. The gaps between slots stay where they were, so the
977
+ blank-line rhythm / separators survive a reorder."""
978
+ items_by_key = {it.key: it for it in seq.items if not _fixed(it)}
979
+ cores = [it.core for it in seq.items]
980
+ gaps = [(cores[i][1], cores[i + 1][0]) for i in range(len(cores) - 1)]
981
+ movable = [k for k in keys if k in present_set or k in adds_set]
982
+ default_gap = "" if seq.kind in ("body", "decorators") else seq.sep
983
+ pieces = []
984
+ emitted = False
985
+ mi = 0
986
+ last_indent = seq.indent
987
+
988
+ def member(k):
989
+ it = items_by_key.get(k)
990
+ if it is not None:
991
+ return it.core, it.indent
992
+ return _render_member(node, k, seq, origin, last_indent), last_indent
993
+
994
+ for idx, it in enumerate(seq.items):
995
+ gap = gaps[idx - 1] if idx > 0 else None
996
+ if _fixed(it):
997
+ piece, indent = it.core, it.indent
998
+ elif mi < len(movable):
999
+ piece, indent = member(movable[mi])
1000
+ mi += 1
1001
+ else:
1002
+ continue # a removed key's slot (and the gap before it)
1003
+ if emitted and gap is not None:
1004
+ pieces.append(gap)
1005
+ pieces.append(piece)
1006
+ last_indent = indent
1007
+ emitted = True
1008
+ while mi < len(movable):
1009
+ if emitted:
1010
+ pieces.append(default_gap)
1011
+ piece, indent = member(movable[mi])
1012
+ pieces.append(piece)
1013
+ last_indent = indent
1014
+ emitted = True
1015
+ mi += 1
1016
+ start, end = seq.region
1017
+ if not pieces and seq.kind in ("elements", "args", "pairs", "params"):
1018
+ # Everything removed: take a trailing comma / whitespace with it.
1019
+ tail = end
1020
+ text = origin.text
1021
+ while tail < len(text) and text[tail] in " \t\r\n":
1022
+ tail += 1
1023
+ if tail < len(text) and text[tail] == ",":
1024
+ end = tail + 1
1025
+ return RegionEdit(start, end, pieces)
1026
+
1027
+
1028
+ # ── materialize + apply ──────────────────────────────────────────────────────
1029
+
1030
+ def apply_edits(text, edits):
1031
+ """Apply non-overlapping plain edits (any order) to `text`."""
1032
+ out, pos = [], 0
1033
+ for e in sorted(edits, key=lambda e: (e.start, e.end)):
1034
+ if e.start < pos:
1035
+ raise ValueError(f"overlapping edits at {e.start} (previous ended at {pos})")
1036
+ out.append(text[pos:e.start])
1037
+ out.append(e.replacement)
1038
+ pos = e.end
1039
+ out.append(text[pos:])
1040
+ return "".join(out)
1041
+
1042
+
1043
+ def materialize(text, edits):
1044
+ """Flatten RegionEdits into plain TextEdits (absolute offsets), applying the
1045
+ edits nested inside each region's source slices."""
1046
+ ordered = sorted(edits, key=lambda e: (e.start, -(e.end - e.start)))
1047
+ out = []
1048
+ i = 0
1049
+ while i < len(ordered):
1050
+ e = ordered[i]
1051
+ if isinstance(e, RegionEdit):
1052
+ j = i + 1
1053
+ children = []
1054
+ while j < len(ordered) and ordered[j].start < e.end:
1055
+ if ordered[j].end > e.end:
1056
+ raise ValueError("edit crosses a region boundary")
1057
+ children.append(ordered[j])
1058
+ j += 1
1059
+ out.append(TextEdit(e.start, e.end, _render_region(text, e, children)))
1060
+ i = j
1061
+ else:
1062
+ out.append(e)
1063
+ i += 1
1064
+ return out
1065
+
1066
+
1067
+ def _render_region(text, region, children):
1068
+ flat = materialize(text, children) if children else []
1069
+ parts = []
1070
+ n = len(region.pieces)
1071
+ for idx, p in enumerate(region.pieces):
1072
+ if isinstance(p, str):
1073
+ parts.append(p)
1074
+ continue
1075
+ s, e = p
1076
+ inner = [TextEdit(c.start - s, c.end - s, c.replacement) for c in flat
1077
+ if s <= c.start and c.end <= e and (c.start < e or (s == e) or idx == n - 1)]
1078
+ parts.append(apply_edits(text[s:e], inner) if inner else text[s:e])
1079
+ return "".join(parts)
1080
+
1081
+
1082
+ def general_parse_to_str(gp, *, check=True) -> str:
1083
+ """The text `gp` now describes. With `check`, the result must parse."""
1084
+ origin = gp[ORIGIN_KEY]
1085
+ edits = materialize(origin.text, diff(gp, origin))
1086
+ new_text = apply_edits(origin.text, edits)
1087
+ if check and edits:
1088
+ try:
1089
+ ast.parse(new_text)
1090
+ except SyntaxError as e:
1091
+ raise CoreSyntaxError(str(e), new_text, e.lineno or 0, e.offset or 0) from e
1092
+ return new_text
1093
+
1094
+
1095
+ # ╔══════════════════════════════════════════════════════════════════════════════╗
1096
+ # ║ Live path: re-extract, reuse unchanged value objects ║
1097
+ # ╚══════════════════════════════════════════════════════════════════════════════╝
1098
+
1099
+ _MISSING = object()
1100
+
1101
+ _ROOT_CARRY_ATTRS = ("file_path", "line_offset", "address", "symbol_usage", "_symbol_gen",
1102
+ "usages", "source_ref")
1103
+
1104
+
1105
+ def reparse_reusing(gp, new_text) -> GeneralParse:
1106
+ """A fresh parse of `new_text` in which every value object that is
1107
+ semantically unchanged from `gp` is REUSED by identity — a nested node
1108
+ whose whole subtree is unchanged is the old object itself (its spans
1109
+ refreshed) — so draw_states keyed on those objects survive the edit.
1110
+
1111
+ `gp` is NEVER mutated: the studio's held tree is bubbling-wrapped, where a
1112
+ dict mutation notifies the code host as a user edit — the libcst
1113
+ incremental builds a new root the same way. The root is `gp` itself only
1114
+ when nothing surfaced changed (then just its residual moves on). Root
1115
+ bookkeeping (address, file_path, symbol usages, …) carries over. Raises
1116
+ SyntaxError like parse_to_dict."""
1117
+ fresh = parse_to_dict(new_text, file_path=getattr(gp, "file_path", None),
1118
+ line_offset=getattr(gp, "line_offset", 0))
1119
+ origin = fresh[ORIGIN_KEY]
1120
+ from meltygui.code.libcst_conversion import _yield_to_ui
1121
+ _yield_to_ui()
1122
+ merged = _merge_node(gp, fresh, (), origin)
1123
+ if merged is gp:
1124
+ gp[ORIGIN_KEY] = origin # internal key: a raw write OK on a bubbling node
1125
+ _copy_node_attrs(gp, fresh)
1126
+ return gp
1127
+ for attr in _ROOT_CARRY_ATTRS:
1128
+ if hasattr(gp, attr):
1129
+ setattr(merged, attr, getattr(gp, attr))
1130
+ return merged
1131
+
1132
+
1133
+ def reparse_incremental(gp, new_text) -> GeneralParse:
1134
+ """The keystroke path: re-parse only the TOP-LEVEL statements the edit
1135
+ touched and splice them into a new root; everything else is the previous
1136
+ parse's objects with their offsets / line numbers shifted. Falls back to
1137
+ `reparse_reusing` (a full parse) when the edit lands outside every
1138
+ statement at the module head, touches more than half the file, or the
1139
+ previous parse carries no statement table. Raises SyntaxError like
1140
+ parse_to_dict (region line numbers mapped back to the file).
1141
+
1142
+ The previous parse is CONSUMED: nodes it shares with the result get their
1143
+ spans moved to the new coordinates and its residual tables are re-based —
1144
+ use the returned gp from then on (the libcst incremental had the same
1145
+ contract; the code host always chains)."""
1146
+ origin = gp.get(ORIGIN_KEY)
1147
+ if origin is None or not getattr(origin, "top_stmts", None):
1148
+ return reparse_reusing(gp, new_text)
1149
+ old_text = origin.text
1150
+ if new_text == old_text:
1151
+ return gp
1152
+
1153
+ # ── 1. the changed char range (memcmp-style prefix / suffix), snapped to lines ──
1154
+ pre = _common_prefix(old_text, new_text)
1155
+ suf = _common_suffix(old_text, new_text, pre)
1156
+ old_lo = old_text.rfind("\n", 0, pre) + 1
1157
+ old_hi = old_text.find("\n", len(old_text) - suf)
1158
+ old_hi = len(old_text) if old_hi == -1 else old_hi + 1
1159
+ delta = len(new_text) - len(old_text)
1160
+ top = origin.top_extents()
1161
+ i0 = i1 = None
1162
+ for i, (s0, e0, _k) in enumerate(top):
1163
+ if e0 > old_lo and s0 < max(old_hi, old_lo + 1):
1164
+ if i0 is None:
1165
+ i0 = i
1166
+ i1 = i
1167
+ # Appending AFTER the last statement still belongs to a small region:
1168
+ # include the statement plus the trailing gap. Including the statement
1169
+ # preserves indented body extensions and comments/decorators at its head.
1170
+ tail_edit = old_lo >= top[-1][0] and old_hi >= top[-1][1]
1171
+ if i0 is None and tail_edit:
1172
+ i0 = i1 = len(top) - 1
1173
+ if i0 is None or old_lo < top[0][0]:
1174
+ return reparse_reusing(gp, new_text)
1175
+ if tail_edit:
1176
+ # Semicolon-separated statements share a physical line. Include
1177
+ # all preceding them so the regional parser preserves their columns.
1178
+ line_start = old_text.rfind('\n', 0, top[i0][0]) + 1
1179
+ while i0 > 0 and top[i0 - 1][0] >= line_start:
1180
+ i0 -= 1
1181
+ rs, re_ = top[i0][0], top[i1][1]
1182
+ if tail_edit and i1 == len(top) - 1:
1183
+ re_ = len(old_text)
1184
+ if old_hi > re_ or (re_ - rs) * 2 > len(old_text):
1185
+ return reparse_reusing(gp, new_text)
1186
+ region_old = old_text[rs:re_]
1187
+ region_new = new_text[rs:re_ + delta]
1188
+ if rs > pre or len(old_text) - re_ > suf:
1189
+ return reparse_reusing(gp, new_text)
1190
+ dl = region_new.count("\n") - region_old.count("\n")
1191
+
1192
+ # ── 2. parse the region on its own (column 0, statement boundaries) ──
1193
+ from meltygui.core.runtime.toggles import Toggles
1194
+ frontend = "scan" if Toggles.TextEditor.melty_scanner else "ast"
1195
+ first_line = origin.src.linecol(rs)[0]
1196
+ try:
1197
+ rgp, rorigin = _extract(region_new, frontend=frontend, types=REAL_TYPES, module_header=(rs == 0))
1198
+ except SyntaxError as e:
1199
+ raise SyntaxError(e.msg, (str(getattr(gp, "file_path", None) or "<text>"),
1200
+ (e.lineno or 1) + first_line - 1, e.offset or 0, "")) from None
1201
+
1202
+ # ── 3. root keys: before / region / after, by extent ──
1203
+ before_keys, after_keys, region_keys = [], [], set()
1204
+ for k in gp:
1205
+ if _is_dunder(k):
1206
+ continue
1207
+ item = origin.items.get((k,))
1208
+ if item is None or (item.extent[0] < re_ and item.extent[1] > rs):
1209
+ region_keys.add(k)
1210
+ elif item.extent[1] <= rs:
1211
+ before_keys.append(k)
1212
+ else:
1213
+ after_keys.append(k)
1214
+ kept = set(before_keys) | set(after_keys)
1215
+ rkeys = [k for k in rgp if not _is_dunder(k)]
1216
+ if kept.intersection(rkeys):
1217
+ # The full parser disambiguates all top-level names globally.
1218
+ # A parsed tail cannot safely choose those keys on its own.
1219
+ return reparse_reusing(gp, new_text)
1220
+
1221
+ # ── 4. the new root ──
1222
+ merged = type(gp)(source=new_text)
1223
+ for attr in _ROOT_CARRY_ATTRS:
1224
+ if hasattr(gp, attr):
1225
+ setattr(merged, attr, getattr(gp, attr))
1226
+ for k in before_keys:
1227
+ merged[k] = gp[k]
1228
+ for k in rkeys:
1229
+ v = rgp[k]
1230
+ ov = gp.get(k) if k in region_keys else None
1231
+ if isinstance(ov, dict) and isinstance(v, dict) and _parse_kind(ov) is _parse_kind(v):
1232
+ v = _merge_node(ov, v, (k,), rorigin) # unchanged subtrees keep their identity
1233
+ merged[k] = v
1234
+ for k in after_keys:
1235
+ merged[k] = gp[k]
1236
+ old_ov = gp.get("__overrides__") if isinstance(gp.get("__overrides__"), dict) else {}
1237
+ new_ov = {k: v for k, v in old_ov.items()
1238
+ if (_is_dunder(k) and len(k) > 4 and k[2:-2] in kept) or (not _is_dunder(k) and rs > 0)}
1239
+ if isinstance(rgp.get("__overrides__"), dict):
1240
+ new_ov.update(rgp["__overrides__"])
1241
+ if new_ov:
1242
+ merged["__overrides__"] = new_ov
1243
+ for k, v in gp.items():
1244
+ if _is_dunder(k) and k not in merged and k not in (ORIGIN_KEY, "__cst__", "__overrides__"):
1245
+ merged[k] = v
1246
+
1247
+ # ── 5. the residual, updated IN PLACE: forget the region's entries (start by
1248
+ # walking the OLD region subtrees), re-base the statements after it,
1249
+ # add the region's entries ──
1250
+ body = origin.seqs[origin.default_seq[()]]
1251
+ body_before = [it for it in body.items if it.extent[1] <= rs]
1252
+ body_after = [it for it in body.items if it.extent[0] >= re_]
1253
+ for k in region_keys:
1254
+ if k in gp:
1255
+ _forget_subtree(origin, gp[k], (k,))
1256
+ old_ov = gp.get("__overrides__") if isinstance(gp.get("__overrides__"), dict) else {}
1257
+ for k in old_ov:
1258
+ if _is_dunder(k) and len(k) > 4 and k[2:-2] not in kept:
1259
+ origin.items.pop(("__overrides__", k), None)
1260
+ if rs == 0:
1261
+ origin.items.pop(("__overrides__",), None)
1262
+ if () in origin.loose:
1263
+ origin.loose[()] = [it for it in origin.loose[()]
1264
+ if (it.path[0] in kept) or (it.path[0] == "__overrides__" and (it.path[1:2] or ("",))[0] != ""
1265
+ and it.path[1][2:-2] in kept) or (it.path == ("__overrides__",) and rs > 0)]
1266
+ rl = first_line - 1
1267
+ for b, _e, _k in origin.top_stmts[i1 + 1:]:
1268
+ b.offset += delta
1269
+ b.line += dl
1270
+ for b, _e, _k in rorigin.top_stmts:
1271
+ b.offset += rs
1272
+ b.line += rl
1273
+ origin.items.update(rorigin.items)
1274
+ idmap = {}
1275
+ for sid, sq in rorigin.seqs.items():
1276
+ if sq.owner == ():
1277
+ continue
1278
+ sq.id = origin._next_seq_id
1279
+ origin._next_seq_id += 1
1280
+ origin.seqs[sq.id] = sq
1281
+ idmap[sid] = sq.id
1282
+ for it in sq.items:
1283
+ it.seq = sq.id
1284
+ for path, sid in rorigin.default_seq.items():
1285
+ if path != () and sid in idmap:
1286
+ origin.default_seq[path] = idmap[sid]
1287
+ for path, sids in rorigin.owned.items():
1288
+ if path != ():
1289
+ origin.owned[path] = [idmap[x] for x in sids if x in idmap]
1290
+ for path, its in rorigin.loose.items():
1291
+ if path == ():
1292
+ origin.loose.setdefault((), []).extend(its)
1293
+ else:
1294
+ origin.loose[path] = its
1295
+ rbody = rorigin.seqs[rorigin.default_seq[()]]
1296
+ body.items = body_before + list(rbody.items) + body_after
1297
+ for it in rbody.items:
1298
+ it.seq = body.id
1299
+ origin.top_stmts = origin.top_stmts[:i0] + rorigin.top_stmts + origin.top_stmts[i1 + 1:]
1300
+ origin.text = new_text
1301
+ origin.src = _Src.spliced(origin.src, new_text, rs, re_, delta, rorigin.src)
1302
+
1303
+ # ── 6. spans: nothing to renumber - they hang off the Base cells shifted above ──
1304
+ child_spans = {k: sp for k, sp in (getattr(gp, "_child_spans", None) or {}).items() if k in kept}
1305
+ child_spans.update(getattr(rgp, "_child_spans", None) or {})
1306
+ if child_spans:
1307
+ merged._child_spans = child_spans
1308
+ merged[ORIGIN_KEY] = origin
1309
+ merged.source = new_text
1310
+ return merged
1311
+
1312
+
1313
+ def _common_prefix(a, b):
1314
+ """Length of the common prefix — C-speed slice compares, log(n) of them."""
1315
+ lo, hi = 0, min(len(a), len(b))
1316
+ if hi and a[:hi] == b[:hi]:
1317
+ return hi
1318
+ while lo < hi:
1319
+ mid = (lo + hi + 1) // 2
1320
+ if a[:mid] == b[:mid]:
1321
+ lo = mid
1322
+ else:
1323
+ hi = mid - 1
1324
+ return lo
1325
+
1326
+
1327
+ def _common_suffix(a, b, prefix):
1328
+ """Length of the common suffix past `prefix` (so the two never overlap)."""
1329
+ lo, hi = 0, min(len(a), len(b)) - prefix
1330
+ if hi > 0 and a[-hi:] == b[-hi:]:
1331
+ return hi
1332
+ while lo < hi:
1333
+ mid = (lo + hi + 1) // 2
1334
+ if a[-mid:] == b[-mid:]:
1335
+ lo = mid
1336
+ else:
1337
+ hi = mid - 1
1338
+ return lo
1339
+
1340
+
1341
+ def _forget_subtree(origin, node, path):
1342
+ """Drop every Origin table entry the old region subtree at `path` owns:
1343
+ its node's Items (one per key, elements by index), loose comments /
1344
+ overrides, and the Seqs it owns — O(region), no table scans."""
1345
+ stack = [(path, node)]
1346
+ while stack:
1347
+ p, n = stack.pop()
1348
+ for sid in origin.owned.pop(p, ()):
1349
+ origin.seqs.pop(sid, None)
1350
+ origin.default_seq.pop(p, None)
1351
+ origin.loose.pop(p, None)
1352
+ origin.loose.pop(p + ("__overrides__",), None)
1353
+ if isinstance(n, dict):
1354
+ # Callee provenance is virtual: it has no normal dict key.
1355
+ origin.items.pop(p + ("__callee__",), None)
1356
+ ov = n.get("__overrides__")
1357
+ if isinstance(ov, dict):
1358
+ origin.items.pop(p + ("__overrides__",), None)
1359
+ for k in ov:
1360
+ origin.items.pop(p + ("__overrides__", k), None)
1361
+ for k, v in n.items():
1362
+ if k in (ORIGIN_KEY, "__pos_names__", "__symbol_usages__", "__overrides__"):
1363
+ continue
1364
+ cp = p + (k,)
1365
+ origin.items.pop(cp, None)
1366
+ if isinstance(v, (dict, list, tuple)):
1367
+ stack.append((cp, v))
1368
+ elif isinstance(n, (list, tuple)):
1369
+ for i, v in enumerate(n):
1370
+ cp = p + (i,)
1371
+ origin.items.pop(cp, None)
1372
+ if isinstance(v, (dict, list, tuple)):
1373
+ stack.append((cp, v))
1374
+ origin.items.pop(path, None)
1375
+
1376
+
1377
+ def _merge_node(old, fresh, path, origin):
1378
+ """The node to use in place of `fresh`: `old` itself when the whole subtree
1379
+ is unchanged (coordinates refreshed onto it), else `fresh` with every
1380
+ unchanged child swapped for the old object. Reads `old` only; writes go
1381
+ into `fresh`, which is a plain (unwrapped) parse."""
1382
+ changed = ("__callee__" in old
1383
+ or [k for k in old if not _is_dunder(k)] != [k for k in fresh if not _is_dunder(k)])
1384
+ # __overrides__ / __pos_names__ are part of the node's identity (a new
1385
+ # `# [tint=...]` above a def must not be dropped for an otherwise-equal node).
1386
+ for meta in ("__overrides__", "__pos_names__"):
1387
+ if not values_equal(old.get(meta), fresh.get(meta)):
1388
+ changed = True
1389
+ for k in list(fresh.keys()):
1390
+ if _is_dunder(k):
1391
+ continue
1392
+ v = fresh[k]
1393
+ ov = old.get(k, _MISSING)
1394
+ if ov is _MISSING:
1395
+ changed = True
1396
+ continue
1397
+ item = origin.items.get(path + (k,))
1398
+ if isinstance(ov, dict) and isinstance(v, dict) and _parse_kind(ov) is _parse_kind(v):
1399
+ m = _merge_node(ov, v, path + (k,), origin)
1400
+ if m is not v:
1401
+ fresh[k] = m
1402
+ if m is not ov:
1403
+ changed = True
1404
+ if item is not None:
1405
+ item.orig = m
1406
+ elif not isinstance(v, dict) and _same_kind(ov, v) and values_equal(ov, v):
1407
+ fresh[k] = ov
1408
+ if item is not None:
1409
+ item.orig = ov
1410
+ else:
1411
+ changed = True
1412
+ if changed:
1413
+ # Bookkeeping the fresh parse doesn't produce (`__symbol_usages__`
1414
+ # distributed by the symbol generator) rides along on the new node.
1415
+ for k, v in old.items():
1416
+ if _is_dunder(k) and k not in fresh and k not in ("__cst__", "__callee__"):
1417
+ fresh[k] = v
1418
+ return fresh
1419
+ _copy_node_attrs(old, fresh)
1420
+ return old
1421
+
1422
+
1423
+ def _copy_node_attrs(dst, src):
1424
+ for attr in ("span", "_child_spans", "source", "condition", "target", "iter", "header",
1425
+ "func_name", "def_name"):
1426
+ if hasattr(src, attr):
1427
+ try:
1428
+ setattr(dst, attr, getattr(src, attr))
1429
+ except AttributeError:
1430
+ pass