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