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.
- meltygui/__init__.py +107 -0
- meltygui/accounts/__init__.py +0 -0
- meltygui/accounts/internet_accounts.py +1355 -0
- meltygui/chat/__init__.py +91 -0
- meltygui/chat/activity.py +75 -0
- meltygui/chat/backends.py +36 -0
- meltygui/chat/chat_interface.py +732 -0
- meltygui/chat/chat_proxy.py +352 -0
- meltygui/chat/codex_proxy.py +592 -0
- meltygui/chat/codex_settings.py +100 -0
- meltygui/chat/codex_transport.py +60 -0
- meltygui/chat/command_parser.py +204 -0
- meltygui/chat/images.py +227 -0
- meltygui/chat/messages.py +417 -0
- meltygui/chat/metadata.py +139 -0
- meltygui/chat/writer_locks.py +64 -0
- meltygui/code/__init__.py +0 -0
- meltygui/code/basic_converters.py +533 -0
- meltygui/code/chain_converters.py +2111 -0
- meltygui/code/code_checks.py +2209 -0
- meltygui/code/core_syntax.py +1430 -0
- meltygui/code/file_converters.py +1933 -0
- meltygui/code/fileref.py +702 -0
- meltygui/code/hotswap_guard.py +144 -0
- meltygui/code/libcst_conversion.py +9724 -0
- meltygui/code/live_instrument.py +392 -0
- meltygui/code/live_view.py +2490 -0
- meltygui/code/melty_scan.py +2684 -0
- meltygui/code/new_codecs.py +1255 -0
- meltygui/code/new_converters.py +3017 -0
- meltygui/code/project_code.py +278 -0
- meltygui/code/source_context.py +63 -0
- meltygui/code/symbol_roster.py +1588 -0
- meltygui/code/syntax_check.py +34 -0
- meltygui/code/syntax_check_worker.py +114 -0
- meltygui/completion/__init__.py +0 -0
- meltygui/completion/fim.py +1232 -0
- meltygui/completion/fim_context.py +481 -0
- meltygui/completion/providers/__init__.py +0 -0
- meltygui/completion/providers/anthropic_oauth.py +446 -0
- meltygui/completion/providers/anthropic_requests.py +69 -0
- meltygui/completion/providers/claude.py +203 -0
- meltygui/completion/providers/claude_usage.py +499 -0
- meltygui/completion/providers/codex_accounts.py +180 -0
- meltygui/completion/providers/copilot.py +617 -0
- meltygui/completion/providers/oauth_popup.py +220 -0
- meltygui/completion/providers/ollama.py +320 -0
- meltygui/completion/providers/profiles.py +23 -0
- meltygui/core/README.md +88 -0
- meltygui/core/__init__.py +1 -0
- meltygui/core/automation/__init__.py +1 -0
- meltygui/core/automation/action_core.py +153 -0
- meltygui/core/automation/collection_action.py +45 -0
- meltygui/core/automation/mcp_eval.py +167 -0
- meltygui/core/automation/mcp_hotswap.py +107 -0
- meltygui/core/automation/mcp_query.py +552 -0
- meltygui/core/automation/mcp_server.py +657 -0
- meltygui/core/automation/orchestration_core.py +2636 -0
- meltygui/core/automation/query_core.py +124 -0
- meltygui/core/automation/search_core.py +26 -0
- meltygui/core/automation/selector_core.py +340 -0
- meltygui/core/automation/value_core.py +1752 -0
- meltygui/core/cache/__init__.py +1 -0
- meltygui/core/cache/cache_diagnostics.py +0 -0
- meltygui/core/cache/invalidation_decoration.py +153 -0
- meltygui/core/cache/invalidation_tracker.py +43 -0
- meltygui/core/cache/tile_cache.py +5968 -0
- meltygui/core/conversion/__init__.py +1 -0
- meltygui/core/conversion/bubbling.py +599 -0
- meltygui/core/conversion/cache_tree.py +188 -0
- meltygui/core/conversion/chain.py +113 -0
- meltygui/core/conversion/converter_register.py +145 -0
- meltygui/core/conversion/data_decoration.py +67 -0
- meltygui/core/conversion/dict_conversion.py +1815 -0
- meltygui/core/conversion/dict_conversion_util.py +177 -0
- meltygui/core/conversion/dynamic_obj.py +89 -0
- meltygui/core/conversion/graph_compare.py +183 -0
- meltygui/core/conversion/load_save_v2.py +1032 -0
- meltygui/core/conversion/missing_saved_class.py +39 -0
- meltygui/core/conversion/path_finder.py +606 -0
- meltygui/core/conversion/render_host.py +1083 -0
- meltygui/core/core_render.py +6537 -0
- meltygui/core/definition_hotswap.py +231 -0
- meltygui/core/diagnostics/__init__.py +1 -0
- meltygui/core/diagnostics/attribute_churn.py +38 -0
- meltygui/core/diagnostics/fps_counter.py +39 -0
- meltygui/core/diagnostics/gpu_frame_timer.py +103 -0
- meltygui/core/diagnostics/inspection_core.py +169 -0
- meltygui/core/diagnostics/monitor_core.py +105 -0
- meltygui/core/diagnostics/notifications.py +706 -0
- meltygui/core/diagnostics/perf_trace.py +281 -0
- meltygui/core/diagnostics/profile_decoration.py +88 -0
- meltygui/core/diagnostics/resize_trace.py +62 -0
- meltygui/core/diagnostics/screenshot_core.py +247 -0
- meltygui/core/diagnostics/session_status.py +98 -0
- meltygui/core/diagnostics/trace_core.py +445 -0
- meltygui/core/files/__init__.py +1 -0
- meltygui/core/files/file_core.py +208 -0
- meltygui/core/files/file_explorer_core.py +104 -0
- meltygui/core/files/file_tree_core.py +198 -0
- meltygui/core/files/file_watch_core.py +43 -0
- meltygui/core/files/import_graph_core.py +43 -0
- meltygui/core/files/metadata_core.py +51 -0
- meltygui/core/graphics/__init__.py +1 -0
- meltygui/core/graphics/cuda_context_core.py +166 -0
- meltygui/core/graphics/cuda_interop_core.py +136 -0
- meltygui/core/graphics/cuda_kernel_core.py +91 -0
- meltygui/core/graphics/framebuffer_recorder.py +337 -0
- meltygui/core/graphics/gl_state.py +658 -0
- meltygui/core/graphics/lut_core.py +52 -0
- meltygui/core/graphics/overlay_renderer.py +984 -0
- meltygui/core/graphics/scene_target.py +180 -0
- meltygui/core/graphics/screenshot.py +439 -0
- meltygui/core/graphics/shader_func.py +478 -0
- meltygui/core/graphics/tensor_core.py +45 -0
- meltygui/core/graphics/text_texture.py +329 -0
- meltygui/core/graphics/wayland_color.py +635 -0
- meltygui/core/input/__init__.py +1 -0
- meltygui/core/input/collision.py +165 -0
- meltygui/core/input/drag_drop_core.py +1525 -0
- meltygui/core/input/hypr_left_drag.py +323 -0
- meltygui/core/input/input_core.py +245 -0
- meltygui/core/input/input_handler.py +1101 -0
- meltygui/core/input/mouse_cursor.py +355 -0
- meltygui/core/input/pynput_backend.py +1054 -0
- meltygui/core/input/space_mouse.py +338 -0
- meltygui/core/input/touchpad_backend.py +393 -0
- meltygui/core/input/view_selection.py +177 -0
- meltygui/core/layout/__init__.py +1 -0
- meltygui/core/layout/column_core.py +2153 -0
- meltygui/core/layout/cursor_core.py +161 -0
- meltygui/core/layout/dropdown_core.py +278 -0
- meltygui/core/layout/edge_constraints.py +155 -0
- meltygui/core/layout/grid_core.py +117 -0
- meltygui/core/layout/header_core.py +23 -0
- meltygui/core/layout/header_runtime.py +67 -0
- meltygui/core/layout/layout_core.py +87 -0
- meltygui/core/layout/tile_manager_core.py +591 -0
- meltygui/core/melty.py +6726 -0
- meltygui/core/module_map.json +896 -0
- meltygui/core/module_names.py +19 -0
- meltygui/core/rendering/__init__.py +1 -0
- meltygui/core/rendering/core_decoration.py +431 -0
- meltygui/core/rendering/core_render_helpers.py +328 -0
- meltygui/core/rendering/func_metadata.py +398 -0
- meltygui/core/rendering/mode.py +818 -0
- meltygui/core/rendering/mode_defaults.py +41 -0
- meltygui/core/rendering/modes.py +136 -0
- meltygui/core/rendering/parameter_core.py +1665 -0
- meltygui/core/rendering/render_dispatch.py +1891 -0
- meltygui/core/rendering/render_funcs.py +273 -0
- meltygui/core/rendering/shaped.py +312 -0
- meltygui/core/rendering/window_decoration.py +25 -0
- meltygui/core/runtime/__init__.py +1 -0
- meltygui/core/runtime/app.py +735 -0
- meltygui/core/runtime/app_session.py +140 -0
- meltygui/core/runtime/background.py +564 -0
- meltygui/core/runtime/extensions.py +53 -0
- meltygui/core/runtime/gc_manager.py +1163 -0
- meltygui/core/runtime/lifecycle.py +21 -0
- meltygui/core/runtime/paths.py +27 -0
- meltygui/core/runtime/settings.py +14 -0
- meltygui/core/runtime/singleton.py +16 -0
- meltygui/core/runtime/thread_safe_bool.py +24 -0
- meltygui/core/runtime/thread_signal.py +30 -0
- meltygui/core/runtime/toggles.py +3115 -0
- meltygui/core/services/__init__.py +1 -0
- meltygui/core/services/account_core.py +10 -0
- meltygui/core/services/chat_core.py +19 -0
- meltygui/core/services/claude_terminal_core.py +346 -0
- meltygui/core/services/terminal_core.py +458 -0
- meltygui/core/services/terminal_runtime.py +78 -0
- meltygui/core/styling/__init__.py +1 -0
- meltygui/core/styling/color_core.py +46 -0
- meltygui/core/styling/fonts.py +639 -0
- meltygui/core/styling/global_style.py +338 -0
- meltygui/core/styling/style.py +198 -0
- meltygui/core/styling/style_core.py +522 -0
- meltygui/core/styling/warm_start.py +149 -0
- meltygui/core/windowing/__init__.py +1 -0
- meltygui/core/windowing/backends/PYIMGUI_LICENSE +28 -0
- meltygui/core/windowing/backends/__init__.py +1 -0
- meltygui/core/windowing/backends/imgui_renderer.py +138 -0
- meltygui/core/windowing/backends/native_wayland.py +850 -0
- meltygui/core/windowing/backends/protocols/xdg-decoration-unstable-v1.xml +156 -0
- meltygui/core/windowing/backends/protocols/xdg-shell.xml +1420 -0
- meltygui/core/windowing/backends/wayland_protocol.py +101 -0
- meltygui/core/windowing/dock_core.py +182 -0
- meltygui/core/windowing/frame_geometry.py +42 -0
- meltygui/core/windowing/geometry_feed.py +864 -0
- meltygui/core/windowing/glfw_utils.py +1343 -0
- meltygui/core/windowing/os_frame.py +1552 -0
- meltygui/core/windowing/surface.py +643 -0
- meltygui/core/windowing/titlebar.py +1560 -0
- meltygui/core/windowing/titlebar_buttons.py +281 -0
- meltygui/core/windowing/wayland_move.py +932 -0
- meltygui/core/windowing/window_api.py +62 -0
- meltygui/core/windowing/window_constants.py +339 -0
- meltygui/core/windowing/window_visibility.py +162 -0
- meltygui/debug/__init__.py +0 -0
- meltygui/debug/app_view_utils.py +9 -0
- meltygui/editor/__init__.py +0 -0
- meltygui/editor/bash_syntax.py +30 -0
- meltygui/editor/code_line_fast.py +152 -0
- meltygui/editor/diff.py +139 -0
- meltygui/editor/external_changes.py +159 -0
- meltygui/editor/file_header.py +41 -0
- meltygui/editor/live_usage.py +169 -0
- meltygui/editor/live_view_views.py +1803 -0
- meltygui/editor/pending_save.py +1351 -0
- meltygui/editor/roster_tints.py +547 -0
- meltygui/editor/source_preview.py +16 -0
- meltygui/editor/source_tools.py +10 -0
- meltygui/editor/source_ui.py +70 -0
- meltygui/editor/spell_check.py +77 -0
- meltygui/editor/text_editor.py +9076 -0
- meltygui/editor/usage_picker.py +538 -0
- meltygui/events/__init__.py +0 -0
- meltygui/events/example.py +118 -0
- meltygui/examples/__init__.py +0 -0
- meltygui/examples/columns_demo.py +33 -0
- meltygui/examples/columns_window_demo.py +60 -0
- meltygui/examples/context_menu_demo.py +24 -0
- meltygui/examples/context_menu_window_demo.py +49 -0
- meltygui/examples/gui_playground.py +127 -0
- meltygui/examples/live_view_playground.py +256 -0
- meltygui/examples/lora.py +43 -0
- meltygui/examples/lora_data.py +59 -0
- meltygui/examples/lora_policies.py +67 -0
- meltygui/examples/mode_demo.py +49 -0
- meltygui/examples/modifies_demo.py +119 -0
- meltygui/examples/scalar_policies.py +60 -0
- meltygui/examples/style_layouts.py +149 -0
- meltygui/examples/tile_manager_demo.py +74 -0
- meltygui/examples/tint_demo.py +160 -0
- meltygui/examples/tint_functions.py +56 -0
- meltygui/examples/trace_demo.py +88 -0
- meltygui/examples/two_windows.py +36 -0
- meltygui/files/__init__.py +0 -0
- meltygui/files/fast_file_explorer.py +439 -0
- meltygui/gnome_extension/lsd-window-geometry@latent-descent/extension.js +237 -0
- meltygui/gnome_extension/lsd-window-geometry@latent-descent/lsd-window-geometry@latent-descent.iml +9 -0
- meltygui/gnome_extension/lsd-window-geometry@latent-descent/metadata.json +7 -0
- meltygui/graphics/__init__.py +6 -0
- meltygui/graphics/base.py +85 -0
- meltygui/graphics/examples.py +507 -0
- meltygui/graphics/executor.py +520 -0
- meltygui/graphics/filter.py +667 -0
- meltygui/graphics/filter.pyi +856 -0
- meltygui/graphics/generate_stubs.py +22 -0
- meltygui/graphics/registry.py +203 -0
- meltygui/graphics/shader_compiler.py +155 -0
- meltygui/graphics/shaders.py +1302 -0
- meltygui/graphics/stub_generator.py +250 -0
- meltygui/graphics/texture_manager.py +170 -0
- meltygui/graphics/texture_min_max.py +295 -0
- meltygui/hdr_color.py +757 -0
- meltygui/image_load.py +308 -0
- meltygui/model/__init__.py +1 -0
- meltygui/model/account_model.py +142 -0
- meltygui/model/camera_model.py +159 -0
- meltygui/model/chat_model.py +86 -0
- meltygui/model/code_model.py +62 -0
- meltygui/model/code_proxy_model.py +876 -0
- meltygui/model/collection_model.py +41 -0
- meltygui/model/color_model.py +127 -0
- meltygui/model/cuda_tensor_model.py +36 -0
- meltygui/model/cuda_texture_model.py +149 -0
- meltygui/model/dropdown_model.py +137 -0
- meltygui/model/file_metadata_model.py +73 -0
- meltygui/model/file_model.py +289 -0
- meltygui/model/format_model.py +390 -0
- meltygui/model/graph_model.py +98 -0
- meltygui/model/icon_model.py +1024 -0
- meltygui/model/import_graph_model.py +468 -0
- meltygui/model/layout_model.py +15 -0
- meltygui/model/lut_model.py +266 -0
- meltygui/model/search_model.py +173 -0
- meltygui/model/tensor_model.py +402 -0
- meltygui/model/terminal_model.py +329 -0
- meltygui/model/texture_model.py +146 -0
- meltygui/model/tile_model.py +24 -0
- meltygui/model/trace_model.py +198 -0
- meltygui/model/trace_report_model.py +146 -0
- meltygui/models/__init__.py +0 -0
- meltygui/models/file_meta.py +613 -0
- meltygui/models/function_console.py +184 -0
- meltygui/models/orchestration.py +48 -0
- meltygui/pbr.py +1576 -0
- meltygui/png_unfilter.c +49 -0
- meltygui/resources/JetBrainsMono-Regular.ttf +0 -0
- meltygui/resources/THIRD_PARTY_NOTICES.md +21 -0
- meltygui/resources/dejavu/DejaVuSans-Bold.ttf +0 -0
- meltygui/resources/dejavu/DejaVuSans-ExtraLight.ttf +0 -0
- meltygui/resources/dejavu/DejaVuSans.ttf +0 -0
- meltygui/resources/dejavu/LICENSE.txt +78 -0
- meltygui/resources/fontawesome-LICENSE.txt +121 -0
- meltygui/resources/fontawesome-webfont.ttf +0 -0
- meltygui/resources/hdri/studio_small_09_1k.hdr +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-Bold.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-ExtraBold.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-ExtraLight.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-Light.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-Medium.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-SemiBold.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-Thin.ttf +0 -0
- meltygui/resources/jetbrains-weights/OFL.txt +93 -0
- meltygui/resources/jetbrains-weights/README.md +2 -0
- meltygui/state/__init__.py +0 -0
- meltygui/state/account_state.py +22 -0
- meltygui/state/animation_state.py +97 -0
- meltygui/state/annotation_state.py +22 -0
- meltygui/state/chat_state.py +36 -0
- meltygui/state/code_state.py +10 -0
- meltygui/state/core_enums.py +59 -0
- meltygui/state/core_markers.py +115 -0
- meltygui/state/core_undo.py +1075 -0
- meltygui/state/file_state.py +75 -0
- meltygui/state/graph_state.py +26 -0
- meltygui/state/inspection_state.py +47 -0
- meltygui/state/menu_state.py +10 -0
- meltygui/state/model_enums.py +11 -0
- meltygui/state/new_core_model.py +2394 -0
- meltygui/state/orchestration_state.py +14 -0
- meltygui/state/query_state.py +24 -0
- meltygui/state/tensor_state.py +10 -0
- meltygui/state/terminal_state.py +23 -0
- meltygui/state/trace_state.py +32 -0
- meltygui/state/voxel_state.py +12 -0
- meltygui/text_index.py +816 -0
- meltygui/utils/__init__.py +0 -0
- meltygui/utils/jump_to_code.py +344 -0
- meltygui/utils/pkl_inspect.py +90 -0
- meltygui/utils/render_utils.py +1419 -0
- meltygui/view/__init__.py +1 -0
- meltygui/view/account_view.py +524 -0
- meltygui/view/action_view.py +73 -0
- meltygui/view/chat_decoration_view.py +112 -0
- meltygui/view/chat_view.py +1703 -0
- meltygui/view/code_view.py +2677 -0
- meltygui/view/collection_view.py +1185 -0
- meltygui/view/color_view.py +824 -0
- meltygui/view/control_view.py +559 -0
- meltygui/view/decoration_view.py +439 -0
- meltygui/view/diagnostic_view.py +177 -0
- meltygui/view/dropdown_view.py +1082 -0
- meltygui/view/file_view.py +1599 -0
- meltygui/view/graph_cuda_view.py +105 -0
- meltygui/view/graph_view.py +882 -0
- meltygui/view/header_view.py +848 -0
- meltygui/view/input_view.py +238 -0
- meltygui/view/inspection_view.py +1783 -0
- meltygui/view/layout_view.py +596 -0
- meltygui/view/lut_view.py +45 -0
- meltygui/view/menu_view.py +212 -0
- meltygui/view/orchestration_view.py +658 -0
- meltygui/view/query_view.py +118 -0
- meltygui/view/search_view.py +342 -0
- meltygui/view/tab_view.py +258 -0
- meltygui/view/tensor_view.py +430 -0
- meltygui/view/terminal_view.py +355 -0
- meltygui/view/text_view.py +8031 -0
- meltygui/view/texture_view.py +480 -0
- meltygui/view/tile_view.py +38 -0
- meltygui/view/trace_view.py +923 -0
- meltygui/view/voxel_cuda_view.py +824 -0
- meltygui/view/voxel_view.py +1860 -0
- meltygui/view/window_view.py +111 -0
- meltygui-0.1.0.dist-info/METADATA +143 -0
- meltygui-0.1.0.dist-info/RECORD +372 -0
- meltygui-0.1.0.dist-info/WHEEL +4 -0
- 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
|