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
meltygui/text_index.py
ADDED
|
@@ -0,0 +1,816 @@
|
|
|
1
|
+
"""Mmapped trigram full-text index — the engine behind global search's Text tab.
|
|
2
|
+
|
|
3
|
+
Design (the Google-Code-Search / ripgrep-index lineage), so search cost stays
|
|
4
|
+
O(results) instead of O(corpus):
|
|
5
|
+
|
|
6
|
+
segment — an immutable on-disk index file, mmapped read-only: sorted uint32
|
|
7
|
+
trigram keys + fixed-width uint32 postings of file ids. A query's
|
|
8
|
+
trigrams intersect their posting lists down to a small candidate
|
|
9
|
+
set; only candidates are read and verified.
|
|
10
|
+
overlay — files whose CURRENT text may disagree with the segment (unsaved
|
|
11
|
+
pending edits, external disk changes, brand-new files). Kept as a
|
|
12
|
+
dirty SET, not a text copy: dirty files are linearly scanned at
|
|
13
|
+
query time with pending-aware text. The set stays small between
|
|
14
|
+
rebuilds; growing past _REBUILD_DIRTY triggers a background
|
|
15
|
+
rebuild that folds the overlay back into a fresh segment.
|
|
16
|
+
|
|
17
|
+
Invalidation is content-free: the per-file signature is
|
|
18
|
+
(st_mtime_ns, st_size, pending_gen) — disk edits move the stat pair, in-app
|
|
19
|
+
edits bump PendingSave._pending_gen. Signatures are compared lazily on a
|
|
20
|
+
short TTL when a search actually runs; nothing walks or stats while search
|
|
21
|
+
is idle.
|
|
22
|
+
|
|
23
|
+
Pending edits are the source of truth: both indexing and match verification
|
|
24
|
+
read through PendingSave.current_file_text (disk with every unsaved span
|
|
25
|
+
edit spliced in), so hits reflect what the editor shows and hit line numbers
|
|
26
|
+
are in PENDING coordinates — the same coordinates the editor buffer uses, so
|
|
27
|
+
jumps land where the match is.
|
|
28
|
+
|
|
29
|
+
Restart-in-place: live state (segment mmap, dirty set, locks) is a plain
|
|
30
|
+
dict adopted through sys — shared across re-execs and module hotswaps. A
|
|
31
|
+
full process restart just re-mmaps the segment file; only the staleness
|
|
32
|
+
sweep re-runs cold.
|
|
33
|
+
|
|
34
|
+
Scale notes: search scales to multi-GB corpora (posting intersection +
|
|
35
|
+
candidate-only reads). The BUILD is the v1 shortcut — one in-memory numpy
|
|
36
|
+
sort of all (trigram, file) pairs — fine to roughly a GB of text; beyond
|
|
37
|
+
that it should shard into merged sub-segments (the search path would not
|
|
38
|
+
change). Postings are fixed uint32 (no varint) — ~4x larger on disk than
|
|
39
|
+
delta-varint, but mmap only pages in what queries touch.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
import hashlib
|
|
43
|
+
import mmap
|
|
44
|
+
import os
|
|
45
|
+
import pickle
|
|
46
|
+
import struct
|
|
47
|
+
import sys
|
|
48
|
+
import threading
|
|
49
|
+
import time
|
|
50
|
+
import traceback
|
|
51
|
+
from pathlib import Path
|
|
52
|
+
|
|
53
|
+
import numpy as np
|
|
54
|
+
|
|
55
|
+
_MAGIC = b"LSDTGI4\n" # v2: symbol tables; v3: line tints; v4: raw
|
|
56
|
+
# ( (stripped) file/sigils; search rows render
|
|
57
|
+
# through draw_text, whose usage syntaxes map by
|
|
58
|
+
# file path, so buffers must keep their indent
|
|
59
|
+
# magic, n_len, meta_len, n_tri, keys_off, offs_off, post_off
|
|
60
|
+
_HDR = struct.Struct("<8s6Q")
|
|
61
|
+
|
|
62
|
+
_TEXT_EXTS = {
|
|
63
|
+
".py", ".pyi", ".md", ".txt", ".rst", ".ini", ".cfg", ".toml", ".json",
|
|
64
|
+
".yaml", ".yml", ".sh", ".glsl", ".frag", ".vert", ".comp", ".c", ".h",
|
|
65
|
+
".cpp", ".hpp", ".cu", ".cuh", ".js", ".ts", ".css", ".html", ".xml",
|
|
66
|
+
}
|
|
67
|
+
_SKIP_DIRS = {"__pycache__", ".git", ".hg", ".idea", ".vscode", "node_modules",
|
|
68
|
+
".venv", "venv", ".mypy_cache", ".pytest_cache"}
|
|
69
|
+
_MAX_FILE_BYTES = 8 << 20 # bigger than this → skipped (generated blobs)
|
|
70
|
+
_STAT_TTL = 5.0 # seconds between staleness stat sweeps
|
|
71
|
+
_WALK_TTL = 30.0 # seconds between new-file directory walks
|
|
72
|
+
_REBUILD_DIRTY = 64 # overlay size that triggers a background rebuild
|
|
73
|
+
_PER_FILE_CAP = 5 # max content hits reported per file
|
|
74
|
+
_CAND_CAP = 4000 # max segment candidates verified per query
|
|
75
|
+
_FILE_HIT_CAP = 20 # max FILE-NAME hits per query
|
|
76
|
+
_SYMBOL_HIT_CAP = 40 # max symbol-content hits per query
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ── Roots and paths ───────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
def _search_root() -> str:
|
|
82
|
+
"""The same src root the Files tab's labels are relative to."""
|
|
83
|
+
from meltygui.code.libcst_conversion import _SRC_PREFIX
|
|
84
|
+
return str(Path(_SRC_PREFIX))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _segment_file(root: str) -> Path:
|
|
88
|
+
d = Path.home() / ".lsd" / "text_index"
|
|
89
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
return d / (hashlib.sha1(root.encode()).hexdigest()[:16] + ".tgi")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _under(path: str, root: str) -> bool:
|
|
94
|
+
"""`path` is `root` or inside it — a DIRECTORY prefix. A bare
|
|
95
|
+
str.startswith put /a/bc/x.py under root /a/b once sibling projects
|
|
96
|
+
were open side by side."""
|
|
97
|
+
return path == root or path.startswith(root.rstrip(os.sep) + os.sep)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _resolve_root(root) -> str:
|
|
101
|
+
"""The index root as a str: the caller's `root` (a meltygui app's project),
|
|
102
|
+
else the src root. Every index entry point takes an optional root so a
|
|
103
|
+
standalone app searches ITS files, not the framework checkout."""
|
|
104
|
+
return str(root) if root else _search_root()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def warm(root=None):
|
|
108
|
+
"""Build (or load) the segment for `root` ahead of the first search, so
|
|
109
|
+
the first keystroke doesn't pay the walk. Call from a background thread."""
|
|
110
|
+
root = _resolve_root(root)
|
|
111
|
+
st = _state(root)
|
|
112
|
+
_ensure_segment(st, root)
|
|
113
|
+
_sweep(st, root)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def symbol_tables(root=None):
|
|
117
|
+
"""The class/def tables of every indexed .py file under `root`, pending
|
|
118
|
+
edits included: ``(key, [(rel, symbols)])`` with `symbols` in the
|
|
119
|
+
_extract_symbols shape ``(name, line, indent, kind, tint, end, sig)`` and
|
|
120
|
+
every indexed file listed (non-.py files with an empty table). `key` is
|
|
121
|
+
a cheap identity for memoizing anything derived from the tables — it
|
|
122
|
+
changes when the segment is rebuilt, a file appears / goes stale, or an
|
|
123
|
+
edit is queued. Overlay (dirty / new) files are extracted live and
|
|
124
|
+
shadow their segment entries. Builds the index on first use: call from
|
|
125
|
+
a background thread (the global-search worker does)."""
|
|
126
|
+
root = _resolve_root(root)
|
|
127
|
+
st = _state(root)
|
|
128
|
+
_ensure_segment(st, root)
|
|
129
|
+
_sweep(st, root)
|
|
130
|
+
_maybe_rebuild(st, root)
|
|
131
|
+
seg = st["seg"]
|
|
132
|
+
overlay = sorted(st["dirty"] | st["extra"])
|
|
133
|
+
gens = _pending_gens()
|
|
134
|
+
overlay_sigs = []
|
|
135
|
+
for ap in overlay:
|
|
136
|
+
try:
|
|
137
|
+
stat = os.stat(ap)
|
|
138
|
+
sig = (stat.st_mtime_ns, stat.st_size)
|
|
139
|
+
except OSError:
|
|
140
|
+
sig = None
|
|
141
|
+
overlay_sigs.append((ap, sig, gens.get(ap, 0)))
|
|
142
|
+
key = (root, id(seg), tuple(overlay_sigs))
|
|
143
|
+
memo = st.get("symbol_tables_memo")
|
|
144
|
+
if memo is not None and memo[0] == key:
|
|
145
|
+
return memo
|
|
146
|
+
out = []
|
|
147
|
+
seen = set()
|
|
148
|
+
for ap, disk_sig, pending_gen in overlay_sigs:
|
|
149
|
+
rel = os.path.relpath(ap, root)
|
|
150
|
+
seen.add(rel)
|
|
151
|
+
if disk_sig is None and not pending_gen:
|
|
152
|
+
continue
|
|
153
|
+
text = _current_text(ap)
|
|
154
|
+
out.append((rel, _extract_symbols(text, rel.endswith(".py")) if text else []))
|
|
155
|
+
if seg is not None:
|
|
156
|
+
for rel, syms in zip(seg.paths, seg.symbols):
|
|
157
|
+
if rel not in seen:
|
|
158
|
+
out.append((rel, syms))
|
|
159
|
+
out.sort(key=lambda t: t[0])
|
|
160
|
+
st["symbol_tables_memo"] = (key, out)
|
|
161
|
+
return key, out
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ── Pending-aware reads (lazy imports: keep this module standalone-testable) ──
|
|
165
|
+
|
|
166
|
+
def _pending_gens() -> dict:
|
|
167
|
+
"""{resolved abs path str: summed pending generation} — tiny (only files
|
|
168
|
+
with queued edits). Resolved here because PendingSave keys by the Address's
|
|
169
|
+
own Path object, which may be spelled differently."""
|
|
170
|
+
try:
|
|
171
|
+
from meltygui.editor.pending_save import PendingSave
|
|
172
|
+
except ImportError:
|
|
173
|
+
return {}
|
|
174
|
+
out = {}
|
|
175
|
+
for p, g in list(PendingSave._pending_gen.items()):
|
|
176
|
+
try:
|
|
177
|
+
out[str(Path(p).resolve())] = out.get(str(Path(p).resolve()), 0) + g
|
|
178
|
+
except OSError:
|
|
179
|
+
continue
|
|
180
|
+
return out
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _current_text(abs_path: str):
|
|
184
|
+
"""The file as the app sees it: disk with unsaved span edits spliced in.
|
|
185
|
+
Falls back to a plain disk read outside the app (tests)."""
|
|
186
|
+
try:
|
|
187
|
+
from meltygui.editor.pending_save import PendingSave
|
|
188
|
+
return PendingSave.current_file_text(Path(abs_path))
|
|
189
|
+
except ImportError:
|
|
190
|
+
try:
|
|
191
|
+
return Path(abs_path).read_text(errors="replace")
|
|
192
|
+
except OSError:
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ── Symbol extraction (names + definition tints) ──────────────────────────────
|
|
197
|
+
# Per .py file the index also carries a class/def table: (name, 1-based
|
|
198
|
+
# line, indent, kind, tint). Tints come from the editor's own definition-tint
|
|
199
|
+
# resolver (_scan_def_tint_lines - style kwarg / # [tint=...] comment /
|
|
200
|
+
# class-body var), so a search row is coloured exactly like the definition's
|
|
201
|
+
# block wash in the editor - Melty's colour coding is first-class in search:
|
|
202
|
+
# symbol hits carry their OWN tint, else hits the tint of the ENCLOSING
|
|
203
|
+
# block (indent-stack walk in _enclosing_tint).
|
|
204
|
+
|
|
205
|
+
import re as _re
|
|
206
|
+
|
|
207
|
+
_DEF_RE = _re.compile(r"^(\s*)(?:async\s+)?(class|def)\s+([A-Za-z_]\w*)")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _scan_tint(lines, line_no, name):
|
|
211
|
+
"""Explicit tint of the definition at 1-based line_no, or None. Uses the
|
|
212
|
+
editor's resolver in-app; standalone (tests) falls back to None."""
|
|
213
|
+
try:
|
|
214
|
+
from meltygui.editor.text_editor import _scan_def_tint_lines
|
|
215
|
+
except ImportError:
|
|
216
|
+
return None
|
|
217
|
+
try:
|
|
218
|
+
res = _scan_def_tint_lines(lines, line_no, name)
|
|
219
|
+
return tuple(res[0][:3]) if res else None
|
|
220
|
+
except Exception:
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _extract_symbols(text: str, is_py: bool) -> list:
|
|
225
|
+
"""[(name, line, indent, kind, tint, end)] for every class/def in a .py
|
|
226
|
+
buffer, in line order — line/end are a 1-based INCLUSIVE block span (end =
|
|
227
|
+
last line before the dedent, same block the editor's tint wash covers).
|
|
228
|
+
One pass: any code line at indent d closes the open defs at indent >= d.
|
|
229
|
+
Comment lines never close a block (a col-0 comment inside a body is
|
|
230
|
+
common); blank lines are skipped the same way. Non-.py files → []."""
|
|
231
|
+
if not is_py:
|
|
232
|
+
return []
|
|
233
|
+
lines = text.split("\n")
|
|
234
|
+
out, open_ix = [], []
|
|
235
|
+
for i, ln in enumerate(lines):
|
|
236
|
+
s = ln.strip()
|
|
237
|
+
if not s or s.startswith("#"):
|
|
238
|
+
continue
|
|
239
|
+
indent = len(ln) - len(ln.lstrip())
|
|
240
|
+
while open_ix and out[open_ix[-1]][2] >= indent:
|
|
241
|
+
out[open_ix.pop()][5] = i # 1-based inclusive: line i+1 is
|
|
242
|
+
m = _DEF_RE.match(ln) # the first OUTSIDE the block
|
|
243
|
+
if m is not None:
|
|
244
|
+
out.append([m.group(3), i + 1, indent, m.group(2),
|
|
245
|
+
_scan_tint(lines, i + 1, m.group(3)), len(lines),
|
|
246
|
+
ln.rstrip()[:160]]) # sig: the entire def line (widt
|
|
247
|
+
open_ix.append(len(out) - 1) # kept for column-faithful display)
|
|
248
|
+
return [tuple(x) for x in out]
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _parse_comment_tint(comment):
|
|
252
|
+
"""Tint tuple from a `# [tint=(...)]` override comment, or None. Uses the
|
|
253
|
+
app's canonical override parser when importable; a literal-eval fallback
|
|
254
|
+
keeps the module standalone (tests)."""
|
|
255
|
+
try:
|
|
256
|
+
from meltygui.code.libcst_conversion import _parse_override_comment
|
|
257
|
+
parsed = _parse_override_comment(comment)
|
|
258
|
+
t = parsed.get("tint") if parsed else None
|
|
259
|
+
except ImportError:
|
|
260
|
+
m = _re.search(r"tint\s*=\s*(\([^)]*\))", comment)
|
|
261
|
+
if m is None:
|
|
262
|
+
return None
|
|
263
|
+
import ast
|
|
264
|
+
try:
|
|
265
|
+
t = ast.literal_eval(m.group(1))
|
|
266
|
+
except (ValueError, SyntaxError):
|
|
267
|
+
return None
|
|
268
|
+
if (isinstance(t, (tuple, list)) and 3 <= len(t) <= 4
|
|
269
|
+
and all(isinstance(c, (int, float)) for c in t)):
|
|
270
|
+
return tuple(t[:3])
|
|
271
|
+
return None
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _extract_line_tints(text: str, is_py: bool) -> list:
|
|
275
|
+
"""[(start, end, tint)] 1-based inclusive spans for `# [tint=(...)]`
|
|
276
|
+
override comments — the editor's per-LINE tint wash, which mostly
|
|
277
|
+
annotates assignments (Toggles settings, class attrs), not defs. A
|
|
278
|
+
standalone comment washes itself through the next code line (skipping
|
|
279
|
+
blanks and further comments — decorator runs get the wash too, harmless);
|
|
280
|
+
an inline trailing comment washes its own line. Def-attached comments are
|
|
281
|
+
ALSO resolved by _scan_tint for the symbol table; here they just wash the
|
|
282
|
+
comment/def lines themselves, matching the editor."""
|
|
283
|
+
if not is_py:
|
|
284
|
+
return []
|
|
285
|
+
out = []
|
|
286
|
+
lines = text.split("\n")
|
|
287
|
+
i, n = 0, len(lines)
|
|
288
|
+
while i < n:
|
|
289
|
+
ln = lines[i]
|
|
290
|
+
s = ln.strip()
|
|
291
|
+
if s.startswith("#") and "[" in s:
|
|
292
|
+
tint = _parse_comment_tint(s)
|
|
293
|
+
if tint is not None:
|
|
294
|
+
j = i + 1
|
|
295
|
+
while j < n and (not lines[j].strip()
|
|
296
|
+
or lines[j].strip().startswith("#")):
|
|
297
|
+
j += 1
|
|
298
|
+
out.append((i + 1, j + 1 if j < n else i + 1, tint))
|
|
299
|
+
i = j + 1
|
|
300
|
+
continue
|
|
301
|
+
elif "#" in ln and "[" in ln:
|
|
302
|
+
m = _re.search(r"#.*$", ln)
|
|
303
|
+
tint = _parse_comment_tint(m.group(0)) if m else None
|
|
304
|
+
if tint is not None:
|
|
305
|
+
out.append((i + 1, i + 1, tint))
|
|
306
|
+
i += 1
|
|
307
|
+
return out
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _line_tint(line_tints, hit_line):
|
|
311
|
+
"""Tint of the override-comment span containing hit_line, or None.
|
|
312
|
+
Spans are line-ordered; the first containing span wins (they don't
|
|
313
|
+
meaningfully nest)."""
|
|
314
|
+
for s, e, t in line_tints:
|
|
315
|
+
if s > hit_line:
|
|
316
|
+
break
|
|
317
|
+
if hit_line <= e:
|
|
318
|
+
return t
|
|
319
|
+
return None
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _enclosing_tint(symbols, hit_line):
|
|
323
|
+
"""Tint of the innermost tinted definition whose block span contains
|
|
324
|
+
`hit_line` — the block wash the hit renders inside in the editor. Later
|
|
325
|
+
matching symbols are deeper (siblings are excluded by their end), so the
|
|
326
|
+
last match wins. None when no enclosing definition declares a tint."""
|
|
327
|
+
tint = None
|
|
328
|
+
for _name, ln, _indent, _kind, t, end, _sig in symbols:
|
|
329
|
+
if ln > hit_line:
|
|
330
|
+
break
|
|
331
|
+
if hit_line <= end and t is not None:
|
|
332
|
+
tint = t
|
|
333
|
+
return tint
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
# ── Trigram primitives ────────────────────────────────────────────────────────
|
|
337
|
+
|
|
338
|
+
def _trigrams(lowered: bytes) -> np.ndarray:
|
|
339
|
+
"""Sorted unique uint32 trigram keys of a lowercased utf-8 buffer
|
|
340
|
+
(b0<<16 | b1<<8 | b2 — always < 2^24)."""
|
|
341
|
+
a = np.frombuffer(lowered, np.uint8)
|
|
342
|
+
if a.size < 3:
|
|
343
|
+
return np.empty(0, np.uint32)
|
|
344
|
+
k = ((a[:-2].astype(np.uint32) << 16)
|
|
345
|
+
| (a[1:-1].astype(np.uint32) << 8)
|
|
346
|
+
| a[2:])
|
|
347
|
+
return np.unique(k)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _lower_bytes(text: str) -> bytes:
|
|
351
|
+
# str.lower before encode so case folding is unicode-consistent with the
|
|
352
|
+
# query's str lowering (bytes.lower is ASCII-only).
|
|
353
|
+
return text.lower().encode("utf-8", "replace")
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
# ── Segment: the immutable mmapped index file ─────────────────────────────────
|
|
357
|
+
|
|
358
|
+
class _Segment:
|
|
359
|
+
"""Read-only view over one index file. Numeric sections are numpy views
|
|
360
|
+
straight into the mmap (nothing copied); only the meta blob (paths, sigs)
|
|
361
|
+
loads into RAM."""
|
|
362
|
+
|
|
363
|
+
def __init__(self, path: Path):
|
|
364
|
+
with open(path, "rb") as f:
|
|
365
|
+
self._mm = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)
|
|
366
|
+
magic, meta_off, meta_len, n_tri, keys_off, offs_off, post_off = \
|
|
367
|
+
_HDR.unpack_from(self._mm, 0)
|
|
368
|
+
if magic != _MAGIC:
|
|
369
|
+
raise ValueError(f"bad index magic in {path}")
|
|
370
|
+
meta = pickle.loads(self._mm[meta_off:meta_off + meta_len])
|
|
371
|
+
self.root = meta["root"]
|
|
372
|
+
self.paths = meta["paths"] # rel path strs, id = path index
|
|
373
|
+
self.sigs = meta["sigs"] # (mtime_ns, size, pending_gen)
|
|
374
|
+
self.symbols = meta["symbols"] # per-file [(name, line, indent,
|
|
375
|
+
# kind, tint, end, sig)]
|
|
376
|
+
self.line_tints = meta["line_tints"] # per-file [(start, end, tint)]
|
|
377
|
+
self.keys = np.frombuffer(self._mm, np.uint32, int(n_tri), int(keys_off))
|
|
378
|
+
self.offs = np.frombuffer(self._mm, np.uint64, int(n_tri) + 1, int(offs_off))
|
|
379
|
+
self.post_off = int(post_off)
|
|
380
|
+
self.id_by_path = {p: i for i, p in enumerate(self.paths)}
|
|
381
|
+
|
|
382
|
+
def postings(self, key: int):
|
|
383
|
+
"""uint32 file ids containing trigram `key`, or None if absent."""
|
|
384
|
+
i = int(np.searchsorted(self.keys, np.uint32(key)))
|
|
385
|
+
if i >= len(self.keys) or int(self.keys[i]) != key:
|
|
386
|
+
return None
|
|
387
|
+
a, b = int(self.offs[i]), int(self.offs[i + 1])
|
|
388
|
+
return np.frombuffer(self._mm, np.uint32, b - a, self.post_off + 4 * a)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _walk_rel_files(root: str) -> list:
|
|
392
|
+
out = []
|
|
393
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
394
|
+
dirnames[:] = [d for d in dirnames
|
|
395
|
+
if d not in _SKIP_DIRS and not d.startswith(".")]
|
|
396
|
+
for fn in filenames:
|
|
397
|
+
if os.path.splitext(fn)[1].lower() in _TEXT_EXTS:
|
|
398
|
+
out.append(os.path.relpath(os.path.join(dirpath, fn), root))
|
|
399
|
+
out.sort()
|
|
400
|
+
return out
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _pad8(f):
|
|
404
|
+
f.write(b"\0" * (-f.tell() % 8))
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _build_segment(root: str, dst: Path):
|
|
408
|
+
"""Walk `root`, index every text file (pending text as truth), write the
|
|
409
|
+
segment atomically. Runs on a background thread — pure CPU/IO, no GL."""
|
|
410
|
+
t0 = time.perf_counter()
|
|
411
|
+
pend = _pending_gens()
|
|
412
|
+
rels = _walk_rel_files(root)
|
|
413
|
+
paths, sigs, symbols, line_tints = [], [], [], []
|
|
414
|
+
key_parts, id_parts = [], []
|
|
415
|
+
for rel in rels:
|
|
416
|
+
ap = os.path.join(root, rel)
|
|
417
|
+
try:
|
|
418
|
+
st = os.stat(ap)
|
|
419
|
+
except OSError:
|
|
420
|
+
continue
|
|
421
|
+
if st.st_size > _MAX_FILE_BYTES:
|
|
422
|
+
continue
|
|
423
|
+
rp = ap
|
|
424
|
+
try:
|
|
425
|
+
rp = str(Path(ap).resolve())
|
|
426
|
+
except OSError:
|
|
427
|
+
pass
|
|
428
|
+
gen = pend.get(rp, 0)
|
|
429
|
+
text = _current_text(ap) if gen else None
|
|
430
|
+
if text is None:
|
|
431
|
+
try:
|
|
432
|
+
with open(ap, "rb") as f:
|
|
433
|
+
raw = f.read()
|
|
434
|
+
except OSError:
|
|
435
|
+
continue
|
|
436
|
+
if b"\0" in raw[:4096]:
|
|
437
|
+
continue # binary despite the extension
|
|
438
|
+
text = raw.decode("utf-8", "replace")
|
|
439
|
+
fid = len(paths)
|
|
440
|
+
tris = _trigrams(_lower_bytes(text))
|
|
441
|
+
paths.append(rel)
|
|
442
|
+
sigs.append((st.st_mtime_ns, st.st_size, gen))
|
|
443
|
+
symbols.append(_extract_symbols(text, rel.endswith(".py")))
|
|
444
|
+
line_tints.append(_extract_line_tints(text, rel.endswith(".py")))
|
|
445
|
+
if tris.size:
|
|
446
|
+
key_parts.append(tris)
|
|
447
|
+
id_parts.append(np.full(tris.size, fid, np.uint32))
|
|
448
|
+
|
|
449
|
+
if key_parts:
|
|
450
|
+
keys = np.concatenate(key_parts)
|
|
451
|
+
ids = np.concatenate(id_parts)
|
|
452
|
+
order = np.lexsort((ids, keys)) # by key, then file id ascending
|
|
453
|
+
keys, post = keys[order], ids[order]
|
|
454
|
+
uniq, first = np.unique(keys, return_index=True)
|
|
455
|
+
offs = np.append(first, len(keys)).astype(np.uint64)
|
|
456
|
+
else:
|
|
457
|
+
uniq = np.empty(0, np.uint32)
|
|
458
|
+
offs = np.zeros(1, np.uint64)
|
|
459
|
+
post = np.empty(0, np.uint32)
|
|
460
|
+
|
|
461
|
+
meta = pickle.dumps({"root": root, "paths": paths, "sigs": sigs,
|
|
462
|
+
"symbols": symbols, "line_tints": line_tints})
|
|
463
|
+
tmp = dst.with_suffix(".tmp")
|
|
464
|
+
with open(tmp, "wb") as f:
|
|
465
|
+
f.write(b"\0" * _HDR.size)
|
|
466
|
+
_pad8(f); meta_off = f.tell(); f.write(meta)
|
|
467
|
+
_pad8(f); keys_off = f.tell(); f.write(uniq.tobytes())
|
|
468
|
+
_pad8(f); offs_off = f.tell(); f.write(offs.tobytes())
|
|
469
|
+
_pad8(f); post_off = f.tell(); f.write(post.tobytes())
|
|
470
|
+
f.seek(0)
|
|
471
|
+
f.write(_HDR.pack(_MAGIC, meta_off, len(meta), len(uniq),
|
|
472
|
+
keys_off, offs_off, post_off))
|
|
473
|
+
os.replace(tmp, dst)
|
|
474
|
+
print(f"text_index: built {len(paths)} files, {len(uniq)} trigrams, "
|
|
475
|
+
f"{post.size} postings in {time.perf_counter() - t0:.2f}s -> {dst}")
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
# ── Live state (adopted through sys, survives edit-in-place/hotswap) ──────
|
|
479
|
+
|
|
480
|
+
def _state(root: str) -> dict:
|
|
481
|
+
store = getattr(sys, "_lsd_text_index_state", None)
|
|
482
|
+
if not isinstance(store, dict):
|
|
483
|
+
store = sys._lsd_text_index_state = {}
|
|
484
|
+
st = store.get(root)
|
|
485
|
+
if st is not None:
|
|
486
|
+
# Adopted across a restart-in-place: the segment object was built by an
|
|
487
|
+
# earlier session and references THAT session's _Segment class, which
|
|
488
|
+
# pins its entire module graph. Same source → same layout → re-point it.
|
|
489
|
+
seg = st.get("seg")
|
|
490
|
+
if seg is not None and type(seg) is not _Segment:
|
|
491
|
+
try:
|
|
492
|
+
seg.__class__ = _Segment
|
|
493
|
+
except TypeError:
|
|
494
|
+
st["seg"] = None # layout changed: rebuild lazily
|
|
495
|
+
if st is None:
|
|
496
|
+
st = store[root] = {
|
|
497
|
+
"seg": None, # _Segment | None
|
|
498
|
+
"dirty": set(), # abs paths whose segment entry is stale
|
|
499
|
+
"extra": set(), # abs paths not in the segment (new files)
|
|
500
|
+
"lock": threading.Lock(),
|
|
501
|
+
"last_stat": 0.0,
|
|
502
|
+
"last_walk": 0.0,
|
|
503
|
+
"building": False,
|
|
504
|
+
}
|
|
505
|
+
return st
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _ensure_segment(st, root):
|
|
509
|
+
with st["lock"]:
|
|
510
|
+
if st["seg"] is not None:
|
|
511
|
+
return
|
|
512
|
+
sp = _segment_file(root)
|
|
513
|
+
if sp.exists():
|
|
514
|
+
try:
|
|
515
|
+
st["seg"] = _Segment(sp)
|
|
516
|
+
return
|
|
517
|
+
except Exception:
|
|
518
|
+
traceback.print_exc() # corrupt → rebuild below
|
|
519
|
+
_build_segment(root, sp) # first build: synchronous, but the
|
|
520
|
+
st["seg"] = _Segment(sp) # caller is already off-thread
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _sweep(st, root):
|
|
524
|
+
"""Refresh the dirty/extra overlay from cheap signals. Stat sweep and
|
|
525
|
+
directory walk are TTL-throttled; the pending-gen compare is O(edited
|
|
526
|
+
files) and runs every time. At very large file counts the stat sweep is
|
|
527
|
+
the piece to move to watcher-driven marking — everything else is O(small)."""
|
|
528
|
+
seg = st["seg"]
|
|
529
|
+
now = time.monotonic()
|
|
530
|
+
pend = _pending_gens()
|
|
531
|
+
if seg is not None:
|
|
532
|
+
for rp, gen in pend.items():
|
|
533
|
+
if not _under(rp, root):
|
|
534
|
+
continue
|
|
535
|
+
fid = seg.id_by_path.get(os.path.relpath(rp, root))
|
|
536
|
+
if fid is None:
|
|
537
|
+
st["extra"].add(rp)
|
|
538
|
+
elif seg.sigs[fid][2] != gen:
|
|
539
|
+
st["dirty"].add(rp)
|
|
540
|
+
if now - st["last_stat"] > _STAT_TTL:
|
|
541
|
+
st["last_stat"] = now
|
|
542
|
+
for fid, rel in enumerate(seg.paths):
|
|
543
|
+
ap = os.path.join(root, rel)
|
|
544
|
+
sig = seg.sigs[fid]
|
|
545
|
+
try:
|
|
546
|
+
s = os.stat(ap)
|
|
547
|
+
if (s.st_mtime_ns, s.st_size) != (sig[0], sig[1]):
|
|
548
|
+
st["dirty"].add(ap)
|
|
549
|
+
except OSError:
|
|
550
|
+
st["dirty"].add(ap) # deleted: scan yields nothing
|
|
551
|
+
if now - st["last_walk"] > _WALK_TTL:
|
|
552
|
+
st["last_walk"] = now
|
|
553
|
+
known = seg.id_by_path
|
|
554
|
+
for rel in _walk_rel_files(root):
|
|
555
|
+
if rel not in known:
|
|
556
|
+
st["extra"].add(os.path.join(root, rel))
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _maybe_rebuild(st, root):
|
|
560
|
+
if len(st["dirty"]) + len(st["extra"]) <= _REBUILD_DIRTY or st["building"]:
|
|
561
|
+
return
|
|
562
|
+
st["building"] = True
|
|
563
|
+
|
|
564
|
+
def _run():
|
|
565
|
+
try:
|
|
566
|
+
sp = _segment_file(root)
|
|
567
|
+
_build_segment(root, sp)
|
|
568
|
+
with st["lock"]:
|
|
569
|
+
st["seg"] = _Segment(sp) # old mmap freed when views drop
|
|
570
|
+
st["dirty"].clear()
|
|
571
|
+
st["extra"].clear()
|
|
572
|
+
st["last_stat"] = time.monotonic()
|
|
573
|
+
except Exception:
|
|
574
|
+
traceback.print_exc()
|
|
575
|
+
finally:
|
|
576
|
+
st["building"] = False
|
|
577
|
+
|
|
578
|
+
threading.Thread(target=_run, daemon=True, name="text-index-rebuild").start()
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
# ── Search ─────────────────────────────────────────────────────────────────────
|
|
582
|
+
|
|
583
|
+
def _candidates(seg, qb: bytes):
|
|
584
|
+
"""File ids whose trigram set covers every query trigram (superset of the
|
|
585
|
+
true matches — verification prunes). None postings for any trigram means
|
|
586
|
+
no segment file can contain the query."""
|
|
587
|
+
tris = {qb[i:i + 3] for i in range(len(qb) - 2)}
|
|
588
|
+
lists = []
|
|
589
|
+
for t in tris:
|
|
590
|
+
p = seg.postings((t[0] << 16) | (t[1] << 8) | t[2])
|
|
591
|
+
if p is None:
|
|
592
|
+
return np.empty(0, np.uint32)
|
|
593
|
+
lists.append(p)
|
|
594
|
+
lists.sort(key=len)
|
|
595
|
+
cand = lists[0]
|
|
596
|
+
for p in lists[1:]:
|
|
597
|
+
if not cand.size:
|
|
598
|
+
break
|
|
599
|
+
cand = np.intersect1d(cand, p, assume_unique=True)
|
|
600
|
+
return cand
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _verify(abs_path: str, text: str, ql: str, per_file=_PER_FILE_CAP):
|
|
604
|
+
"""(1-based line, RAW line text — indent kept, so renderers can map file
|
|
605
|
+
columns) for each case-insensitive occurrence,
|
|
606
|
+
capped. Offsets are found on the lowered text and snippets cut from the
|
|
607
|
+
original at the same indices; the rare unicode case where lower() changes
|
|
608
|
+
the string's length (e.g. İ) would skew them, so such files fall back to
|
|
609
|
+
lowercased snippets — offsets then index the buffer they came from."""
|
|
610
|
+
tl = text.lower()
|
|
611
|
+
if len(tl) != len(text):
|
|
612
|
+
text = tl
|
|
613
|
+
out = []
|
|
614
|
+
pos = tl.find(ql)
|
|
615
|
+
while pos != -1 and len(out) < per_file:
|
|
616
|
+
line = tl.count("\n", 0, pos) + 1
|
|
617
|
+
ls = text.rfind("\n", 0, pos) + 1
|
|
618
|
+
le = text.find("\n", pos)
|
|
619
|
+
out.append((line, text[ls: le if le != -1 else len(text)].rstrip()))
|
|
620
|
+
nl = tl.find("\n", pos) # one hit per line reads better
|
|
621
|
+
pos = tl.find(ql, nl + 1) if nl != -1 else -1
|
|
622
|
+
return out
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _hit(kind, root, rel, line, text, tint, scope=None):
|
|
626
|
+
return {"kind": kind, "path": os.path.join(root, rel), "rel": rel,
|
|
627
|
+
"line": line, "text": text, "tint": tint, "scope": scope}
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _enclosing_scope(symbols, hit_line):
|
|
631
|
+
"""Qualname chain (tuple of def/class names, outermost first) of the
|
|
632
|
+
definitions whose block span contains `hit_line` — the scope a content
|
|
633
|
+
hit sits in ("Toggles", "TextEditor"); () at module level. Same
|
|
634
|
+
containment walk as _enclosing_tint."""
|
|
635
|
+
chain = []
|
|
636
|
+
for name, ln, indent, _kind, _t, end, _sig in symbols:
|
|
637
|
+
if ln > hit_line:
|
|
638
|
+
break
|
|
639
|
+
if hit_line <= end:
|
|
640
|
+
while chain and chain[-1][1] >= indent:
|
|
641
|
+
chain.pop()
|
|
642
|
+
chain.append((name, indent))
|
|
643
|
+
return tuple(n for n, _i in chain)
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def candidate_paths(query: str, exts=(".py",), root=None) -> list:
|
|
647
|
+
"""Absolute paths whose CURRENT text may contain `query` (case-
|
|
648
|
+
insensitive): every overlay file (dirty / new — their segment entry is
|
|
649
|
+
stale, so they are always candidates) plus the segment files whose
|
|
650
|
+
trigram postings cover the query. A SUPERSET of the true matches —
|
|
651
|
+
callers verify against the text. `exts` filters by extension. The
|
|
652
|
+
symbol roster's reverse lookup (usages of a definition) is built on
|
|
653
|
+
this: O(results) posting intersection instead of a scan of every file.
|
|
654
|
+
Call from a background thread on first use: it builds the index."""
|
|
655
|
+
ql = query.lower()
|
|
656
|
+
qb = ql.encode("utf-8", "replace")
|
|
657
|
+
root = _resolve_root(root)
|
|
658
|
+
st = _state(root)
|
|
659
|
+
_ensure_segment(st, root)
|
|
660
|
+
_sweep(st, root)
|
|
661
|
+
_maybe_rebuild(st, root)
|
|
662
|
+
seg = st["seg"]
|
|
663
|
+
out = []
|
|
664
|
+
seen = set()
|
|
665
|
+
for ap in sorted(st["dirty"] | st["extra"]):
|
|
666
|
+
if os.path.splitext(ap)[1].lower() in exts and ap not in seen:
|
|
667
|
+
seen.add(ap)
|
|
668
|
+
out.append(ap)
|
|
669
|
+
if seg is not None:
|
|
670
|
+
if len(qb) < 3:
|
|
671
|
+
fids = range(len(seg.paths)) # too short to trigram: everything
|
|
672
|
+
else:
|
|
673
|
+
fids = (int(f) for f in _candidates(seg, qb))
|
|
674
|
+
for fid in fids:
|
|
675
|
+
rel = seg.paths[fid]
|
|
676
|
+
if os.path.splitext(rel)[1].lower() not in exts:
|
|
677
|
+
continue
|
|
678
|
+
ap = os.path.join(root, rel)
|
|
679
|
+
if ap not in seen:
|
|
680
|
+
seen.add(ap)
|
|
681
|
+
out.append(ap)
|
|
682
|
+
return out
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def _park_ui():
|
|
686
|
+
"""Park this (background) thread while the render thread is mid-frame —
|
|
687
|
+
libcst_conversion._park_while_frame, reached through sys.modules so
|
|
688
|
+
text_index gains no import edge on the conversion stack (and offline
|
|
689
|
+
callers that never loaded it just don't park). Why: search() is a CPU
|
|
690
|
+
chunk (~140 ms warm for a common word — a lower()+scan per candidate
|
|
691
|
+
file), and a CPU-bound background thread GIL-convoys every frame it
|
|
692
|
+
overlaps; parking at file boundaries keeps keystroke frames smooth."""
|
|
693
|
+
m = sys.modules.get("meltygui.code.libcst_conversion")
|
|
694
|
+
if m is not None:
|
|
695
|
+
park = getattr(m, "_park_while_frame", None)
|
|
696
|
+
if park is not None:
|
|
697
|
+
park()
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def search(query: str, limit=200, per_file=_PER_FILE_CAP, cancelled=None, root=None):
|
|
701
|
+
"""Case-insensitive search over `root` (default: the src root — see
|
|
702
|
+
_search_root; a meltygui app passes its own project roots, one call per
|
|
703
|
+
root), pending edits included.
|
|
704
|
+
Returns hit dicts {kind, path, rel, line, text, tint} in three kinds,
|
|
705
|
+
listed in this order:
|
|
706
|
+
file — the file's NAME matches (line None, text = basename)
|
|
707
|
+
symbol — a class/def NAME matches (text = the def line, tint = the
|
|
708
|
+
definition's own explicit tint)
|
|
709
|
+
line — full-text content match (text = the line, tint = the innermost
|
|
710
|
+
enclosing tinted definition's — the editor's block wash,
|
|
711
|
+
scope = the enclosing def/class qualname chain as a tuple)
|
|
712
|
+
Tints are index-resolved and None when no source tint applies (the UI
|
|
713
|
+
falls back to FileMeta / category tints). Overlay (dirty/new) files are
|
|
714
|
+
served live and shadow their stale segment entries. `per_file` caps the
|
|
715
|
+
content hits reported per file (default _PER_FILE_CAP). Call from a
|
|
716
|
+
background thread: the first call builds the index.
|
|
717
|
+
|
|
718
|
+
`cancelled` (a nullary callable) makes the scan ABANDONABLE: checked at
|
|
719
|
+
file boundaries, and a True return abandons the search and returns None
|
|
720
|
+
(not a partial list — the caller must land nothing). The global-search
|
|
721
|
+
text pass hands its generation staleness here so a new keystroke stops
|
|
722
|
+
a now-pointless scan within one file instead of finishing ~140 ms of
|
|
723
|
+
dead work that would GIL-convoy the keystroke's frame."""
|
|
724
|
+
ql = query.lower()
|
|
725
|
+
qb = ql.encode("utf-8", "replace")
|
|
726
|
+
if len(qb) < 3:
|
|
727
|
+
return []
|
|
728
|
+
root = _resolve_root(root)
|
|
729
|
+
st = _state(root)
|
|
730
|
+
_ensure_segment(st, root)
|
|
731
|
+
_sweep(st, root)
|
|
732
|
+
_maybe_rebuild(st, root)
|
|
733
|
+
|
|
734
|
+
seg = st["seg"]
|
|
735
|
+
overlay = sorted(st["dirty"] | st["extra"])
|
|
736
|
+
overlay_rel = {os.path.relpath(p, root) for p in overlay}
|
|
737
|
+
file_hits, sym_hits, line_hits = [], [], []
|
|
738
|
+
|
|
739
|
+
# ── file-name hits: the whole indexed universe + unseen extras ──
|
|
740
|
+
universe = list(seg.paths) if seg is not None else []
|
|
741
|
+
universe += [r for r in (os.path.relpath(p, root) for p in st["extra"])
|
|
742
|
+
if seg is None or r not in seg.id_by_path]
|
|
743
|
+
for rel in universe:
|
|
744
|
+
if ql in os.path.basename(rel).lower():
|
|
745
|
+
file_hits.append(_hit("file", root, rel, None,
|
|
746
|
+
os.path.basename(rel), None))
|
|
747
|
+
if len(file_hits) >= _FILE_HIT_CAP:
|
|
748
|
+
break
|
|
749
|
+
|
|
750
|
+
def _sym_match(rel, syms):
|
|
751
|
+
for name, ln, _ind, _kind, tint, _end, sig in syms:
|
|
752
|
+
if ql in name.lower():
|
|
753
|
+
sym_hits.append((name.lower(), _hit("symbol", root, rel, ln,
|
|
754
|
+
sig, tint)))
|
|
755
|
+
|
|
756
|
+
# ── overlay files: live symbol table + content scan (freshest wins) ──
|
|
757
|
+
scanned = {}
|
|
758
|
+
for ap in overlay:
|
|
759
|
+
if cancelled is not None and cancelled():
|
|
760
|
+
return None
|
|
761
|
+
_park_ui()
|
|
762
|
+
text = _current_text(ap)
|
|
763
|
+
rel = os.path.relpath(ap, root)
|
|
764
|
+
scanned[ap] = None
|
|
765
|
+
if not text:
|
|
766
|
+
continue
|
|
767
|
+
syms = _extract_symbols(text, rel.endswith(".py"))
|
|
768
|
+
ltints = _extract_line_tints(text, rel.endswith(".py"))
|
|
769
|
+
_sym_match(rel, syms)
|
|
770
|
+
if len(line_hits) < limit:
|
|
771
|
+
for line, snippet in _verify(ap, text, ql, per_file):
|
|
772
|
+
line_hits.append(_hit("line", root, rel, line, snippet,
|
|
773
|
+
_line_tint(ltints, line)
|
|
774
|
+
or _enclosing_tint(syms, line),
|
|
775
|
+
_enclosing_scope(syms, line)))
|
|
776
|
+
|
|
777
|
+
# ── segment symbol tables (overlay files shadowed above) ──
|
|
778
|
+
if seg is not None:
|
|
779
|
+
for i, (rel, syms) in enumerate(zip(seg.paths, seg.symbols)):
|
|
780
|
+
if i % 64 == 0:
|
|
781
|
+
if cancelled is not None and cancelled():
|
|
782
|
+
return None
|
|
783
|
+
_park_ui()
|
|
784
|
+
if len(sym_hits) >= _SYMBOL_HIT_CAP * 10:
|
|
785
|
+
break # raw pool; ranked + trimmed below
|
|
786
|
+
if rel not in overlay_rel:
|
|
787
|
+
_sym_match(rel, syms)
|
|
788
|
+
# Name-prefix matches ahead of nameested ones (the scorer's prefix flag),
|
|
789
|
+
# then shorter names - the tightest match tops the list.
|
|
790
|
+
sym_hits.sort(key=lambda t: (not t[0].startswith(ql), len(t[0]), t[0]))
|
|
791
|
+
sym_hits = [h for _n, h in sym_hits[:_SYMBOL_HIT_CAP]]
|
|
792
|
+
|
|
793
|
+
# ── segment content candidates ──
|
|
794
|
+
if seg is not None and len(line_hits) < limit:
|
|
795
|
+
for fid in _candidates(seg, qb)[:_CAND_CAP]:
|
|
796
|
+
if len(line_hits) >= limit:
|
|
797
|
+
break
|
|
798
|
+
# Per-file boundary: the lower()+scan below is the search's cost
|
|
799
|
+
# center, so this is where cancellation and frame-parking bite.
|
|
800
|
+
if cancelled is not None and cancelled():
|
|
801
|
+
return None
|
|
802
|
+
_park_ui()
|
|
803
|
+
fid = int(fid)
|
|
804
|
+
ap = os.path.join(root, seg.paths[fid])
|
|
805
|
+
if ap in scanned:
|
|
806
|
+
continue
|
|
807
|
+
text = _current_text(ap)
|
|
808
|
+
if not text:
|
|
809
|
+
continue
|
|
810
|
+
for line, snippet in _verify(ap, text, ql, per_file):
|
|
811
|
+
line_hits.append(_hit("line", root, seg.paths[fid], line,
|
|
812
|
+
snippet,
|
|
813
|
+
_line_tint(seg.line_tints[fid], line)
|
|
814
|
+
or _enclosing_tint(seg.symbols[fid], line),
|
|
815
|
+
_enclosing_scope(seg.symbols[fid], line)))
|
|
816
|
+
return (file_hits + sym_hits + line_hits)[:limit]
|