meltygui 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (372) hide show
  1. meltygui/__init__.py +107 -0
  2. meltygui/accounts/__init__.py +0 -0
  3. meltygui/accounts/internet_accounts.py +1355 -0
  4. meltygui/chat/__init__.py +91 -0
  5. meltygui/chat/activity.py +75 -0
  6. meltygui/chat/backends.py +36 -0
  7. meltygui/chat/chat_interface.py +732 -0
  8. meltygui/chat/chat_proxy.py +352 -0
  9. meltygui/chat/codex_proxy.py +592 -0
  10. meltygui/chat/codex_settings.py +100 -0
  11. meltygui/chat/codex_transport.py +60 -0
  12. meltygui/chat/command_parser.py +204 -0
  13. meltygui/chat/images.py +227 -0
  14. meltygui/chat/messages.py +417 -0
  15. meltygui/chat/metadata.py +139 -0
  16. meltygui/chat/writer_locks.py +64 -0
  17. meltygui/code/__init__.py +0 -0
  18. meltygui/code/basic_converters.py +533 -0
  19. meltygui/code/chain_converters.py +2111 -0
  20. meltygui/code/code_checks.py +2209 -0
  21. meltygui/code/core_syntax.py +1430 -0
  22. meltygui/code/file_converters.py +1933 -0
  23. meltygui/code/fileref.py +702 -0
  24. meltygui/code/hotswap_guard.py +144 -0
  25. meltygui/code/libcst_conversion.py +9724 -0
  26. meltygui/code/live_instrument.py +392 -0
  27. meltygui/code/live_view.py +2490 -0
  28. meltygui/code/melty_scan.py +2684 -0
  29. meltygui/code/new_codecs.py +1255 -0
  30. meltygui/code/new_converters.py +3017 -0
  31. meltygui/code/project_code.py +278 -0
  32. meltygui/code/source_context.py +63 -0
  33. meltygui/code/symbol_roster.py +1588 -0
  34. meltygui/code/syntax_check.py +34 -0
  35. meltygui/code/syntax_check_worker.py +114 -0
  36. meltygui/completion/__init__.py +0 -0
  37. meltygui/completion/fim.py +1232 -0
  38. meltygui/completion/fim_context.py +481 -0
  39. meltygui/completion/providers/__init__.py +0 -0
  40. meltygui/completion/providers/anthropic_oauth.py +446 -0
  41. meltygui/completion/providers/anthropic_requests.py +69 -0
  42. meltygui/completion/providers/claude.py +203 -0
  43. meltygui/completion/providers/claude_usage.py +499 -0
  44. meltygui/completion/providers/codex_accounts.py +180 -0
  45. meltygui/completion/providers/copilot.py +617 -0
  46. meltygui/completion/providers/oauth_popup.py +220 -0
  47. meltygui/completion/providers/ollama.py +320 -0
  48. meltygui/completion/providers/profiles.py +23 -0
  49. meltygui/core/README.md +88 -0
  50. meltygui/core/__init__.py +1 -0
  51. meltygui/core/automation/__init__.py +1 -0
  52. meltygui/core/automation/action_core.py +153 -0
  53. meltygui/core/automation/collection_action.py +45 -0
  54. meltygui/core/automation/mcp_eval.py +167 -0
  55. meltygui/core/automation/mcp_hotswap.py +107 -0
  56. meltygui/core/automation/mcp_query.py +552 -0
  57. meltygui/core/automation/mcp_server.py +657 -0
  58. meltygui/core/automation/orchestration_core.py +2636 -0
  59. meltygui/core/automation/query_core.py +124 -0
  60. meltygui/core/automation/search_core.py +26 -0
  61. meltygui/core/automation/selector_core.py +340 -0
  62. meltygui/core/automation/value_core.py +1752 -0
  63. meltygui/core/cache/__init__.py +1 -0
  64. meltygui/core/cache/cache_diagnostics.py +0 -0
  65. meltygui/core/cache/invalidation_decoration.py +153 -0
  66. meltygui/core/cache/invalidation_tracker.py +43 -0
  67. meltygui/core/cache/tile_cache.py +5968 -0
  68. meltygui/core/conversion/__init__.py +1 -0
  69. meltygui/core/conversion/bubbling.py +599 -0
  70. meltygui/core/conversion/cache_tree.py +188 -0
  71. meltygui/core/conversion/chain.py +113 -0
  72. meltygui/core/conversion/converter_register.py +145 -0
  73. meltygui/core/conversion/data_decoration.py +67 -0
  74. meltygui/core/conversion/dict_conversion.py +1815 -0
  75. meltygui/core/conversion/dict_conversion_util.py +177 -0
  76. meltygui/core/conversion/dynamic_obj.py +89 -0
  77. meltygui/core/conversion/graph_compare.py +183 -0
  78. meltygui/core/conversion/load_save_v2.py +1032 -0
  79. meltygui/core/conversion/missing_saved_class.py +39 -0
  80. meltygui/core/conversion/path_finder.py +606 -0
  81. meltygui/core/conversion/render_host.py +1083 -0
  82. meltygui/core/core_render.py +6537 -0
  83. meltygui/core/definition_hotswap.py +231 -0
  84. meltygui/core/diagnostics/__init__.py +1 -0
  85. meltygui/core/diagnostics/attribute_churn.py +38 -0
  86. meltygui/core/diagnostics/fps_counter.py +39 -0
  87. meltygui/core/diagnostics/gpu_frame_timer.py +103 -0
  88. meltygui/core/diagnostics/inspection_core.py +169 -0
  89. meltygui/core/diagnostics/monitor_core.py +105 -0
  90. meltygui/core/diagnostics/notifications.py +706 -0
  91. meltygui/core/diagnostics/perf_trace.py +281 -0
  92. meltygui/core/diagnostics/profile_decoration.py +88 -0
  93. meltygui/core/diagnostics/resize_trace.py +62 -0
  94. meltygui/core/diagnostics/screenshot_core.py +247 -0
  95. meltygui/core/diagnostics/session_status.py +98 -0
  96. meltygui/core/diagnostics/trace_core.py +445 -0
  97. meltygui/core/files/__init__.py +1 -0
  98. meltygui/core/files/file_core.py +208 -0
  99. meltygui/core/files/file_explorer_core.py +104 -0
  100. meltygui/core/files/file_tree_core.py +198 -0
  101. meltygui/core/files/file_watch_core.py +43 -0
  102. meltygui/core/files/import_graph_core.py +43 -0
  103. meltygui/core/files/metadata_core.py +51 -0
  104. meltygui/core/graphics/__init__.py +1 -0
  105. meltygui/core/graphics/cuda_context_core.py +166 -0
  106. meltygui/core/graphics/cuda_interop_core.py +136 -0
  107. meltygui/core/graphics/cuda_kernel_core.py +91 -0
  108. meltygui/core/graphics/framebuffer_recorder.py +337 -0
  109. meltygui/core/graphics/gl_state.py +658 -0
  110. meltygui/core/graphics/lut_core.py +52 -0
  111. meltygui/core/graphics/overlay_renderer.py +984 -0
  112. meltygui/core/graphics/scene_target.py +180 -0
  113. meltygui/core/graphics/screenshot.py +439 -0
  114. meltygui/core/graphics/shader_func.py +478 -0
  115. meltygui/core/graphics/tensor_core.py +45 -0
  116. meltygui/core/graphics/text_texture.py +329 -0
  117. meltygui/core/graphics/wayland_color.py +635 -0
  118. meltygui/core/input/__init__.py +1 -0
  119. meltygui/core/input/collision.py +165 -0
  120. meltygui/core/input/drag_drop_core.py +1525 -0
  121. meltygui/core/input/hypr_left_drag.py +323 -0
  122. meltygui/core/input/input_core.py +245 -0
  123. meltygui/core/input/input_handler.py +1101 -0
  124. meltygui/core/input/mouse_cursor.py +355 -0
  125. meltygui/core/input/pynput_backend.py +1054 -0
  126. meltygui/core/input/space_mouse.py +338 -0
  127. meltygui/core/input/touchpad_backend.py +393 -0
  128. meltygui/core/input/view_selection.py +177 -0
  129. meltygui/core/layout/__init__.py +1 -0
  130. meltygui/core/layout/column_core.py +2153 -0
  131. meltygui/core/layout/cursor_core.py +161 -0
  132. meltygui/core/layout/dropdown_core.py +278 -0
  133. meltygui/core/layout/edge_constraints.py +155 -0
  134. meltygui/core/layout/grid_core.py +117 -0
  135. meltygui/core/layout/header_core.py +23 -0
  136. meltygui/core/layout/header_runtime.py +67 -0
  137. meltygui/core/layout/layout_core.py +87 -0
  138. meltygui/core/layout/tile_manager_core.py +591 -0
  139. meltygui/core/melty.py +6726 -0
  140. meltygui/core/module_map.json +896 -0
  141. meltygui/core/module_names.py +19 -0
  142. meltygui/core/rendering/__init__.py +1 -0
  143. meltygui/core/rendering/core_decoration.py +431 -0
  144. meltygui/core/rendering/core_render_helpers.py +328 -0
  145. meltygui/core/rendering/func_metadata.py +398 -0
  146. meltygui/core/rendering/mode.py +818 -0
  147. meltygui/core/rendering/mode_defaults.py +41 -0
  148. meltygui/core/rendering/modes.py +136 -0
  149. meltygui/core/rendering/parameter_core.py +1665 -0
  150. meltygui/core/rendering/render_dispatch.py +1891 -0
  151. meltygui/core/rendering/render_funcs.py +273 -0
  152. meltygui/core/rendering/shaped.py +312 -0
  153. meltygui/core/rendering/window_decoration.py +25 -0
  154. meltygui/core/runtime/__init__.py +1 -0
  155. meltygui/core/runtime/app.py +735 -0
  156. meltygui/core/runtime/app_session.py +140 -0
  157. meltygui/core/runtime/background.py +564 -0
  158. meltygui/core/runtime/extensions.py +53 -0
  159. meltygui/core/runtime/gc_manager.py +1163 -0
  160. meltygui/core/runtime/lifecycle.py +21 -0
  161. meltygui/core/runtime/paths.py +27 -0
  162. meltygui/core/runtime/settings.py +14 -0
  163. meltygui/core/runtime/singleton.py +16 -0
  164. meltygui/core/runtime/thread_safe_bool.py +24 -0
  165. meltygui/core/runtime/thread_signal.py +30 -0
  166. meltygui/core/runtime/toggles.py +3115 -0
  167. meltygui/core/services/__init__.py +1 -0
  168. meltygui/core/services/account_core.py +10 -0
  169. meltygui/core/services/chat_core.py +19 -0
  170. meltygui/core/services/claude_terminal_core.py +346 -0
  171. meltygui/core/services/terminal_core.py +458 -0
  172. meltygui/core/services/terminal_runtime.py +78 -0
  173. meltygui/core/styling/__init__.py +1 -0
  174. meltygui/core/styling/color_core.py +46 -0
  175. meltygui/core/styling/fonts.py +639 -0
  176. meltygui/core/styling/global_style.py +338 -0
  177. meltygui/core/styling/style.py +198 -0
  178. meltygui/core/styling/style_core.py +522 -0
  179. meltygui/core/styling/warm_start.py +149 -0
  180. meltygui/core/windowing/__init__.py +1 -0
  181. meltygui/core/windowing/backends/PYIMGUI_LICENSE +28 -0
  182. meltygui/core/windowing/backends/__init__.py +1 -0
  183. meltygui/core/windowing/backends/imgui_renderer.py +138 -0
  184. meltygui/core/windowing/backends/native_wayland.py +850 -0
  185. meltygui/core/windowing/backends/protocols/xdg-decoration-unstable-v1.xml +156 -0
  186. meltygui/core/windowing/backends/protocols/xdg-shell.xml +1420 -0
  187. meltygui/core/windowing/backends/wayland_protocol.py +101 -0
  188. meltygui/core/windowing/dock_core.py +182 -0
  189. meltygui/core/windowing/frame_geometry.py +42 -0
  190. meltygui/core/windowing/geometry_feed.py +864 -0
  191. meltygui/core/windowing/glfw_utils.py +1343 -0
  192. meltygui/core/windowing/os_frame.py +1552 -0
  193. meltygui/core/windowing/surface.py +643 -0
  194. meltygui/core/windowing/titlebar.py +1560 -0
  195. meltygui/core/windowing/titlebar_buttons.py +281 -0
  196. meltygui/core/windowing/wayland_move.py +932 -0
  197. meltygui/core/windowing/window_api.py +62 -0
  198. meltygui/core/windowing/window_constants.py +339 -0
  199. meltygui/core/windowing/window_visibility.py +162 -0
  200. meltygui/debug/__init__.py +0 -0
  201. meltygui/debug/app_view_utils.py +9 -0
  202. meltygui/editor/__init__.py +0 -0
  203. meltygui/editor/bash_syntax.py +30 -0
  204. meltygui/editor/code_line_fast.py +152 -0
  205. meltygui/editor/diff.py +139 -0
  206. meltygui/editor/external_changes.py +159 -0
  207. meltygui/editor/file_header.py +41 -0
  208. meltygui/editor/live_usage.py +169 -0
  209. meltygui/editor/live_view_views.py +1803 -0
  210. meltygui/editor/pending_save.py +1351 -0
  211. meltygui/editor/roster_tints.py +547 -0
  212. meltygui/editor/source_preview.py +16 -0
  213. meltygui/editor/source_tools.py +10 -0
  214. meltygui/editor/source_ui.py +70 -0
  215. meltygui/editor/spell_check.py +77 -0
  216. meltygui/editor/text_editor.py +9076 -0
  217. meltygui/editor/usage_picker.py +538 -0
  218. meltygui/events/__init__.py +0 -0
  219. meltygui/events/example.py +118 -0
  220. meltygui/examples/__init__.py +0 -0
  221. meltygui/examples/columns_demo.py +33 -0
  222. meltygui/examples/columns_window_demo.py +60 -0
  223. meltygui/examples/context_menu_demo.py +24 -0
  224. meltygui/examples/context_menu_window_demo.py +49 -0
  225. meltygui/examples/gui_playground.py +127 -0
  226. meltygui/examples/live_view_playground.py +256 -0
  227. meltygui/examples/lora.py +43 -0
  228. meltygui/examples/lora_data.py +59 -0
  229. meltygui/examples/lora_policies.py +67 -0
  230. meltygui/examples/mode_demo.py +49 -0
  231. meltygui/examples/modifies_demo.py +119 -0
  232. meltygui/examples/scalar_policies.py +60 -0
  233. meltygui/examples/style_layouts.py +149 -0
  234. meltygui/examples/tile_manager_demo.py +74 -0
  235. meltygui/examples/tint_demo.py +160 -0
  236. meltygui/examples/tint_functions.py +56 -0
  237. meltygui/examples/trace_demo.py +88 -0
  238. meltygui/examples/two_windows.py +36 -0
  239. meltygui/files/__init__.py +0 -0
  240. meltygui/files/fast_file_explorer.py +439 -0
  241. meltygui/gnome_extension/lsd-window-geometry@latent-descent/extension.js +237 -0
  242. meltygui/gnome_extension/lsd-window-geometry@latent-descent/lsd-window-geometry@latent-descent.iml +9 -0
  243. meltygui/gnome_extension/lsd-window-geometry@latent-descent/metadata.json +7 -0
  244. meltygui/graphics/__init__.py +6 -0
  245. meltygui/graphics/base.py +85 -0
  246. meltygui/graphics/examples.py +507 -0
  247. meltygui/graphics/executor.py +520 -0
  248. meltygui/graphics/filter.py +667 -0
  249. meltygui/graphics/filter.pyi +856 -0
  250. meltygui/graphics/generate_stubs.py +22 -0
  251. meltygui/graphics/registry.py +203 -0
  252. meltygui/graphics/shader_compiler.py +155 -0
  253. meltygui/graphics/shaders.py +1302 -0
  254. meltygui/graphics/stub_generator.py +250 -0
  255. meltygui/graphics/texture_manager.py +170 -0
  256. meltygui/graphics/texture_min_max.py +295 -0
  257. meltygui/hdr_color.py +757 -0
  258. meltygui/image_load.py +308 -0
  259. meltygui/model/__init__.py +1 -0
  260. meltygui/model/account_model.py +142 -0
  261. meltygui/model/camera_model.py +159 -0
  262. meltygui/model/chat_model.py +86 -0
  263. meltygui/model/code_model.py +62 -0
  264. meltygui/model/code_proxy_model.py +876 -0
  265. meltygui/model/collection_model.py +41 -0
  266. meltygui/model/color_model.py +127 -0
  267. meltygui/model/cuda_tensor_model.py +36 -0
  268. meltygui/model/cuda_texture_model.py +149 -0
  269. meltygui/model/dropdown_model.py +137 -0
  270. meltygui/model/file_metadata_model.py +73 -0
  271. meltygui/model/file_model.py +289 -0
  272. meltygui/model/format_model.py +390 -0
  273. meltygui/model/graph_model.py +98 -0
  274. meltygui/model/icon_model.py +1024 -0
  275. meltygui/model/import_graph_model.py +468 -0
  276. meltygui/model/layout_model.py +15 -0
  277. meltygui/model/lut_model.py +266 -0
  278. meltygui/model/search_model.py +173 -0
  279. meltygui/model/tensor_model.py +402 -0
  280. meltygui/model/terminal_model.py +329 -0
  281. meltygui/model/texture_model.py +146 -0
  282. meltygui/model/tile_model.py +24 -0
  283. meltygui/model/trace_model.py +198 -0
  284. meltygui/model/trace_report_model.py +146 -0
  285. meltygui/models/__init__.py +0 -0
  286. meltygui/models/file_meta.py +613 -0
  287. meltygui/models/function_console.py +184 -0
  288. meltygui/models/orchestration.py +48 -0
  289. meltygui/pbr.py +1576 -0
  290. meltygui/png_unfilter.c +49 -0
  291. meltygui/resources/JetBrainsMono-Regular.ttf +0 -0
  292. meltygui/resources/THIRD_PARTY_NOTICES.md +21 -0
  293. meltygui/resources/dejavu/DejaVuSans-Bold.ttf +0 -0
  294. meltygui/resources/dejavu/DejaVuSans-ExtraLight.ttf +0 -0
  295. meltygui/resources/dejavu/DejaVuSans.ttf +0 -0
  296. meltygui/resources/dejavu/LICENSE.txt +78 -0
  297. meltygui/resources/fontawesome-LICENSE.txt +121 -0
  298. meltygui/resources/fontawesome-webfont.ttf +0 -0
  299. meltygui/resources/hdri/studio_small_09_1k.hdr +0 -0
  300. meltygui/resources/jetbrains-weights/JetBrainsMono-Bold.ttf +0 -0
  301. meltygui/resources/jetbrains-weights/JetBrainsMono-ExtraBold.ttf +0 -0
  302. meltygui/resources/jetbrains-weights/JetBrainsMono-ExtraLight.ttf +0 -0
  303. meltygui/resources/jetbrains-weights/JetBrainsMono-Light.ttf +0 -0
  304. meltygui/resources/jetbrains-weights/JetBrainsMono-Medium.ttf +0 -0
  305. meltygui/resources/jetbrains-weights/JetBrainsMono-SemiBold.ttf +0 -0
  306. meltygui/resources/jetbrains-weights/JetBrainsMono-Thin.ttf +0 -0
  307. meltygui/resources/jetbrains-weights/OFL.txt +93 -0
  308. meltygui/resources/jetbrains-weights/README.md +2 -0
  309. meltygui/state/__init__.py +0 -0
  310. meltygui/state/account_state.py +22 -0
  311. meltygui/state/animation_state.py +97 -0
  312. meltygui/state/annotation_state.py +22 -0
  313. meltygui/state/chat_state.py +36 -0
  314. meltygui/state/code_state.py +10 -0
  315. meltygui/state/core_enums.py +59 -0
  316. meltygui/state/core_markers.py +115 -0
  317. meltygui/state/core_undo.py +1075 -0
  318. meltygui/state/file_state.py +75 -0
  319. meltygui/state/graph_state.py +26 -0
  320. meltygui/state/inspection_state.py +47 -0
  321. meltygui/state/menu_state.py +10 -0
  322. meltygui/state/model_enums.py +11 -0
  323. meltygui/state/new_core_model.py +2394 -0
  324. meltygui/state/orchestration_state.py +14 -0
  325. meltygui/state/query_state.py +24 -0
  326. meltygui/state/tensor_state.py +10 -0
  327. meltygui/state/terminal_state.py +23 -0
  328. meltygui/state/trace_state.py +32 -0
  329. meltygui/state/voxel_state.py +12 -0
  330. meltygui/text_index.py +816 -0
  331. meltygui/utils/__init__.py +0 -0
  332. meltygui/utils/jump_to_code.py +344 -0
  333. meltygui/utils/pkl_inspect.py +90 -0
  334. meltygui/utils/render_utils.py +1419 -0
  335. meltygui/view/__init__.py +1 -0
  336. meltygui/view/account_view.py +524 -0
  337. meltygui/view/action_view.py +73 -0
  338. meltygui/view/chat_decoration_view.py +112 -0
  339. meltygui/view/chat_view.py +1703 -0
  340. meltygui/view/code_view.py +2677 -0
  341. meltygui/view/collection_view.py +1185 -0
  342. meltygui/view/color_view.py +824 -0
  343. meltygui/view/control_view.py +559 -0
  344. meltygui/view/decoration_view.py +439 -0
  345. meltygui/view/diagnostic_view.py +177 -0
  346. meltygui/view/dropdown_view.py +1082 -0
  347. meltygui/view/file_view.py +1599 -0
  348. meltygui/view/graph_cuda_view.py +105 -0
  349. meltygui/view/graph_view.py +882 -0
  350. meltygui/view/header_view.py +848 -0
  351. meltygui/view/input_view.py +238 -0
  352. meltygui/view/inspection_view.py +1783 -0
  353. meltygui/view/layout_view.py +596 -0
  354. meltygui/view/lut_view.py +45 -0
  355. meltygui/view/menu_view.py +212 -0
  356. meltygui/view/orchestration_view.py +658 -0
  357. meltygui/view/query_view.py +118 -0
  358. meltygui/view/search_view.py +342 -0
  359. meltygui/view/tab_view.py +258 -0
  360. meltygui/view/tensor_view.py +430 -0
  361. meltygui/view/terminal_view.py +355 -0
  362. meltygui/view/text_view.py +8031 -0
  363. meltygui/view/texture_view.py +480 -0
  364. meltygui/view/tile_view.py +38 -0
  365. meltygui/view/trace_view.py +923 -0
  366. meltygui/view/voxel_cuda_view.py +824 -0
  367. meltygui/view/voxel_view.py +1860 -0
  368. meltygui/view/window_view.py +111 -0
  369. meltygui-0.1.0.dist-info/METADATA +143 -0
  370. meltygui-0.1.0.dist-info/RECORD +372 -0
  371. meltygui-0.1.0.dist-info/WHEEL +4 -0
  372. meltygui-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,2209 @@
1
+ """Static "will it run" checks — undefined names + call-signature mismatches.
2
+
3
+ `check_source(text, path=None)` -> [(line, message)], 1-based lines aligned with
4
+ the buffer. Runs on chain_in's background thread AFTER libcst and compile()
5
+ both passed (see _run_chain_in), so the source is known syntactically valid and
6
+ this pass only adds the NameError / TypeError / AttributeError class of
7
+ mistakes those let through:
8
+
9
+ * a Name load that no enclosing scope, builtin, or live-module global binds
10
+ ("name 'myvarr' is not defined"). When the name is satisfiable by an
11
+ import — an importable top-level module, or a name other live modules got
12
+ from an import (`np`, `Path`, ...) — the message carries the exact fixing
13
+ statement after a "missing import:" marker, e.g.
14
+ "name 'np' is not defined — missing import: import numpy as np", so the
15
+ editor can both style it distinctly and offer the auto-import fix.
16
+ * a call to a function/class DEFINED IN THIS BUFFER whose arguments can't
17
+ bind (unknown kwarg, too many positionals, missing required args)
18
+ * the same signature check against LIVE objects — a bare imported name
19
+ (`request_render(1, 2, 3)`), a builtin (`isinstance(x)`), or a dotted
20
+ module attribute (`imgui.dummy()`), resolved through the running process's
21
+ module for `path`. C/Cython callables that hide their signature from
22
+ inspect fall back to the embedsignature doc line ("dummy(width, height)").
23
+ When the callee's DEFINING file has unsaved edits, the expected signature
24
+ comes from that file's pending text instead of the live object (which
25
+ reflects the last compile) — see the pending-truth signature section.
26
+ Span buffers (only_missing_imports) run the same call check through the
27
+ enclosing module's pending text (_check_call_span).
28
+ * a missing attribute on a live MODULE (`imgui.dummyy`) — chains only ever
29
+ walk through module objects, never arbitrary instances
30
+
31
+ Every rule errs on SILENCE — a missed problem beats a false alarm in an editor
32
+ that flags while you type:
33
+
34
+ * `from x import *` anywhere disables the name pass for the whole buffer
35
+ * signature checks skip decorated defs (a decorator can change the signature
36
+ arbitrarily — and @render_func always does), rebound/duplicated names, and
37
+ calls through anything we can't pin to a def in the buffer or a live object
38
+ * live objects carrying `__wrapped__` are skipped — inspect.signature follows
39
+ the wrap, but the wrapper may inject arguments (render_func's draw_state)
40
+ * a call using *args / **kwargs expansion skips the counts it makes unknowable
41
+ * annotations are never name-checked (string/forward refs, future-import)
42
+ * `'name' in globals()` guards bind the tested name; a try body whose except
43
+ catches NameError suppresses name checks, TypeError/AttributeError ones
44
+ suppress signature/attribute checks (probing is the handled case)
45
+ * missing-attr checks skip modules with a PEP 562 `__getattr__`, dotted paths
46
+ the buffer itself imports (`import a.b` ⇒ `a.b` will exist), and attrs the
47
+ buffer assigns (`mod.flag = True` earlier in the file)
48
+ * scope analysis is flow-insensitive: module-level code may legally use names
49
+ defined later (function bodies run later), so order is ignored
50
+
51
+ Name resolution follows Python's actual rule — local scope, enclosing FUNCTION
52
+ scopes (class scopes are invisible to nested functions), module, builtins —
53
+ plus one editor-specific fallback: the LIVE module's namespace when `path` maps
54
+ to an entry in sys.modules, so names a hotswap/exec injected at runtime don't
55
+ flag even though no static binding exists.
56
+ """
57
+
58
+ import ast
59
+ import builtins
60
+ import inspect
61
+ import os
62
+ import sys
63
+ import time
64
+ import types
65
+
66
+ from meltygui.core.diagnostics.notifications import lag_traced
67
+
68
+ # Names every module/frame sees without a visible binding.
69
+ _BUILTIN_NAMES = frozenset(dir(builtins)) | {
70
+ "__file__", "__name__", "__doc__", "__package__", "__spec__",
71
+ "__loader__", "__builtins__", "__debug__", "__path__", "__class__",
72
+ "__annotations__", "__dict__", "__module__", "__qualname__",
73
+ }
74
+
75
+ _MISS = object()
76
+
77
+
78
+ class _Scope:
79
+
80
+ def __init__(self, kind, parent):
81
+ self.kind = kind # 'module' | 'function' | 'class' | 'comp'
82
+ self.parent = parent
83
+ self.binds = set() # every name bound somewhere in this scope
84
+ self.import_binds = set() # names bound by an import statement
85
+ self.other_binds = set() # names bound by anything else
86
+ self.ambiguous = set() # rebound names - excluded from signature checks
87
+ self.defs = {} # name -> (FunctionDef, flavor) - undecorated, bound once
88
+ self.classes = {} # name -> (ClassDef, class _Scope) - undecorated, bound once
89
+ self.loads = [] # (name, lineno) Name loads made in this scope
90
+ self.global_names = set() # names declared `global` here
91
+ self.self_name = None # method scopes: the first arg ('self'/'cls')
92
+
93
+ def bind(self, name, is_import=False):
94
+ if name in self.binds:
95
+ # A second binding makes "which def is this name" unknowable for the
96
+ # signature pass; the name pass only needs set membership.
97
+ self.defs.pop(name, None)
98
+ self.classes.pop(name, None)
99
+ self.ambiguous.add(name)
100
+ else:
101
+ self.binds.add(name)
102
+ (self.import_binds if is_import else self.other_binds).add(name)
103
+
104
+
105
+ # Project decorator conventions, matched BY NAME (bare or called). This is a
106
+ # project-specific lint, so the names are trusted without resolving them:
107
+ # * transparent - leave the function UNCHANGED (window_decoration.window,
108
+ # core_decoration.defaults and app.glfw_window only register), so the
109
+ # def's own signature covers the calling code, in any order relative
110
+ # to @render_func.
111
+ # * wrapper — @render_func replaces the def with core_render's
112
+ # `wrapper(input_value=None, **kwargs)`: at most ONE positional, any
113
+ # kwarg accepted (modes/defaults/comment-args may fill required params,
114
+ # so only the positional shape is checkable).
115
+ _TRANSPARENT_DECORATORS = frozenset({"window", "defaults", "glfw_window"})
116
+ _WRAPPER_DECORATORS = frozenset({"render_func"})
117
+
118
+
119
+ def _decorator_name(dec):
120
+ """The bare name a decorator is spelled with (`@window` / `@window(...)`),
121
+ or None for anything dotted/complex."""
122
+ f = dec.func if isinstance(dec, ast.Call) else dec
123
+ return f.id if isinstance(f, ast.Name) else None
124
+
125
+
126
+ def _decorator_flavor(decorator_list):
127
+ """'plain' / 'static' / 'class' when the signature is still trustworthy,
128
+ None when a decorator could have changed it. Besides the two builtins,
129
+ transparent project decorators (@window) keep the def's real signature."""
130
+ if not decorator_list:
131
+ return "plain"
132
+ if len(decorator_list) == 1 and isinstance(decorator_list[0], ast.Name):
133
+ if decorator_list[0].id == "staticmethod":
134
+ return "static"
135
+ if decorator_list[0].id == "classmethod":
136
+ return "class"
137
+ if all(_decorator_name(d) in _TRANSPARENT_DECORATORS
138
+ for d in decorator_list):
139
+ return "plain"
140
+ return None
141
+
142
+
143
+ def _unwind_chain(node):
144
+ """node and its .value ancestry as (base Name, [(attr, node)…]) when the
145
+ whole chain is Name.attr.attr…, else (None, None)."""
146
+ attrs = []
147
+ cur = node
148
+ while isinstance(cur, ast.Attribute):
149
+ attrs.append((cur.attr, cur))
150
+ cur = cur.value
151
+ if isinstance(cur, ast.Name) and isinstance(cur.ctx, ast.Load):
152
+ attrs.reverse()
153
+ return cur, attrs
154
+ return None, None
155
+
156
+
157
+ def _handler_catches(handler_type, names):
158
+ if handler_type is None:
159
+ return True # bare except
160
+ if isinstance(handler_type, ast.Tuple):
161
+ return any(_handler_catches(e, names) for e in handler_type.elts)
162
+ return isinstance(handler_type, ast.Name) and handler_type.id in names
163
+
164
+
165
+ def _analysis_checkpoint():
166
+ """Let input/rendering run between bounded pieces of background analysis."""
167
+ import threading
168
+ if threading.current_thread() is threading.main_thread():
169
+ return
170
+ from meltygui.code.libcst_conversion import _yield_to_ui
171
+ _yield_to_ui() # also guards a non-main GL thread
172
+
173
+
174
+ def _analysis_matches(pattern, text):
175
+ import re
176
+ _analysis_checkpoint()
177
+ for index, match in enumerate(re.finditer(pattern, text)):
178
+ if index and index % 128 == 0:
179
+ _analysis_checkpoint()
180
+ yield match.group(1)
181
+
182
+
183
+ def _parse_for_analysis(text):
184
+ _analysis_checkpoint()
185
+ result = ast.parse(text)
186
+ _analysis_checkpoint()
187
+ return result
188
+
189
+
190
+ class _Collector:
191
+ """One pass over the tree building the scope graph + the check worklists."""
192
+
193
+ def __init__(self):
194
+ self._visited = 0
195
+ self.module = _Scope("module", None)
196
+ self.scopes = [self.module]
197
+ self.calls = [] # (Call node, scope, type_guarded)
198
+ self.attr_chains = [] # (base Name node, [(attr, node)...], scope, guarded)
199
+ self.declared_attrs = set() # dotted paths the buffer itself makes visible:
200
+ # `import a.b` / `from a.b import c` / `a.b = ...`
201
+ self.star_import = False
202
+ self.star_modules = [] # `from X import *` source module names
203
+ self._name_guard = 0 # >0 inside try guarded by except NameError
204
+ self._type_guard = 0 # >0 inside try guarded by except TypeError/AttributeError
205
+
206
+ def run(self, tree):
207
+ self._body(tree.body, self.module)
208
+
209
+ def __del__(self):
210
+ # The scope graph is cyclic: child.parent points up while
211
+ # parent.classes[name] = (ClassDef, child) points down - and defs /
212
+ # classes / calls hold ast nodes, so every pass left the ENTIRE parsed
213
+ # tree as cyclic garbage that only a full gc pass could reclaim (the
214
+ # gc profile of this session's boot collect: 680k objects, ~all ast.*
215
+ # nodes + their __dict__/body lists). Collectors are function-local
216
+ # everywhere, so cut the up-edges here and the tree frees by refcount
217
+ # the moment the pass returns.
218
+ for s in self.scopes:
219
+ s.parent = None
220
+
221
+ # ── plumbing ────────────────────────────────────────────────────────────
222
+ def _new_scope(self, kind, parent):
223
+ s = _Scope(kind, parent)
224
+ self.scopes.append(s)
225
+ return s
226
+
227
+ def _body(self, stmts, scope):
228
+ for stmt in stmts:
229
+ self._visit(stmt, scope)
230
+
231
+ def _visit(self, node, scope):
232
+ self._visited += 1
233
+ if self._visited % 128 == 0:
234
+ _analysis_checkpoint()
235
+ meth = getattr(self, "_v_" + type(node).__name__, None)
236
+ if meth is not None:
237
+ meth(node, scope)
238
+ return
239
+ for child in ast.iter_child_nodes(node):
240
+ self._visit(child, scope)
241
+
242
+ # ── scope makers ────────────────────────────────────────────────────────
243
+ def _function(self, node, scope, name=None):
244
+ flavor = _decorator_flavor(getattr(node, "decorator_list", []))
245
+ if name is not None:
246
+ scope.bind(name)
247
+ if flavor is not None and name not in scope.ambiguous:
248
+ scope.defs[name] = (node, flavor)
249
+ for dec in getattr(node, "decorator_list", []):
250
+ self._visit(dec, scope)
251
+ a = node.args
252
+ for default in list(a.defaults) + [d for d in a.kw_defaults if d is not None]:
253
+ self._visit(default, scope)
254
+ # node.returns / arg annotations deliberately not handled (see above).
255
+ child = self._new_scope("function", scope)
256
+ all_args = list(a.posonlyargs) + list(a.args) + list(a.kwonlyargs)
257
+ for arg in all_args:
258
+ child.bind(arg.arg)
259
+ for var in (a.vararg, a.kwarg):
260
+ if var is not None:
261
+ child.bind(var.arg)
262
+ if scope.kind == "class" and flavor == "plain" and all_args:
263
+ child.self_name = all_args[0].arg
264
+ body = node.body if isinstance(node.body, list) else [node.body]
265
+ self._body(body, child)
266
+
267
+ def _v_FunctionDef(self, node, scope):
268
+ self._function(node, scope, name=node.name)
269
+
270
+ def _v_AsyncFunctionDef(self, node, scope):
271
+ self._function(node, scope, name=node.name)
272
+
273
+ def _v_Lambda(self, node, scope):
274
+ self._function(node, scope)
275
+
276
+ def _v_ClassDef(self, node, scope):
277
+ scope.bind(node.name)
278
+ for dec in node.decorator_list:
279
+ self._visit(dec, scope)
280
+ for base in list(node.bases) + list(node.keywords):
281
+ self._visit(base, scope)
282
+ child = self._new_scope("class", scope)
283
+ if not node.decorator_list and node.name not in scope.ambiguous:
284
+ scope.classes[node.name] = (node, child)
285
+ self._body(node.body, child)
286
+
287
+ def _comp(self, node, scope):
288
+ child = self._new_scope("comp", scope)
289
+ first = True
290
+ for gen in node.generators:
291
+ self._visit(gen.iter, scope if first else child)
292
+ first = False
293
+ self._visit(gen.target, child)
294
+ for cond in gen.ifs:
295
+ self._visit(cond, child)
296
+ for field in ("elt", "key", "value"):
297
+ sub = getattr(node, field, None)
298
+ if sub is not None:
299
+ self._visit(sub, child)
300
+
301
+ _v_ListComp = _v_SetComp = _v_GeneratorExp = _v_DictComp = _comp
302
+
303
+ def _try(self, node, scope):
304
+ # Code in a guarded try body is PROBING - that's the handled case, not
305
+ # a bug. except NameError guards the name pass; except TypeError /
306
+ # AttributeError guard the signature / missing-attr pass. The broad
307
+ # handlers guard everything.
308
+ broad = ("Exception", "BaseException")
309
+ name_guarded = any(_handler_catches(h.type, ("NameError",) + broad)
310
+ for h in node.handlers)
311
+ type_guarded = any(_handler_catches(h.type, ("TypeError", "AttributeError") + broad)
312
+ for h in node.handlers)
313
+ self._name_guard += name_guarded
314
+ self._type_guard += type_guarded
315
+ self._body(node.body, scope)
316
+ self._name_guard -= name_guarded
317
+ self._type_guard -= type_guarded
318
+ for h in node.handlers:
319
+ self._visit(h, scope)
320
+ self._body(node.orelse, scope)
321
+ self._body(node.finalbody, scope)
322
+
323
+ _v_Try = _v_TryStar = _try
324
+
325
+ # ── binders and loads ─────────────────────────────────────────────────────
326
+ def _v_Name(self, node, scope):
327
+ if isinstance(node.ctx, ast.Load):
328
+ if not self._name_guard:
329
+ scope.loads.append((node.id, node.lineno))
330
+ else:
331
+ scope.bind(node.id)
332
+ if node.id in scope.global_names:
333
+ self.module.bind(node.id)
334
+
335
+ def _v_Global(self, node, scope):
336
+ scope.global_names.update(node.names)
337
+ for name in node.names:
338
+ # Permissive both ways: resolvable here, and at module level (this
339
+ # function may be the module-level name's only definer).
340
+ scope.bind(name)
341
+ self.module.bind(name)
342
+
343
+ def _v_Nonlocal(self, node, scope):
344
+ # compile() already rejected a nonlocal with no enclosing binding.
345
+ for name in node.names:
346
+ scope.bind(name)
347
+
348
+ def _v_Import(self, node, scope):
349
+ for alias in node.names:
350
+ scope.bind(alias.asname or alias.name.partition(".")[0],
351
+ is_import=True)
352
+ # `import a.b.c` guarantees a.b and a.b.c exist as attributes.
353
+ parts = alias.name.split(".")
354
+ for i in range(1, len(parts) + 1):
355
+ self.declared_attrs.add(".".join(parts[:i]))
356
+
357
+ def _v_ImportFrom(self, node, scope):
358
+ if node.module and not node.level:
359
+ parts = node.module.split(".")
360
+ for i in range(1, len(parts) + 1):
361
+ self.declared_attrs.add(".".join(parts[:i]))
362
+ for alias in node.names:
363
+ if alias.name == "*":
364
+ self.star_import = True
365
+ # Which module the * came from (None for relative imports) -
366
+ # _module_text_binds resolves the export list through the
367
+ # LIVE module so a star import doesn't force the whole
368
+ # binds answer to "unknowable".
369
+ self.star_modules.append(node.module if not node.level else None)
370
+ else:
371
+ scope.bind(alias.asname or alias.name, is_import=True)
372
+ if node.module and not node.level:
373
+ # `from a.b import c` makes c an attribute of a.b too.
374
+ self.declared_attrs.add(f"{node.module}.{alias.name}")
375
+
376
+ def _v_ExceptHandler(self, node, scope):
377
+ if node.name:
378
+ scope.bind(node.name)
379
+ if node.type is not None:
380
+ self._visit(node.type, scope)
381
+ self._body(node.body, scope)
382
+
383
+ def _v_NamedExpr(self, node, scope):
384
+ # A walrus binds in the nearest enclosing non-comprehension scope; bind
385
+ # in the comp scope too so later use inside the same comp resolves.
386
+ target = scope
387
+ while target.kind == "comp":
388
+ target = target.parent
389
+ target.bind(node.target.id)
390
+ if scope is not target:
391
+ scope.bind(node.target.id)
392
+ self._visit(node.value, scope)
393
+
394
+ def _v_AnnAssign(self, node, scope):
395
+ self._visit(node.target, scope) # binds (Store ctx)
396
+ if node.value is not None:
397
+ self._visit(node.value, scope)
398
+ # node.annotation deliberately ignored.
399
+
400
+ def _v_MatchAs(self, node, scope):
401
+ if node.name:
402
+ scope.bind(node.name)
403
+ if node.pattern is not None:
404
+ self._visit(node.pattern, scope)
405
+
406
+ def _v_MatchStar(self, node, scope):
407
+ if node.name:
408
+ scope.bind(node.name)
409
+
410
+ def _v_MatchMapping(self, node, scope):
411
+ if node.rest:
412
+ scope.bind(node.rest)
413
+ for child in ast.iter_child_nodes(node):
414
+ self._visit(child, scope)
415
+
416
+ def _v_TypeAlias(self, node, scope): # py3.12 `type X = ...` - annotation-like
417
+ if isinstance(node.name, ast.Name):
418
+ scope.bind(node.name.id)
419
+
420
+ def _v_Call(self, node, scope):
421
+ self.calls.append((node, scope, self._type_guard > 0))
422
+ for child in ast.iter_child_nodes(node):
423
+ self._visit(child, scope)
424
+
425
+ def _v_Attribute(self, node, scope):
426
+ if isinstance(node.ctx, (ast.Store, ast.Del)):
427
+ # `obj.flag = True` makes the attr exist at runtime - declare the
428
+ # whole dotted path so a later LOAD of it doesn't flag as missing.
429
+ base, attrs = _unwind_chain(node.value)
430
+ if base is not None:
431
+ self.declared_attrs.add(
432
+ ".".join([base.id] + [a for a, _ in attrs] + [node.attr]))
433
+ self._visit(node.value, scope)
434
+ return
435
+ base, attrs = _unwind_chain(node)
436
+ if base is not None:
437
+ self.attr_chains.append(
438
+ (base, attrs, scope, self._name_guard > 0 or self._type_guard > 0))
439
+ # We don't recurse into the chain, so record the base node here.
440
+ if not self._name_guard:
441
+ scope.loads.append((base.id, base.lineno))
442
+ return
443
+ for child in ast.iter_child_nodes(node):
444
+ self._visit(child, scope)
445
+
446
+ def _v_Compare(self, node, scope):
447
+ # `'name' in globals()` is a deliberate is-it-re-bound-yet guard
448
+ # (latent_descent's server hooks). Treat the tested name as module-bound
449
+ # so the guarded load it gates doesn't flag.
450
+ if (len(node.ops) == 1 and isinstance(node.ops[0], ast.In)
451
+ and isinstance(node.left, ast.Constant)
452
+ and isinstance(node.left.value, str)
453
+ and node.left.value.isidentifier()
454
+ and isinstance(node.comparators[0], ast.Call)
455
+ and isinstance(node.comparators[0].func, ast.Name)
456
+ and node.comparators[0].func.id in ("globals", "locals", "vars", "dir")):
457
+ self.module.bind(node.left.value)
458
+ for child in ast.iter_child_nodes(node):
459
+ self._visit(child, scope)
460
+
461
+
462
+ # ── name resolution ──────────────────────────────────────────────────────────
463
+
464
+ def _resolves(scope, name):
465
+ """Python's lookup rule: own scope, then enclosing scopes SKIPPING class
466
+ scopes (a class body is invisible to the functions nested inside it)."""
467
+ own = True
468
+ s = scope
469
+ while s is not None:
470
+ if (own or s.kind != "class") and name in s.binds:
471
+ return True
472
+ own = False
473
+ s = s.parent
474
+ return False
475
+
476
+
477
+ def _binding_scope(scope, name):
478
+ """The scope whose binding a load of `name` would see, or None."""
479
+ own = True
480
+ s = scope
481
+ while s is not None:
482
+ if (own or s.kind != "class") and name in s.binds:
483
+ return s
484
+ own = False
485
+ s = s.parent
486
+ return None
487
+
488
+
489
+ def _lookup_def(scope, name):
490
+ """The def/class a bare-name call statically pins to, or None. The FIRST
491
+ scope binding the name decides (a nearer non-def binding shadows an outer
492
+ def → unknowable → None). Class-scope defs are skipped except from the
493
+ class body itself — a bare-name method call is an unbound call whose
494
+ `self` convention we can't assume."""
495
+ s = _binding_scope(scope, name)
496
+ if s is None or name in s.ambiguous:
497
+ return None
498
+ if name in s.defs and s.kind != "class":
499
+ return ("func", *s.defs[name])
500
+ if name in s.classes:
501
+ return ("cls", *s.classes[name])
502
+ return None
503
+
504
+
505
+ def _enclosing_method_class(scope, base_name):
506
+ """For a `self.m(...)` call: the class scope owning the method we're inside,
507
+ if `base_name` is that method's first arg. None otherwise."""
508
+ s = scope
509
+ while s is not None:
510
+ if s.kind == "function" and s.self_name == base_name:
511
+ parent = s.parent
512
+ return parent if parent is not None and parent.kind == "class" else None
513
+ if s.kind == "function" and s.self_name is None:
514
+ return None # an inner plain def shadows the method's self
515
+ s = s.parent
516
+ return None
517
+
518
+
519
+ # ── call-signature matching ──────────────────────────────────────────────
520
+
521
+ class _Spec:
522
+ """One callable's parameters, however we learned them (buffer ast,
523
+ inspect.signature, or a Cython embedsignature doc line)."""
524
+
525
+ def __init__(self):
526
+ self.named = [] # positional(-or-keyword) names, in order
527
+ self.n_posonly = 0 # leading slice of `named` not passable by kw
528
+ self.required = set() # names with no default (positional + kwonly)
529
+ self.kwonly = []
530
+ self.kw_required = set()
531
+ self.has_var_pos = False
532
+ self.has_var_kw = False
533
+ self.types = {} # name -> 'float'|'int'|'str'|'bool', only
534
+ # where known (doc C type / annotation)
535
+
536
+
537
+ def _spec_from_arguments(a, skip_first=0):
538
+ spec = _Spec()
539
+ named = [x.arg for x in a.posonlyargs] + [x.arg for x in a.args]
540
+ n_posonly = len(a.posonlyargs)
541
+ if skip_first:
542
+ if not named:
543
+ return None # *args-only def used as a method - fine
544
+ named = named[1:]
545
+ n_posonly = max(0, n_posonly - 1)
546
+ spec.named = named
547
+ spec.n_posonly = n_posonly
548
+ spec.required = set(named[:len(named) - len(a.defaults)])
549
+ spec.kwonly = [x.arg for x in a.kwonlyargs]
550
+ spec.kw_required = {x.arg for x, d in zip(a.kwonlyargs, a.kw_defaults) if d is None}
551
+ spec.required |= spec.kw_required
552
+ spec.has_var_pos = a.vararg is not None
553
+ spec.has_var_kw = a.kwarg is not None
554
+ for arg in list(a.posonlyargs) + list(a.args) + list(a.kwonlyargs):
555
+ ann = arg.annotation
556
+ if isinstance(ann, ast.Name) and ann.id in _CHECKED_TYPES:
557
+ spec.types[arg.arg] = ann.id
558
+ return spec
559
+
560
+
561
+ def _spec_from_signature(sig):
562
+ P = inspect.Parameter
563
+ spec = _Spec()
564
+ for p in sig.parameters.values():
565
+ ann = p.annotation
566
+ if isinstance(ann, type) and ann.__name__ in _CHECKED_TYPES:
567
+ spec.types[p.name] = ann.__name__
568
+ elif isinstance(ann, str) and ann in _CHECKED_TYPES:
569
+ spec.types[p.name] = ann
570
+ if p.kind in (P.POSITIONAL_ONLY, P.POSITIONAL_OR_KEYWORD):
571
+ spec.named.append(p.name)
572
+ if p.kind == P.POSITIONAL_ONLY:
573
+ spec.n_posonly = len(spec.named)
574
+ if p.default is P.empty:
575
+ spec.required.add(p.name)
576
+ elif p.kind == P.KEYWORD_ONLY:
577
+ spec.kwonly.append(p.name)
578
+ if p.default is P.empty:
579
+ spec.kw_required.add(p.name)
580
+ spec.required.add(p.name)
581
+ elif p.kind == P.VAR_POSITIONAL:
582
+ spec.has_var_pos = True
583
+ elif p.kind == P.VAR_KEYWORD:
584
+ spec.has_var_kw = True
585
+ return spec
586
+
587
+
588
+ def _split_top_level(text):
589
+ parts, depth, start = [], 0, 0
590
+ for i, ch in enumerate(text):
591
+ if ch in "([{":
592
+ depth += 1
593
+ elif ch in ")]}":
594
+ depth -= 1
595
+ elif ch == "," and depth == 0:
596
+ parts.append(text[start:i])
597
+ start = i + 1
598
+ parts.append(text[start:])
599
+ return parts
600
+
601
+
602
+ def _spec_from_doc(fname, doc):
603
+ """Cython embedsignature fallback: pyimgui-style C functions raise on
604
+ inspect.signature but lead their docstring with `dummy(width, height)` /
605
+ `arrow_button(str label, ImGuiDir direction=DIRECTION_NONE)`. Parse that
606
+ line into a spec; param tokens may carry a C type prefix (drop it). Anything
607
+ that doesn't look exactly like a signature line → None (no check)."""
608
+ if not doc:
609
+ return None
610
+ lines = doc.strip().split("\n")
611
+ line = lines[0].strip()
612
+ # The doc line may sign itself with an ALIAS TARGET's name - pyimgui's
613
+ # set_cursor_position shares set_cursor_pos's doc ("set_cursor_pos(
614
+ # local_position)") - so accept any identifier-headed signature line and use
615
+ # ITS name for the overload scan; `fname` stays the alias's spelling for
616
+ # messages. Prose first lines fail the identifier/paren test here or the
617
+ # strict per-piece validation below.
618
+ docname = line.partition("(")[0].strip()
619
+ if not docname.isidentifier() or not line.startswith(docname + "("):
620
+ return None
621
+ # Polymorphic callables document each overload on its own line - bare
622
+ # ("slice(stop)" / "slice(start, stop[, step])") or directive-marked the way
623
+ # torch does (".. function:: mean(input, dim, ...)"). The first line alone
624
+ # is a lie, so any bare overload line disqualifies the whole fallback.
625
+ for more in lines[1:]:
626
+ more = more.strip()
627
+ for marker in (".. function::", ".. method::"):
628
+ if more.startswith(marker):
629
+ more = more[len(marker):].strip()
630
+ if more.startswith(docname + "("):
631
+ return None
632
+ # Take exactly the BALANCED paren group after the name - torch-style lines
633
+ # carry a return annotation after it ("sort(input, ...) -> (Tensor,
634
+ # LongTensor)") that must not leak into the spec.
635
+ head = line[len(docname):]
636
+ inner = tail = None
637
+ depth = 0
638
+ for i, ch in enumerate(head):
639
+ if ch in "([{":
640
+ depth += 1
641
+ elif ch in ")]}":
642
+ depth -= 1
643
+ if depth == 0:
644
+ inner = head[1:i].strip()
645
+ tail = head[i + 1:].strip()
646
+ break
647
+ if inner is None or (tail and not tail.startswith("->")):
648
+ return None
649
+ spec = _Spec()
650
+ if not inner:
651
+ return spec
652
+ kwonly = False
653
+ for piece in _split_top_level(inner):
654
+ piece = piece.strip()
655
+ if not piece or piece == "...":
656
+ return None # can't check a partial signature
657
+ if piece == "/":
658
+ spec.n_posonly = len(spec.named)
659
+ continue
660
+ if piece == "*":
661
+ kwonly = True
662
+ continue
663
+ if piece.startswith("**"):
664
+ spec.has_var_kw = True
665
+ continue
666
+ if piece.startswith("*"):
667
+ # Native docstrings use variadic notation for overload families,
668
+ # not necessarily Python binding rules: torch.rand(*size) also
669
+ # accepts size=(...). Without a real signature we cannot safely
670
+ # reject keywords or infer collisions/required arguments.
671
+ return None
672
+ name_part = piece.split("=", 1)[0].strip()
673
+ tokens = name_part.split()
674
+ pname = tokens[-1] if tokens else ""
675
+ if not pname.isidentifier():
676
+ return None
677
+ if len(tokens) >= 2:
678
+ # C type prefix ("float position") - convert the ones the literal
679
+ # type check knows; unknown types simply aren't checked.
680
+ t = _DOC_TYPE_MAP.get(tokens[-2].lstrip("*"))
681
+ if t is not None:
682
+ spec.types[pname] = t
683
+ has_default = "=" in piece
684
+ if kwonly:
685
+ spec.kwonly.append(pname)
686
+ if not has_default:
687
+ spec.kw_required.add(pname)
688
+ spec.required.add(pname)
689
+ else:
690
+ # A required positional AFTER a default one is illegal in real
691
+ # Python - the doc line is describing overload shorthand (torch's
692
+ # "arange(start=0, end, step=1)"), not a signature. Trust nothing.
693
+ if not has_default and any(n not in spec.required for n in spec.named):
694
+ return None
695
+ spec.named.append(pname)
696
+ if not has_default:
697
+ spec.required.add(pname)
698
+ return spec
699
+
700
+
701
+ def _match_spec(fname, spec, call):
702
+ """One mismatch message, or None. Mirrors CPython's binding rules but
703
+ checks only what the call makes knowable: *args in the call hides
704
+ positional counts, ** hides keyword coverage."""
705
+ # A *splat normally makes the positional count unknowable - EXCEPT a
706
+ # literal tuple/list (`f(*(0, 0))`), whose count is right there.
707
+ star_args = False
708
+ npos = 0
709
+ for x in call.args:
710
+ if isinstance(x, ast.Starred):
711
+ v = x.value
712
+ if (isinstance(v, (ast.Tuple, ast.List))
713
+ and not any(isinstance(e, ast.Starred) for e in v.elts)):
714
+ npos += len(v.elts)
715
+ else:
716
+ star_args = True
717
+ else:
718
+ npos += 1
719
+ kw_expand = any(k.arg is None for k in call.keywords)
720
+ kw_names = [k.arg for k in call.keywords if k.arg is not None]
721
+
722
+ if not spec.has_var_kw:
723
+ allowed = set(spec.named[spec.n_posonly:]) | set(spec.kwonly)
724
+ for k in kw_names:
725
+ if k not in allowed:
726
+ return f"{fname}() got an unexpected keyword argument '{k}'"
727
+
728
+ if star_args:
729
+ return None # positional count unknowable from here on
730
+
731
+ if npos > len(spec.named) and not spec.has_var_pos:
732
+ plural = "s" if len(spec.named) != 1 else ""
733
+ return (f"{fname}() takes {len(spec.named)} positional argument{plural} "
734
+ f"but {npos} were given")
735
+
736
+ consumed = set(spec.named[:npos])
737
+ for k in kw_names:
738
+ if k in consumed:
739
+ return f"{fname}() got multiple values for argument '{k}'"
740
+
741
+ if not kw_expand:
742
+ kw_set = set(kw_names)
743
+ missing = [p for i, p in enumerate(spec.named)
744
+ if i >= npos and p in spec.required and p not in kw_set]
745
+ missing += [k for k in spec.kwonly if k in spec.kw_required and k not in kw_set]
746
+ if missing:
747
+ listed = ", ".join(f"'{m}'" for m in missing)
748
+ plural = "s" if len(missing) != 1 else ""
749
+ return f"{fname}() missing required argument{plural}: {listed}"
750
+ return _literal_type_mismatch(fname, spec, call)
751
+
752
+
753
+ # Declared param types the literal check knows, and the literal types each
754
+ # accepts. Deliberately narrower than Python's coercion rules: bool→float is
755
+ # LEGAL at runtime (bool is an int) but `same_line(False)` is a bug every
756
+ # time it's written, so numeric params reject bool literals. None literals
757
+ # are never checked (Optional params are indistinguishable statically).
758
+ _CHECKED_TYPES = frozenset({"float", "int", "str", "bool"})
759
+ _TYPE_ACCEPTS = {
760
+ "float": ("float", "int"),
761
+ "int": ("int",),
762
+ "str": ("str",),
763
+ "bool": ("bool",),
764
+ }
765
+ # Doc C-type spellings → the checked type they mean; anything else unchecked.
766
+ _DOC_TYPE_MAP = {
767
+ "float": "float", "double": "float",
768
+ "int": "int", "long": "int", "short": "int", "unsigned": "int",
769
+ "size_t": "int", "Py_ssize_t": "int",
770
+ "bool": "bool", "bint": "bool",
771
+ "str": "str", "string": "str",
772
+ }
773
+
774
+
775
+ def _literal_arg_type(node):
776
+ """'bool'/'int'/'float'/'str' when the argument is that LITERAL (unary
777
+ +/- kept for numbers), else None — expressions, names, calls and None
778
+ literals are never type-checked."""
779
+ if isinstance(node, ast.Constant):
780
+ v = node.value
781
+ if v is True or v is False:
782
+ return "bool" # before int - bool subclasses int
783
+ if isinstance(v, float):
784
+ return "float"
785
+ if isinstance(v, int):
786
+ return "int"
787
+ if isinstance(v, str):
788
+ return "str"
789
+ return None
790
+ if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.USub, ast.UAdd)):
791
+ t = _literal_arg_type(node.operand)
792
+ return t if t in ("int", "float") else None
793
+ return None
794
+
795
+
796
+ def _literal_type_mismatch(fname, spec, call):
797
+ """A wrong-TYPE message for a LITERAL argument against a DECLARED param
798
+ type (doc C type or float/int/str/bool annotation), or None. Runs last in
799
+ _match_spec, so the call already binds; only (declared, literal) pairs
800
+ the tables above know are judged — everything else is silence."""
801
+ if not spec.types:
802
+ return None
803
+ try:
804
+ from meltygui.core.runtime.toggles import Toggles
805
+ if not Toggles.TextEditor.lint_literal_types:
806
+ return None
807
+ except Exception:
808
+ pass
809
+ flat = [] # positional args incl. any splats
810
+ for x in call.args:
811
+ if isinstance(x, ast.Starred):
812
+ flat.extend(x.value.elts) # unknowable splats bailed earlier
813
+ else:
814
+ flat.append(x)
815
+ pairs = list(zip(spec.named, flat))
816
+ pairs += [(k.arg, k.value) for k in call.keywords
817
+ if k.arg is not None and k.arg in spec.types]
818
+ for pname, node in pairs:
819
+ want = spec.types.get(pname)
820
+ if want is None:
821
+ continue
822
+ got = _literal_arg_type(node)
823
+ if got is None or got in _TYPE_ACCEPTS[want]:
824
+ continue
825
+ if want == "int" and got == "float":
826
+ # An INTEGRAL float literal (drag_int(min_value=0.0)) coerces
827
+ # losslessly and is common working code - only a fractional
828
+ # literal (2.5) is provably wrong.
829
+ try:
830
+ v = ast.literal_eval(node)
831
+ except Exception:
832
+ continue
833
+ if isinstance(v, float) and v.is_integer():
834
+ continue
835
+ return f"{fname}() expected {want} for '{pname}', got {got}"
836
+ return None
837
+
838
+
839
+ def _check_call_static(call, scope):
840
+ """Signature check against defs/classes in the BUFFER. Returns the message
841
+ or None; sets nothing aside — a None just means 'nothing provably wrong'."""
842
+ func = call.func
843
+ if isinstance(func, ast.Name):
844
+ spec_info = _lookup_def(scope, func.id)
845
+ if spec_info is None:
846
+ return None
847
+ if spec_info[0] == "func":
848
+ _, node, flavor = spec_info
849
+ spec = _spec_from_arguments(node.args,
850
+ skip_first=1 if flavor == "class" else 0)
851
+ return _match_spec(func.id, spec, call) if spec else None
852
+ _, node, cls_scope = spec_info
853
+ init = cls_scope.defs.get("__init__")
854
+ if init is not None and init[1] == "plain":
855
+ spec = _spec_from_arguments(init[0].args, skip_first=1)
856
+ return _match_spec(func.id, spec, call) if spec else None
857
+ if (init is None and not node.bases and not node.keywords
858
+ and "__init__" not in cls_scope.binds
859
+ and "__new__" not in cls_scope.binds):
860
+ if call.args or call.keywords:
861
+ return f"{func.id}() takes no arguments"
862
+ return None
863
+ if isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name):
864
+ cls_scope = _enclosing_method_class(scope, func.value.id)
865
+ if cls_scope is None or func.attr in cls_scope.ambiguous:
866
+ return None
867
+ method = cls_scope.defs.get(func.attr)
868
+ if method is None:
869
+ return None # not defined directly - could be inherited
870
+ node, flavor = method
871
+ spec = _spec_from_arguments(node.args,
872
+ skip_first=0 if flavor == "static" else 1)
873
+ return _match_spec(func.attr, spec, call) if spec else None
874
+ return None
875
+
876
+
877
+ # ── live-module resolution ───────────────────────────────────────────────────
878
+
879
+ class _LiveCtx:
880
+
881
+ def __init__(self, module, declared):
882
+ try:
883
+ self.ns = dict(vars(module)) if module is not None else None
884
+ except TypeError:
885
+ self.ns = None
886
+ self.declared = declared
887
+
888
+
889
+ def _module_for(path):
890
+ """The live module object whose __file__ is `path`, or None — covers
891
+ hotswap/exec-injected globals no static binding accounts for. The same
892
+ file can appear in sys.modules under SEVERAL names (the studio holds both
893
+ `src.lsd...` and `lsd...` aliases, one of them a barely-initialized stub) —
894
+ take the match with the richest namespace, that's the one that actually
895
+ ran."""
896
+ if not path:
897
+ return None
898
+ target = str(path)
899
+ try:
900
+ target_real = os.path.realpath(target)
901
+ except OSError:
902
+ target_real = target
903
+ best = None
904
+ best_size = -1
905
+ for mod in list(sys.modules.values()):
906
+ f = getattr(mod, "__file__", None)
907
+ if f is not None and (f == target or f == target_real):
908
+ try:
909
+ size = len(vars(mod))
910
+ except TypeError:
911
+ continue
912
+ if size > best_size:
913
+ best, best_size = mod, size
914
+ return best
915
+
916
+
917
+ def _import_only(scope, name):
918
+ """True when the binding a load would see comes ONLY from import statements
919
+ — the one case where the live module's value for the name is trustworthy
920
+ (an assignment/def in the buffer may not match the running process yet)."""
921
+ s = _binding_scope(scope, name)
922
+ return (s is not None and name in s.import_binds
923
+ and name not in s.other_binds)
924
+
925
+
926
+ def _resolve_live_base(ctx, scope, name):
927
+ if ctx.ns is None or not _import_only(scope, name):
928
+ return _MISS
929
+ return ctx.ns.get(name, _MISS)
930
+
931
+
932
+ def _walk_chain(ctx, scope, base, attrs):
933
+ """Resolve base.attr.attr… through LIVE objects. Returns (object, report):
934
+ object is _MISS when unresolvable, report is a (line, msg) missing-attr
935
+ finding. Only ever steps through MODULE objects — instances can hide
936
+ anything behind descriptors/__getattr__, so we stop (silently) at them
937
+ unless they're the final resolved value."""
938
+ obj = _resolve_live_base(ctx, scope, base.id)
939
+ if obj is _MISS:
940
+ return _MISS, None
941
+ dotted = base.id
942
+ for attr, node in attrs:
943
+ if not isinstance(obj, types.ModuleType):
944
+ return _MISS, None
945
+ dotted += "." + attr
946
+ nxt = inspect.getattr_static(obj, attr, _MISS)
947
+ if nxt is _MISS:
948
+ if dotted in ctx.declared or "__getattr__" in vars(obj):
949
+ return _MISS, None # buffer imports/assigns it, or lazy module
950
+ return _MISS, (node.lineno,
951
+ f"module '{obj.__name__}' has no attribute '{attr}'")
952
+ obj = nxt
953
+ return obj, None
954
+
955
+
956
+ def _is_cython_function(obj):
957
+ """Cython 3 compiles `def` to its own function type (introspectable, unlike
958
+ Cython 0.29's builtin functions); the embedded doc signature still carries
959
+ the C types."""
960
+ return type(obj).__name__ == "cython_function_or_method"
961
+
962
+
963
+ def _live_spec(obj, fname):
964
+ if hasattr(obj, "__wrapped__"):
965
+ return None # the wrapper may inject args (@render_func)
966
+ if _is_cython_function(obj):
967
+ spec = _spec_from_doc(fname, getattr(obj, "__doc__", None))
968
+ if spec is not None:
969
+ return spec
970
+ try:
971
+ return _spec_from_signature(inspect.signature(obj))
972
+ except (ValueError, TypeError):
973
+ # C/Cython callable hiding its signature - pyimgui embeds it in the
974
+ # docstring's first line instead. Real function objects only: a
975
+ # callable INSTANCE hiding its signature (PyOpenGL's glDrawBuffers
976
+ # wrapper) documents C args that parse into the wrong arity.
977
+ if not isinstance(obj, (types.BuiltinFunctionType, types.MethodType)):
978
+ return None
979
+ return _spec_from_doc(fname, getattr(obj, "__doc__", None))
980
+
981
+
982
+ def _check_call_live(ctx, call, scope):
983
+ """Signature check against the LIVE object a call resolves to: a bare
984
+ import-bound name, a builtin, or a dotted module-attribute chain."""
985
+ func = call.func
986
+ obj, fname = _MISS, None
987
+ if isinstance(func, ast.Name):
988
+ fname = func.id
989
+ obj = _resolve_live_base(ctx, scope, fname)
990
+ if obj is _MISS and not _resolves(scope, fname):
991
+ obj = getattr(builtins, fname, _MISS) # len(), isinstance(x), ...
992
+ elif isinstance(func, ast.Attribute):
993
+ base, attrs = _unwind_chain(func)
994
+ if base is not None:
995
+ obj, _ = _walk_chain(ctx, scope, base, attrs)
996
+ fname = attrs[-1][0]
997
+ if obj is _MISS or not callable(obj):
998
+ return None
999
+ spec = _object_spec(obj, fname)
1000
+ return _match_spec(fname, spec, call) if spec is not None else None
1001
+
1002
+
1003
+ # ── import suggestions - a SEPARATE channel from errors ─────────────────────
1004
+ #
1005
+ # These are what the editor's quick-fix reads ({line: [statements]},
1006
+ # built by collect_import_suggestions below), never encoded into error
1007
+ # messages: an error describes what's wrong ("expected NAME", "name 'json' is
1008
+ # not defined") whereas suggestions propose a fix, and the two travel side by
1009
+ # side through the chain payload (`lint` vs `imports`).
1010
+
1011
+ # How many candidate statements a symbol gets (the editor shows them in a
1012
+ # dropdown; past a handful they're meaningless, their choice).
1013
+ _MAX_IMPORT_CANDIDATES = 4
1014
+
1015
+ _import_suggestion_cache = {} # name -> [import statements] (possibly empty)
1016
+
1017
+
1018
+ def _suggest_import(name):
1019
+ """The import statements that would bind `name`, best-ranked first — []
1020
+ when nothing importable answers to it (then it's just a typo/unassigned
1021
+ variable).
1022
+
1023
+ Three probes, cheapest first, results cached per name:
1024
+ * `name` is a top-level module already loaded in this process
1025
+ * other LIVE modules bind `name` — to a module (`np` → numpy ⇒
1026
+ `import numpy as np`) or to an object its defining module really
1027
+ exports (`Path` ⇒ `from pathlib import Path`); candidates rank by how
1028
+ many live modules vote for them
1029
+ * `name` is an importable-but-not-yet-loaded module (find_spec — path
1030
+ search only, nothing executes)
1031
+ """
1032
+ if name in _import_suggestion_cache:
1033
+ return _import_suggestion_cache[name]
1034
+ stmts = []
1035
+ if name in sys.modules and "." not in name:
1036
+ stmts.append(f"import {name}")
1037
+ votes = {}
1038
+ for mod in list(sys.modules.values()):
1039
+ try:
1040
+ obj = vars(mod).get(name, _MISS)
1041
+ except TypeError:
1042
+ continue
1043
+ if obj is _MISS:
1044
+ continue
1045
+ if isinstance(obj, types.ModuleType):
1046
+ top = obj.__name__
1047
+ if top.endswith("." + name) and (stmts
1048
+ or top == f"{getattr(mod, '__name__', '')}.{name}"):
1049
+ # A nested `*.json`-style module, either the binder's own
1050
+ # submodule attribute (datasets.utils.json - not an alias
1051
+ # vote), or trumped by the exact top-level module when one
1052
+ # exists (`import json` beats any wrapper of it).
1053
+ continue
1054
+ cand = (f"import {top}" if top == name
1055
+ else f"import {top} as {name}")
1056
+ else:
1057
+ owner = getattr(obj, "__module__", None)
1058
+ owner_mod = sys.modules.get(owner) if owner else None
1059
+ if (owner_mod is None
1060
+ or getattr(owner_mod, name, _MISS) is not obj):
1061
+ continue # not really importable as `name` from there
1062
+ cand = f"from {owner} import {name}"
1063
+ votes[cand] = votes.get(cand, 0) + 1
1064
+ for cand, _n in sorted(votes.items(), key=lambda kv: -kv[1]):
1065
+ if cand not in stmts:
1066
+ stmts.append(cand)
1067
+ if not stmts:
1068
+ import importlib.util
1069
+ try:
1070
+ if "." not in name and importlib.util.find_spec(name) is not None:
1071
+ stmts.append(f"import {name}")
1072
+ except Exception:
1073
+ pass
1074
+ stmts = stmts[:_MAX_IMPORT_CANDIDATES]
1075
+ _import_suggestion_cache[name] = stmts
1076
+ return stmts
1077
+
1078
+
1079
+ _project_import_suggestion_cache = {}
1080
+
1081
+
1082
+ def invalidate_import_bindings(path=None):
1083
+ """An explicit import edit affects uses in every block, not just its own line."""
1084
+ keys = {None, str(path) if path else None}
1085
+ if path is not None:
1086
+ from pathlib import Path
1087
+ keys.add(str(Path(path).resolve()))
1088
+ for key in keys:
1089
+ _file_binds_cache.pop(key, None)
1090
+ _inc_scan_state.pop(key, None)
1091
+ for span in (False, True):
1092
+ _inc_lint_state.pop((key, span), None)
1093
+
1094
+
1095
+ def _suggest_import_for_path(name, path, cached_only=False):
1096
+ if path is None:
1097
+ return _import_suggestion_cache.get(name) if cached_only else _suggest_import(name)
1098
+ key = (str(path), name)
1099
+ if cached_only:
1100
+ return _project_import_suggestion_cache.get(key)
1101
+ from meltygui.core.runtime.extensions import get
1102
+ provider = get('source_imports')
1103
+ statements = provider(name, path) if provider else _suggest_import(name)
1104
+ _project_import_suggestion_cache[key] = statements[:_MAX_IMPORT_CANDIDATES]
1105
+ return _project_import_suggestion_cache[key]
1106
+
1107
+
1108
+ _project_importables_cache = None # (src_module_count, rows, stmts)
1109
+
1110
+
1111
+ def project_importables():
1112
+ """(rows, stmts) for the completion popup's import shortcuts: `rows` is a
1113
+ sorted [(name, "auto_import")] list of the main package's top-level classes
1114
+ and modules, `stmts` maps each name to the import statement that binds it
1115
+ (`DrawState` → `from src.…draw_state import DrawState`, `draw_state` →
1116
+ `from src.…core_model import draw_state`). Only the canonical `src.`
1117
+ module identities are scanned (the bare `lsd.` aliases of the same files
1118
+ would spell imports the project doesn't use). Classes win a name collision
1119
+ with a module. Cached; rebuilt when the number of loaded src. modules
1120
+ changes (imports only ever add modules mid-session)."""
1121
+ global _project_importables_cache
1122
+ from meltygui.code.fileref import is_editable_source
1123
+ src_mods = {n: m for n, m in list(sys.modules.items())
1124
+ if m is not None and getattr(m, "__file__", None)
1125
+ and is_editable_source(m.__file__)}
1126
+ stamp = len(src_mods)
1127
+ if (_project_importables_cache is not None
1128
+ and _project_importables_cache[0] == stamp):
1129
+ return _project_importables_cache[1], _project_importables_cache[2]
1130
+ stmts = {}
1131
+ for mod_name in sorted(src_mods):
1132
+ mod = src_mods[mod_name]
1133
+ try:
1134
+ ns = vars(mod)
1135
+ except TypeError:
1136
+ continue
1137
+ for attr, obj in list(ns.items()):
1138
+ if (not attr.startswith("_") and isinstance(obj, type)
1139
+ and getattr(obj, "__module__", None) == mod_name):
1140
+ stmts.setdefault(attr, f"from {mod_name} import {attr}")
1141
+ for mod_name in sorted(src_mods):
1142
+ parent, _, base = mod_name.rpartition(".")
1143
+ if parent and not base.startswith("_"):
1144
+ stmts.setdefault(base, f"from {parent} import {base}")
1145
+ rows = [(n, "auto_import") for n in sorted(stmts)]
1146
+ _project_importables_cache = (stamp, rows, stmts)
1147
+ return rows, stmts
1148
+
1149
+
1150
+ _file_binds_cache = {} # str(path) -> (mtime_ns, pending_gen, binds, mono_ts)
1151
+
1152
+ # Freshness floor for the binds cache: within this window a cached answer is
1153
+ # served even when (mtime, pending_gen) moved on. pending_gen bumps on EVERY
1154
+ # queued keystroke save (and redundantly during chain_out echo bursts), so
1155
+ # keying on it alone would re-run the whole-file ast parse near-continuously
1156
+ # while typing. Import-block changes are rare and human-paced - a second of
1157
+ # staleness is invisible, the saved parses are not.
1158
+ _FILE_BINDS_MIN_INTERVAL_S = 1.0
1159
+
1160
+
1161
+ @lag_traced("module-binds parse", 30)
1162
+ def _module_text_binds(path):
1163
+ """Module-scope names the file's CURRENT text binds — pending-save
1164
+ inclusive, so an import removed (or added) in an unsaved edit changes the
1165
+ answer immediately. This, not the live namespace, is the truth for "does
1166
+ the module still import X": the live module keeps a binding forever once
1167
+ an import RAN, so lint suppression keyed on it could never re-flag a
1168
+ removed import.
1169
+
1170
+ None when the text is unreadable/unparseable or holds a star import —
1171
+ callers fall back to the live namespace (err on silence, exactly the old
1172
+ behavior). Cached on (mtime_ns, pending_gen) per CLAUDE.md's no-content-
1173
+ hash rule, with a time floor (_FILE_BINDS_MIN_INTERVAL_S) so gen churn
1174
+ can't re-parse the file continuously; runs on the lint's worker."""
1175
+ try:
1176
+ st = os.stat(path)
1177
+ except (OSError, TypeError, ValueError):
1178
+ return None
1179
+ now = time.monotonic()
1180
+
1181
+ def _fresh(hit):
1182
+ return hit is not None and (
1183
+ (hit[0] == st.st_mtime_ns and hit[1] == gen)
1184
+ or now - hit[3] < _FILE_BINDS_MIN_INTERVAL_S)
1185
+
1186
+ def _reusable(hit, new_text):
1187
+ # Diff-based shortcut (an incremental import-scan fix): a stale-by-gen
1188
+ # hit whose cached TEXT differs from the new pending text only
1189
+ # inside def/class bodies can't change its MODULE-scope binds -
1190
+ # skip the O(file) ast parse (~60ms on a 340KB file, observed on the
1191
+ # render thread) and keep the set. Text rides in the cache entry
1192
+ # (index 4; older 4-tuples predate this and never reuse).
1193
+ return (hit is not None and len(hit) > 4 and hit[2] is not None
1194
+ and hit[4] is not None and new_text is not None
1195
+ and _binds_unchanged(hit[4], new_text))
1196
+
1197
+ gen = 0
1198
+ text = None
1199
+ key = str(path)
1200
+ try:
1201
+ from meltygui.editor.pending_save import PendingSave
1202
+ from pathlib import Path as _P
1203
+ rp = _P(path).resolve()
1204
+ key = str(rp)
1205
+ gen = PendingSave.pending_gen_for(rp)
1206
+ hit = _file_binds_cache.get(key)
1207
+ if _fresh(hit):
1208
+ return hit[2]
1209
+ text = PendingSave.current_file_text(rp)
1210
+ if _reusable(hit, text):
1211
+ _file_binds_cache[key] = (st.st_mtime_ns, gen, hit[2], now, text)
1212
+ return hit[2]
1213
+ except Exception:
1214
+ hit = _file_binds_cache.get(key)
1215
+ if _fresh(hit):
1216
+ return hit[2]
1217
+ try:
1218
+ with open(path, encoding="utf-8", errors="replace") as f:
1219
+ text = f.read()
1220
+ except OSError:
1221
+ text = None
1222
+ if _reusable(hit, text):
1223
+ _file_binds_cache[key] = (st.st_mtime_ns, gen, hit[2], now, text)
1224
+ return hit[2]
1225
+ binds = None
1226
+ if text is not None:
1227
+ try:
1228
+ file_col = _Collector()
1229
+ file_col.run(_parse_for_analysis(text))
1230
+ binds = set(file_col.module.binds)
1231
+ # Star imports don't make the answer unknowable: resolve each
1232
+ # source to its LIVE module's export list (__all__, else
1233
+ # public names). Only an unloaded/relative star source degrades
1234
+ # to None (fall back to live-ns suppression).
1235
+ for sm in file_col.star_modules:
1236
+ mod = sys.modules.get(sm) if sm else None
1237
+ if mod is None:
1238
+ binds = None
1239
+ break
1240
+ names = getattr(mod, "__all__", None)
1241
+ if names is None:
1242
+ names = [n for n in vars(mod) if not n.startswith("_")]
1243
+ binds.update(names)
1244
+ if binds is not None:
1245
+ binds = frozenset(binds)
1246
+ except (SyntaxError, ValueError, RecursionError, TypeError):
1247
+ binds = None
1248
+ _file_binds_cache[key] = (st.st_mtime_ns, gen, binds, now, text)
1249
+ return binds
1250
+
1251
+
1252
+ def _binds_unchanged(old_text, new_text):
1253
+ """True when the old→new edit provably can't change MODULE-scope binds:
1254
+ every changed line (both sides of the diff) is blank/comment or indented,
1255
+ contains no import/global statement, and the enclosing column-0 block is
1256
+ a def/class/decorator — whose interior binds function or class scope, not
1257
+ module scope. An edit inside a module-level `if:`/`try:` block (indented
1258
+ yet module-scope) fails the header check and re-parses. Conservative by
1259
+ construction: any doubt → False → the full parse runs."""
1260
+ if old_text is new_text or old_text == new_text:
1261
+ return True
1262
+ a = old_text.split("\n")
1263
+ b = new_text.split("\n")
1264
+ na, nb = len(a), len(b)
1265
+ pre = 0
1266
+ m = min(na, nb)
1267
+ while pre < m and a[pre] == b[pre]:
1268
+ pre += 1
1269
+ suf = 0
1270
+ while suf < (na - pre) and suf < (nb - pre) and a[na - 1 - suf] == b[nb - 1 - suf]:
1271
+ suf += 1
1272
+ lo, hi = pre, nb - suf
1273
+ for ln in b[lo:hi] + a[lo:na - suf]:
1274
+ s = ln.lstrip()
1275
+ if not s or s.startswith("#"):
1276
+ continue
1277
+ if ln[0] not in " \t":
1278
+ return False # a top-level line changed
1279
+ if s.startswith(("global ", "import ", "from ")):
1280
+ return False
1281
+ start = min(lo, nb - 1)
1282
+ while start > 0 and (not b[start] or b[start][0] in " \t"):
1283
+ start -= 1
1284
+ head = b[start].lstrip() if 0 <= start < nb else ""
1285
+ return head.startswith(("def ", "async def ", "class ", "@"))
1286
+
1287
+
1288
+ # ── pending-truth signature source ───────────────────────────────────────────
1289
+ #
1290
+ # The live object's inspect.signature reflects the last COMPILE, and disk only
1291
+ # updates at shutdown (PendingSave defers all writes) - so between an edit and
1292
+ # its recompile both lie about a function's parameters. The file's CURRENT
1293
+ # text (disk + every queued unsaved edit, via PendingSave.current_file_text)
1294
+ # is truth reliable, exactly the way Ctrl+B's find usages and the jedi passes
1295
+ # already read it. _signature_table parses that text for call specs; callers
1296
+ # prefer it over live introspection whenever the defining file has pending
1297
+ # edits.
1298
+
1299
+ _file_sig_cache = {} # str(realpath) -> (mtime_ns, pending_gen, table, mono_ts)
1300
+
1301
+ # Sentinel spec: the name IS bound at module scope but its signature is
1302
+ # unknowable (decorated def, rebound name, inherited __init__) - suppresses
1303
+ # both the check and any live-object fallback (err on silence).
1304
+ _SIG_UNKNOWN = object()
1305
+
1306
+
1307
+ def _class_call_spec(node, cls_scope):
1308
+ """The spec calling class `node` checks against — mirrors the buffer-static
1309
+ rule in _check_call_static: a plain __init__ decides; a bare class with no
1310
+ bases/keywords and no __init__/__new__ takes no args; anything else is
1311
+ unknowable."""
1312
+ init = cls_scope.defs.get("__init__")
1313
+ if init is not None and init[1] == "plain":
1314
+ spec = _spec_from_arguments(init[0].args, skip_first=1)
1315
+ return spec if spec is not None else _SIG_UNKNOWN
1316
+ if (init is None and not node.bases and not node.keywords
1317
+ and "__init__" not in cls_scope.binds
1318
+ and "__new__" not in cls_scope.binds):
1319
+ return _Spec() # no-arg constructor
1320
+ return _SIG_UNKNOWN
1321
+
1322
+
1323
+ def _wrapper_spec_for(decorator_list):
1324
+ """The calling-convention spec for a render-wrapper-decorated def, or None
1325
+ (unknowable). Applies when every decorator is a known project convention
1326
+ and at least one is a wrapper (@render_func): the live callable is
1327
+ core_render's `wrapper(input_value=None, **kwargs)`, so the ONLY checkable
1328
+ claims are the positional shape (at most one) and an input_value
1329
+ positional/keyword collision — required params may be filled by modes,
1330
+ decorator defaults, comment args or annotation maps, and wrapper-level
1331
+ kwargs (mode, name, ...) are always legal."""
1332
+ names = [_decorator_name(d) for d in decorator_list]
1333
+ known = _TRANSPARENT_DECORATORS | _WRAPPER_DECORATORS
1334
+ if (not names or not all(n in known for n in names)
1335
+ or not any(n in _WRAPPER_DECORATORS for n in names)):
1336
+ return None
1337
+ spec = _Spec()
1338
+ spec.named = ["input_value"]
1339
+ spec.has_var_kw = True
1340
+ return spec
1341
+
1342
+
1343
+ def _module_scope_defs(tree):
1344
+ """{name: FunctionDef | None} for every function def the module's own
1345
+ scope executes — descending module-level if/try/with/for blocks but never
1346
+ def/class bodies (mirrors _module_scope_imports). A name defined twice
1347
+ maps to None (which def wins is unknowable)."""
1348
+ defs = {}
1349
+
1350
+ def walk(stmts):
1351
+ for st in stmts:
1352
+ if isinstance(st, (ast.FunctionDef, ast.AsyncFunctionDef)):
1353
+ defs[st.name] = None if st.name in defs else st
1354
+ continue
1355
+ if isinstance(st, ast.ClassDef):
1356
+ continue
1357
+ for field in ("body", "orelse", "finalbody"):
1358
+ sub = getattr(st, field, None)
1359
+ if sub:
1360
+ walk(sub)
1361
+ for h in getattr(st, "handlers", None) or ():
1362
+ walk(h.body)
1363
+
1364
+ walk(tree.body)
1365
+ return defs
1366
+
1367
+
1368
+ def _module_scope_imports(tree):
1369
+ """(from_imports, module_aliases) the module's own scope executes —
1370
+ descending module-level if/try/with/for blocks but never def/class bodies
1371
+ (those bind other scopes). from_imports is {alias: (module, name)} for
1372
+ absolute `from m import x`; module_aliases is {alias: module_name} for
1373
+ `import m` / `import m.n as p` (dotted-call bases: `imgui.dummy(...)`).
1374
+ Relative imports are skipped (not hop-resolvable by name)."""
1375
+ imports = {}
1376
+ modules = {}
1377
+
1378
+ def walk(stmts):
1379
+ for st in stmts:
1380
+ if isinstance(st, (ast.FunctionDef, ast.AsyncFunctionDef,
1381
+ ast.ClassDef)):
1382
+ continue
1383
+ if isinstance(st, ast.ImportFrom):
1384
+ if st.module and not st.level:
1385
+ for al in st.names:
1386
+ if al.name != "*":
1387
+ imports[al.asname or al.name] = (st.module, al.name)
1388
+ continue
1389
+ if isinstance(st, ast.Import):
1390
+ for al in st.names:
1391
+ if al.asname:
1392
+ modules[al.asname] = al.name
1393
+ else:
1394
+ # `import a.b` binds `a`; the chain walk steps to `b`.
1395
+ top = al.name.partition(".")[0]
1396
+ modules[top] = top
1397
+ continue
1398
+ for field in ("body", "orelse", "finalbody"):
1399
+ sub = getattr(st, field, None)
1400
+ if sub:
1401
+ walk(sub)
1402
+ for h in getattr(st, "handlers", None) or ():
1403
+ walk(h.body)
1404
+
1405
+ walk(tree.body)
1406
+ return imports, modules
1407
+
1408
+
1409
+ @lag_traced("signature-table parse", 30)
1410
+ def _signature_table(path):
1411
+ """Call specs the file's CURRENT text (pending-save inclusive) defines at
1412
+ module scope, or None (unreadable/unparseable — callers err on silence).
1413
+
1414
+ {"specs": {name: _Spec | _SIG_UNKNOWN} for module-level defs/classes,
1415
+ "imports": {alias: (module, name)} for its `from m import x` bindings}
1416
+
1417
+ Trust rules match the buffer-static pass: decorated defs, rebound names
1418
+ and non-trivial classes map to _SIG_UNKNOWN (bound, but never checked).
1419
+ Cached on (mtime_ns, pending_gen) with the same freshness floor as
1420
+ _file_binds_cache — an O(file) parse, throttled, worker-side only."""
1421
+ try:
1422
+ st = os.stat(path)
1423
+ except (OSError, TypeError, ValueError):
1424
+ return None
1425
+ now = time.monotonic()
1426
+
1427
+ def _fresh(hit):
1428
+ return hit is not None and (
1429
+ (hit[0] == st.st_mtime_ns and hit[1] == gen)
1430
+ or now - hit[3] < _FILE_BINDS_MIN_INTERVAL_S)
1431
+
1432
+ gen = 0
1433
+ text = None
1434
+ key = str(path)
1435
+ try:
1436
+ from meltygui.editor.pending_save import PendingSave
1437
+ from pathlib import Path as _P
1438
+ rp = _P(path).resolve()
1439
+ key = str(rp)
1440
+ gen = PendingSave.pending_gen_for(rp)
1441
+ hit = _file_sig_cache.get(key)
1442
+ if _fresh(hit):
1443
+ return hit[2]
1444
+ text = PendingSave.current_file_text(rp)
1445
+ except Exception:
1446
+ hit = _file_sig_cache.get(key)
1447
+ if _fresh(hit):
1448
+ return hit[2]
1449
+ try:
1450
+ with open(path, encoding="utf-8", errors="replace") as f:
1451
+ text = f.read()
1452
+ except OSError:
1453
+ text = None
1454
+ table = None
1455
+ if text is not None:
1456
+ try:
1457
+ tree = _parse_for_analysis(text)
1458
+ col = _Collector()
1459
+ col.run(tree)
1460
+ specs = {}
1461
+ # Walked directly (not col.module.defs - the collector drops every
1462
+ # decorated def) so @command/@render_func defs get their convention
1463
+ # specs; rebound names still fall to unknowable via `ambiguous`.
1464
+ for name, node in _module_scope_defs(tree).items():
1465
+ if node is None or name in col.module.ambiguous:
1466
+ specs[name] = _SIG_UNKNOWN
1467
+ continue
1468
+ flavor = _decorator_flavor(node.decorator_list)
1469
+ spec = (_spec_from_arguments(node.args)
1470
+ if flavor in ("plain", "static")
1471
+ else _wrapper_spec_for(node.decorator_list))
1472
+ specs[name] = spec if spec is not None else _SIG_UNKNOWN
1473
+ for name, (node, cls_scope) in col.module.classes.items():
1474
+ specs[name] = _class_call_spec(node, cls_scope)
1475
+ imports, modules = _module_scope_imports(tree)
1476
+ for d in (imports, modules):
1477
+ for alias in list(d):
1478
+ # A name the module also defs/rebinds isn't the import target.
1479
+ if alias in specs or alias in col.module.ambiguous:
1480
+ del d[alias]
1481
+ table = {"specs": specs, "imports": imports, "modules": modules}
1482
+ except (SyntaxError, ValueError, RecursionError, TypeError):
1483
+ table = None
1484
+ if table is None and hit is not None and hit[2] is not None:
1485
+ # Known-good: the pending text is unparseable exactly while the cursor is
1486
+ # MID-KEYSTROKE in some span of this file - a None here would blank
1487
+ # every signature marker built from the previous good parse (the
1488
+ # flash-then-vanish bug) so the stale table serves until a clean
1489
+ # parse replaces it. Signatures rarely change in the broken window.
1490
+ table = hit[2]
1491
+ _file_sig_cache[key] = (st.st_mtime_ns, gen, table, now)
1492
+ return table
1493
+
1494
+
1495
+ def _pending_spec_for(obj):
1496
+ """(handled, spec) — the spec for `obj` from its defining file's PENDING
1497
+ text. handled=True means the pending source answered authoritatively
1498
+ (spec may be None = deliberately unknowable → silence); handled=False
1499
+ means no pending edits / not locatable → use live introspection."""
1500
+ if isinstance(obj, type):
1501
+ mod = sys.modules.get(getattr(obj, "__module__", None) or "")
1502
+ file = getattr(mod, "__file__", None)
1503
+ qual = getattr(obj, "__qualname__", None)
1504
+ else:
1505
+ fn = obj
1506
+ for _ in range(8): # unwrap decorators to the def's real code
1507
+ inner = getattr(fn, "__wrapped__", None)
1508
+ if inner is None:
1509
+ break
1510
+ fn = inner
1511
+ code = getattr(fn, "__code__", None)
1512
+ if code is None:
1513
+ return False, None
1514
+ file = code.co_filename
1515
+ qual = getattr(fn, "__qualname__", None)
1516
+ if (not file or not qual or "." in qual):
1517
+ return False, None # only module-level names are in the table
1518
+ try:
1519
+ from meltygui.editor.pending_save import PendingSave
1520
+ from pathlib import Path as _P
1521
+ rp = _P(file).resolve()
1522
+ if PendingSave.pending_gen_for(rp) <= 0:
1523
+ return False, None # no unsaved edits - live/disk agree
1524
+ except Exception:
1525
+ return False, None
1526
+ table = _signature_table(str(rp))
1527
+ if table is None:
1528
+ return False, None
1529
+ spec = table["specs"].get(qual, _MISS)
1530
+ if spec is _MISS:
1531
+ return False, None # def moved/renamed - fall back to live
1532
+ return True, (None if spec is _SIG_UNKNOWN else spec)
1533
+
1534
+
1535
+ def _object_spec(obj, fname):
1536
+ """The spec a call to `obj` checks against: the pending text of its
1537
+ defining file when that file has unsaved edits (the live signature goes
1538
+ stale between an edit and its recompile), else live introspection."""
1539
+ try:
1540
+ handled, spec = _pending_spec_for(obj)
1541
+ except Exception:
1542
+ handled, spec = False, None
1543
+ if handled:
1544
+ return spec
1545
+ return _live_spec(obj, fname)
1546
+
1547
+
1548
+ def _span_spec_for_live(obj, fname):
1549
+ """Spec for a LIVE object reached from span mode, or None. Stricter than
1550
+ the whole-file live pass: only real function objects are trusted.
1551
+ Classes lie (ast.Constant's signature claims required params its
1552
+ constructor doesn't enforce) and callable INSTANCES lie (PyOpenGL's
1553
+ glDrawBuffers wrapper hides its signature and its doc line parses into
1554
+ the wrong arity) — both produce false alarms, so they stay silent."""
1555
+ if isinstance(obj, type) or not (_is_cython_function(obj) or isinstance(
1556
+ obj, (types.FunctionType, types.BuiltinFunctionType))):
1557
+ return None
1558
+ return _object_spec(obj, fname)
1559
+
1560
+ def _check_dotted_call_span(call, func, scope, path):
1561
+ """Span-mode signature check for a dotted call (`imgui.dummy(...)`,
1562
+ `mod.helper(...)`): the base name resolves through the module file's
1563
+ import bindings (its own text, so a base the buffer shadows never
1564
+ matches), then the chain walks LIVE modules only — the same modules-only
1565
+ rule as _walk_chain, minus the missing-attr report (out of scope for the
1566
+ span pass). Anything unresolvable → silence."""
1567
+ base, attrs = _unwind_chain(func)
1568
+ if base is None or _resolves(scope, base.id):
1569
+ return None
1570
+ table = _signature_table(path) if path else None
1571
+ if table is None:
1572
+ return None
1573
+ obj = _MISS
1574
+ modname = table["modules"].get(base.id)
1575
+ if modname is not None:
1576
+ mod = sys.modules.get(modname)
1577
+ obj = mod if mod is not None else _MISS
1578
+ else:
1579
+ imp = table["imports"].get(base.id)
1580
+ if imp is not None:
1581
+ mod = sys.modules.get(imp[0])
1582
+ try:
1583
+ obj = vars(mod).get(imp[1], _MISS) if mod is not None else _MISS
1584
+ except TypeError:
1585
+ obj = _MISS
1586
+ if obj is _MISS:
1587
+ return None
1588
+ for attr, _node in attrs:
1589
+ if not isinstance(obj, types.ModuleType):
1590
+ return None # never walk through functions/classes
1591
+ obj = inspect.getattr_static(obj, attr, _MISS)
1592
+ if obj is _MISS:
1593
+ return None
1594
+ if not callable(obj):
1595
+ return None
1596
+ fname = attrs[-1][0]
1597
+ spec = _span_spec_for_live(obj, fname)
1598
+ return _match_spec(fname, spec, call) if spec is not None else None
1599
+
1600
+
1601
+ def _check_call_span(call, scope, path, file_binds):
1602
+ """Signature check for SPAN buffers (only_missing_imports mode), where the
1603
+ live-ctx pass can't run (the buffer binds none of its module's names). A
1604
+ bare-name call the buffer itself doesn't bind resolves through the
1605
+ enclosing module's CURRENT text (_signature_table — pending-save
1606
+ inclusive, so a signature edited in another view flags wrong call sites
1607
+ before any recompile): a module-level def/class directly, a
1608
+ `from m import name` via the live module (upgraded to m's pending text
1609
+ when m's file has unsaved edits), and builtins only when the module's
1610
+ text provably doesn't shadow the name. Anything else → silence."""
1611
+ func = call.func
1612
+ if isinstance(func, ast.Attribute):
1613
+ return _check_dotted_call_span(call, func, scope, path)
1614
+ if not isinstance(func, ast.Name):
1615
+ return None
1616
+ name = func.id
1617
+ if _resolves(scope, name):
1618
+ return None # the buffer's own binding, static pass owns it
1619
+ table = _signature_table(path) if path else None
1620
+ if table is None:
1621
+ return None
1622
+ spec = table["specs"].get(name, _MISS)
1623
+ if spec is _SIG_UNKNOWN:
1624
+ return None
1625
+ if spec is not _MISS:
1626
+ return _match_spec(name, spec, call)
1627
+ imp = table["imports"].get(name)
1628
+ if imp is not None:
1629
+ mod = sys.modules.get(imp[0])
1630
+
1631
+ try:
1632
+ obj = vars(mod).get(imp[1], _MISS) if mod is not None else _MISS
1633
+ except TypeError:
1634
+ obj = _MISS
1635
+ if obj is _MISS or not callable(obj):
1636
+ return None
1637
+ spec = _span_spec_for_live(obj, name)
1638
+ return _match_spec(name, spec, call) if spec is not None else None
1639
+ if file_binds is not None and name not in file_binds:
1640
+ obj = getattr(builtins, name, _MISS)
1641
+ if obj is not _MISS and callable(obj):
1642
+ spec = _live_spec(obj, name)
1643
+ return _match_spec(name, spec, call) if spec is not None else None
1644
+ return None
1645
+
1646
+
1647
+ def _buffer_bound_names(text):
1648
+ """Every name the buffer text plausibly BINDS — local vars, params,
1649
+ def/class names, loop/with targets, import statements — in a handful of
1650
+ regex passes (findall, C-speed), never per-name. Approximate on purpose,
1651
+ erring toward "bound" (a bound name is merely never suggested — silence
1652
+ beats noise). Used where the buffer's parse can't be trusted (mid-edit
1653
+ syntax errors)."""
1654
+ import re
1655
+ bound = set()
1656
+ bound.update(_analysis_matches(r"(?m)^\s*(?:def|class)\s+(\w+)", text))
1657
+ bound.update(_analysis_matches(r"(?m)^\s*(\w+)\s*(?:=[^=]|,|\)|=$)", text))
1658
+ bound.update(_analysis_matches(r"\b(?:as|for)\s+(\w+)", text))
1659
+ # Tuple targets: every name between `for` and `in` is a binding
1660
+ # (`for chev, step, vid in ...` - the pass above only got `chev`), and
1661
+ # likewise every name on the left of an unpack assignment (`a, b = ...`).
1662
+ # `(` is excluded from the assignment class so a call's kwargs
1663
+ # (`foo(bar, baz=1)`) are never read as targets.
1664
+ for targets in _analysis_matches(r"\bfor\s+([\w\s,()\[\]*]+?)\s+in\b", text):
1665
+ bound.update(re.findall(r"\w+", targets))
1666
+ for targets in _analysis_matches(r"(?m)^\s*([\w\s,\[\]*]+?)\s*=[^=]", text):
1667
+ bound.update(re.findall(r"\w+", targets))
1668
+ for params in _analysis_matches(r"(?m)^\s*(?:def\s+\w+|lambda)\s*\(([^)]*)", text):
1669
+ bound.update(re.findall(r"\w+", params))
1670
+ # Import bindings at ANY indent - a function-local `from m import name`
1671
+ # covers `name` for the whole buffer's scope, so it must never be
1672
+ # re-suggested. The parenthesized form is captured ACROSS lines ([^)]
1673
+ # matches newlines), so EVERY name of a multi-line
1674
+ # `from m import (a,\n b, c)` binds, not just the first per line.
1675
+ for names in _analysis_matches(
1676
+ r"(?m)^\s*from\s+[.\w]+\s+import\s+(\([^)]*\)?|[^#\n]*)", text):
1677
+ names = names.strip("()")
1678
+ for part in names.split(","):
1679
+ toks = part.split()
1680
+ if toks and toks[0] != "*":
1681
+ bound.add(toks[0])
1682
+ for names in _analysis_matches(r"(?m)^\s*import\s+([^#\n]+)", text):
1683
+ for part in names.split(","):
1684
+ toks = part.split()
1685
+ if toks:
1686
+ bound.add(toks[0].split(".")[0])
1687
+ return bound
1688
+
1689
+
1690
+ def _tokenize_lenient(slice_text):
1691
+ """[(type, string, rel_line)] NAME/OP tokens for a slice. Whole-slice
1692
+ tokenize first; where it BREAKS (a mid-edit dedent mismatch — e.g. an
1693
+ appended line shallower than the line above — or an unterminated string)
1694
+ the remaining lines are tokenized INDIVIDUALLY, stripped so indentation
1695
+ can't fault. The scan's filters only use prev/next context within a
1696
+ line, so per-line context is enough; without the salvage every line
1697
+ after the break silently vanished from the scan."""
1698
+ import io
1699
+ import tokenize as _tokenize
1700
+ out = []
1701
+ broke = False
1702
+ try:
1703
+ for index, tok in enumerate(_tokenize.generate_tokens(io.StringIO(slice_text).readline)):
1704
+ if index % 128 == 0:
1705
+ _analysis_checkpoint()
1706
+ if tok.type in (_tokenize.NAME, _tokenize.OP):
1707
+ out.append((tok.type, tok.string, tok.start[0]))
1708
+ except (_tokenize.TokenError, IndentationError, SyntaxError, ValueError):
1709
+ broke = True
1710
+ lines = slice_text.split("\n")
1711
+ # Salvage ONLY small slices (the incremental region case, where the break
1712
+ # is the edit). A whole-buffer scan that breaks mid-file must NOT be
1713
+ # salvaged: per-line mode has no string context, so thousands of
1714
+ # docstring lines below the break would tokenize as code and flood the
1715
+ # UI with prose "suggestions". Losing the below-break findings for
1716
+ # one mid-edit scan is the old, silent behavior - the incremental state
1717
+ # keeps the previous findings for those lines anyway.
1718
+ if broke and len(lines) <= 200:
1719
+ resume = max((ln for _t, _s, ln in out), default=0) + 1
1720
+ for idx in range(resume - 1, len(lines)):
1721
+ stripped = lines[idx].strip()
1722
+ if not stripped:
1723
+ continue
1724
+ try:
1725
+ for tok in _tokenize.generate_tokens(
1726
+ io.StringIO(stripped + "\n").readline):
1727
+ if tok.type in (_tokenize.NAME, _tokenize.OP):
1728
+ out.append((tok.type, tok.string, idx + 1))
1729
+ except (_tokenize.TokenError, IndentationError, SyntaxError,
1730
+ ValueError):
1731
+ continue
1732
+ return out
1733
+
1734
+
1735
+ def _scan_slice(slice_text, line_offset, bound, path, cached_only=False):
1736
+ """{absolute 1-based line: [import stmts]} for one text slice — the
1737
+ tokenize-based candidate pass shared by the full and incremental scans.
1738
+ Base identifiers only (not attributes after a dot, not assignment
1739
+ targets, not keywords / import / decorator lines); a name survives when
1740
+ neither the module's current text (_module_text_binds) nor `bound` (the
1741
+ buffer's own bindings) accounts for it AND an import statement would
1742
+ bind it (_suggest_import, cached per name)."""
1743
+ import keyword
1744
+ import tokenize as _tokenize
1745
+ toks = _tokenize_lenient(slice_text)
1746
+ if not toks:
1747
+ return {}
1748
+ file_binds = None
1749
+ file_binds_ready = False
1750
+ lines = slice_text.split("\n")
1751
+ verdict = {} # name -> [stmts] or None (checked once)
1752
+ out = {}
1753
+ for i, (kind, s, rel_line) in enumerate(toks):
1754
+ if kind != _tokenize.NAME or keyword.iskeyword(s):
1755
+ continue
1756
+ stripped = (lines[rel_line - 1].strip()
1757
+ if 1 <= rel_line <= len(lines) else "")
1758
+ if stripped.startswith(("import ", "from ")):
1759
+ continue # import block line
1760
+ prev = toks[i - 1][1] if i > 0 else None
1761
+ nxt = toks[i + 1][1] if i + 1 < len(toks) else None
1762
+ # Attribute / binding position. Decorator lines scan like any other
1763
+ # code - the name after `@` AND its arguments are real usages an
1764
+ # import could fix (the old line-level `@` skip hid both).
1765
+ if prev in (".", "def", "class", "as", "import", "from"):
1766
+ continue
1767
+ if nxt == "=": # plain assignment target (== is one token)
1768
+ continue
1769
+ if s not in verdict:
1770
+ stmts = None
1771
+ if s not in _BUILTIN_NAMES and s not in bound:
1772
+ if not file_binds_ready:
1773
+ # Lazy: most slices resolve every name via builtins/bound
1774
+ # and never need the (cached/throttled) file parse.
1775
+ if cached_only:
1776
+ # The render-thread incremental path must not parse the
1777
+ # module or discover imports. A miss belongs to relint.
1778
+ hit = _file_binds_cache.get(str(path)) if path else None
1779
+ if path and hit is None:
1780
+ return None
1781
+ file_binds = hit[2] if hit else None
1782
+ else:
1783
+ file_binds = _module_text_binds(path) if path else None
1784
+ file_binds_ready = True
1785
+ if not (file_binds is not None and s in file_binds):
1786
+ try:
1787
+ stmts = _suggest_import_for_path(s, path, cached_only=cached_only)
1788
+ if cached_only and stmts is None:
1789
+ return None
1790
+ stmts = stmts or None
1791
+ except Exception:
1792
+ stmts = None
1793
+ verdict[s] = stmts
1794
+ stmts = verdict[s]
1795
+ if stmts:
1796
+ row = out.setdefault(line_offset + rel_line, [])
1797
+ for st in stmts:
1798
+ if st not in row:
1799
+ row.append(st)
1800
+ return out
1801
+
1802
+
1803
+ # Incremental scan state, one entry per lint_path: the last scan text, its
1804
+ # result, and the buffer's bound-name set. Two buffers sharing a path (two
1805
+ # span editors of one file) are back to full rescans - never wrong.
1806
+ _inc_scan_state = {}
1807
+
1808
+ # An edit region larger than this re-runs the full scan instead (the diff
1809
+ # bookkeeping stops being cheaper than one pass).
1810
+ _INC_MAX_REGION_CHARS = 4096
1811
+
1812
+
1813
+ def has_scan_state(path):
1814
+ """True when a background pass already warmed `path`'s incremental scan
1815
+ state — the editor's per-keystroke fast path (text_editor.py) probes this
1816
+ before calling collect_import_suggestions on the RENDER thread: a warm
1817
+ incremental step is O(changed region) (~0.4ms), but a path's FIRST scan is
1818
+ O(buffer tokenize + module-binds parse) and belongs on a worker."""
1819
+ return path is not None and str(path) in _inc_scan_state
1820
+
1821
+
1822
+ @lag_traced("import scan", 30)
1823
+ def collect_import_suggestions(text, path=None, full=False,
1824
+ incremental_only=False):
1825
+ """{1-based line: [import statements]} for every symbol the buffer USES
1826
+ but nothing binds — the editor's Alt+Enter quick-fix data, a SEPARATE
1827
+ channel from the error lint. Tokenize-based, so it works mid-edit (a
1828
+ dangling `json.` breaks the parse, not the tokenizer).
1829
+
1830
+ INCREMENTAL per keystroke: the previous text/result are kept per path,
1831
+ the edit is located by common prefix/suffix (C-speed string ops), only
1832
+ the changed LINES are re-tokenized, and every unchanged line's findings
1833
+ are shifted, not recomputed — so a keystroke costs O(changed region),
1834
+ never O(buffer). `full=True` (the relint path — the file's import block
1835
+ may have changed) and structural cases (first scan, big paste, edits
1836
+ inside triple-quoted strings, tokenizer trouble) run the whole pass.
1837
+
1838
+ `incremental_only=True` (the editor's render-thread fast path on large
1839
+ buffers) returns None instead of running that O(buffer) full pass — the
1840
+ caller falls back to the debounced background channel, whose next run
1841
+ re-warms the state here."""
1842
+ key = str(path) if path else None
1843
+ st = _inc_scan_state.get(key) if key else None
1844
+ if not full and st is not None:
1845
+ old = st["text"]
1846
+ if old is text or old == text:
1847
+ return st["result"]
1848
+ inc = _incremental_scan(st, old, text, path, cached_only=incremental_only)
1849
+ if inc is not None:
1850
+ if key:
1851
+ _inc_scan_state[key] = inc
1852
+ return inc["result"]
1853
+ if incremental_only:
1854
+ return None
1855
+ bound = _buffer_bound_names(text)
1856
+ result = _scan_slice(text, 0, bound, path)
1857
+ if key:
1858
+ _inc_scan_state[key] = {"text": text, "result": result, "bound": bound}
1859
+ return result
1860
+
1861
+
1862
+ def _incremental_scan(st, old, text, path, cached_only=False):
1863
+ """The O(changed region) path: new state dict, or None → run a full scan.
1864
+
1865
+ The changed region is the line span between the common prefix and common
1866
+ suffix. Findings on lines before it are kept as-is, lines after it shift
1867
+ by the line-count delta, and the region itself is re-tokenized in
1868
+ isolation. The bound-name set only GROWS here (bindings added in the
1869
+ region); a binding DELETED elsewhere keeps its name suppressed until the
1870
+ next full scan — the relint kick that follows every queued save runs one
1871
+ within ~a second, so the miss is transient. An edit inside a triple-
1872
+ quoted string would tokenize prose as code, so an odd quote count before
1873
+ the region skips its rescan (pure line-shift instead)."""
1874
+ # Common prefix/suffix by CHUNKED slice compares (C-speed memcmp) - a
1875
+ # per-char Python loop here costs ~25ms on a 300k buffer, which is
1876
+ # the exact per-keystroke stall this incremental path aims to kill.
1877
+ max_p = min(len(old), len(text))
1878
+ p = 0
1879
+ for step in (1 << 16, 1 << 12, 1 << 8, 1 << 4, 1):
1880
+ while p + step <= max_p and old[p:p + step] == text[p:p + step]:
1881
+ p += step
1882
+ max_s = max_p - p
1883
+ s = 0
1884
+ for step in (1 << 16, 1 << 12, 1 << 8, 1 << 4, 1):
1885
+ while (s + step <= max_s
1886
+ and old[len(old) - s - step:len(old) - s]
1887
+ == text[len(text) - s - step:len(text) - s]):
1888
+ s += step
1889
+ if len(text) - s - p > _INC_MAX_REGION_CHARS:
1890
+ return None # big paste/rewrite - full scan is cheaper
1891
+ pre_lines = text.count("\n", 0, p)
1892
+ old_total = old.count("\n") + 1
1893
+ new_total = text.count("\n") + 1
1894
+ suf_lines = text.count("\n", len(text) - s) if s else 0
1895
+ delta = new_total - old_total
1896
+ result = {}
1897
+ for ln, stmts in st["result"].items():
1898
+ if ln <= pre_lines:
1899
+ result[ln] = stmts
1900
+ elif ln > old_total - suf_lines:
1901
+ result[ln + delta] = stmts
1902
+ # Region slice, rounded to whole lines.
1903
+ start_idx = text.rfind("\n", 0, p) + 1
1904
+ end_idx = len(text) - s
1905
+ nl = text.find("\n", end_idx)
1906
+ slice_end = len(text) if nl == -1 else nl
1907
+ slice_text = text[start_idx:slice_end]
1908
+ bound = st["bound"]
1909
+ in_string = (text.count('"""', 0, start_idx)
1910
+ + text.count("'''", 0, start_idx)) % 2 == 1
1911
+ if slice_text and not in_string:
1912
+ region_bound = _buffer_bound_names(slice_text)
1913
+ if region_bound - bound:
1914
+ bound = bound | region_bound
1915
+ findings = _scan_slice(slice_text, pre_lines, bound, path, cached_only=cached_only)
1916
+ if findings is None:
1917
+ return None
1918
+ for ln, stmts in findings.items():
1919
+ result[ln] = stmts
1920
+ return {"text": text, "result": result, "bound": bound}
1921
+
1922
+
1923
+ # ── entry point ──────────────────────────────────────────────────────────────
1924
+
1925
+ # Incremental lint state, one entry per (path, mode): the last linted text and
1926
+ # its findings. Two buffers sharing a path (a file class + a mid-file view)
1927
+ # degrade to full re-lints on each swap - never wrong, just slower.
1928
+ _inc_lint_state = {}
1929
+
1930
+ # An edited block larger than this skips its region re-lint (findings inside it
1931
+ # go stale until the next full pass) - the point of the incremental path is
1932
+ # bounding worker GIL-hold, so a monster block must not sneak an O(n)-
1933
+ # scale pass back in.
1934
+ _INC_LINT_REGION_CHARS = 64 * 1024
1935
+
1936
+
1937
+ def _changed_block_bounds(a, b):
1938
+ """The edited region of line-lists `a` → `b`, expanded to enclosing
1939
+ top-level block(s) (nearest column-0 lines — the same expansion the
1940
+ editor's region compile uses). Returns (start, end_new, end_old, delta),
1941
+ all 0-based with exclusive ends, or None when the texts are line-identical."""
1942
+ na, nb = len(a), len(b)
1943
+ pre = 0
1944
+ m = min(na, nb)
1945
+ while pre < m and a[pre] == b[pre]:
1946
+ pre += 1
1947
+ if pre == na and pre == nb:
1948
+ return None
1949
+ suf = 0
1950
+ while suf < (na - pre) and suf < (nb - pre) and a[na - 1 - suf] == b[nb - 1 - suf]:
1951
+ suf += 1
1952
+ lo, hi = pre, nb - suf
1953
+ start = min(lo, nb - 1)
1954
+ while start > 0 and (not b[start] or b[start][0] in " \t"):
1955
+ start -= 1
1956
+ end = hi
1957
+ while end < nb and (not b[end] or b[end][0] in " \t"):
1958
+ end += 1
1959
+ return start, end, end + (na - nb), nb - na
1960
+
1961
+
1962
+ @lag_traced("incremental lint", 30)
1963
+ def check_source_incremental(text, path=None, only_missing_imports=False):
1964
+ """check_source, O(edited block) per call: diff against the last linted
1965
+ text, keep findings outside the edited top-level block (shifted by the
1966
+ line delta), and re-lint only the block itself.
1967
+
1968
+ Region lint is sound here for the same reason SPAN lint is: check_source
1969
+ with `path` resolves names through the live module namespace and the
1970
+ pending-file binds (_module_text_binds), so a lone block from mid-file
1971
+ sees its module's imports and sibling definitions instead of flagging
1972
+ them. A block that doesn't parse (mid-edit) reports [] for the region —
1973
+ errs silent, the next clean edit re-lints it.
1974
+
1975
+ Trade-offs vs the full pass: a binding added/removed OUTSIDE the edited
1976
+ block doesn't re-verify findings elsewhere, and an over-sized block
1977
+ (>_INC_LINT_REGION_CHARS) keeps its stale findings. The first call per
1978
+ (path, mode) pays one full pass to seed the state."""
1979
+ key = (str(path) if path else None, bool(only_missing_imports))
1980
+ st = _inc_lint_state.get(key)
1981
+ if st is None:
1982
+ findings = check_source(text, path=path,
1983
+ only_missing_imports=only_missing_imports)
1984
+ # The buffer-wide bound-name set suppresses region findings about
1985
+ # names DEFINED IN OTHER BLOCKS of this buffer: a lone block can't
1986
+ # see them itself, and the live-module fallback only covers files
1987
+ # actually loaded in this process. _buffer_bound_names deliberately
1988
+ # over-approximates (a wrongly-suppressed finding beats a false
1989
+ # alarm). Grow it, using the import scanner's bound set.
1990
+ _inc_lint_state[key] = {"text": text, "findings": findings,
1991
+ "binds": _buffer_bound_names(text)}
1992
+ return findings
1993
+ old = st["text"]
1994
+ if old is text or old == text:
1995
+ return st["findings"]
1996
+ bounds = _changed_block_bounds(old.split("\n"), text.split("\n"))
1997
+ if bounds is None:
1998
+ st["text"] = text
1999
+ return st["findings"]
2000
+ start, end, end_old, delta = bounds
2001
+ # Bindings have file-wide effects. Recheck all uses when a declaration is
2002
+ # added/removed instead of retaining a grow-only set of old imports.
2003
+ previous_region = '\n'.join(old.split('\n')[start:end_old])
2004
+ current_region = '\n'.join(text.split('\n')[start:end])
2005
+ if _buffer_bound_names(previous_region) != _buffer_bound_names(current_region):
2006
+ _file_binds_cache.pop(str(path), None)
2007
+ findings = check_source(text, path=path, only_missing_imports=only_missing_imports)
2008
+ _inc_lint_state[key] = {'text': text, 'findings': findings,
2009
+ 'binds': _buffer_bound_names(text)}
2010
+ return findings
2011
+ # Old-text region rows are start+1 .. end_old (1 based): findings above
2012
+ # keep their line, findings below shift by the edit line delta, findings
2013
+ # inside are re-derived from the fresh region lint.
2014
+ kept = [(ln, msg) if ln <= start else (ln + delta, msg)
2015
+ for ln, msg in st["findings"]
2016
+ if ln <= start or ln > end_old]
2017
+ region = "\n".join(text.split("\n")[start:end])
2018
+ if len(region) <= _INC_LINT_REGION_CHARS:
2019
+ binds = st.get("binds")
2020
+ if binds is None: # state predates the binds field
2021
+ binds = st["binds"] = _buffer_bound_names(old)
2022
+ binds |= _buffer_bound_names(region)
2023
+ # `from x import *` binds names _buffer_bound_names can't see (it
2024
+ # skips `*` on purpose) - a region using a star-imported name
2025
+ # (TrainingStatus in lsd_train.py) would report "not defined" even
2026
+ # though the full-buffer pass stays silent (star_import kills its
2027
+ # name pass). _module_text_binds expands star sources through their
2028
+ # LIVE module's exports; None (unreadable / unresolvable) degrades
2029
+ # to the plain binds check.
2030
+ try:
2031
+ _mod_binds = _module_text_binds(path) if path else None
2032
+ except Exception:
2033
+ _mod_binds = None
2034
+ try:
2035
+ for ln, msg in check_source(region, path=path,
2036
+ only_missing_imports=only_missing_imports):
2037
+ # Name-shape findings about a name some OTHER block in this
2038
+ # buffer binds are cross-block artifacts - drop them. Other
2039
+ # finding shapes (signature/attr) pass through untouched.
2040
+ if msg.startswith("name '"):
2041
+ _nm = msg[6:msg.find("'", 6)]
2042
+ if _nm in binds or (_mod_binds is not None
2043
+ and _nm in _mod_binds):
2044
+ continue
2045
+ kept.append((ln + start, msg))
2046
+ except Exception:
2047
+ pass
2048
+ kept.sort(key=lambda f: f[0])
2049
+ _inc_lint_state[key] = {"text": text, "findings": kept,
2050
+ "binds": st.get("binds")}
2051
+ return kept
2052
+
2053
+
2054
+ @lag_traced("check_source (lint)", 50)
2055
+ def check_source(text, path=None, max_reports=40, only_missing_imports=False):
2056
+ """[(line, message)] for problems that would survive compile() but blow up
2057
+ at run time. Empty list when clean — or when the buffer isn't checkable
2058
+ (syntax error here means the parse/compile pass already reported it).
2059
+
2060
+ only_missing_imports=True is the SPAN-buffer mode (a function/class source
2061
+ edited on its own): the buffer legitimately uses names its module's import
2062
+ block binds, so a generic undefined-name report would flag every one. With
2063
+ `path` = the enclosing module's file, the live-module namespace suppresses
2064
+ everything the module actually binds; what's left is only reported when an
2065
+ import statement would fix it (the missing-import classification below) —
2066
+ a bare typo stays silent. Call-signature checks DO run in span mode
2067
+ (Toggles.TextEditor.lint_span_calls), resolved through the module file's
2068
+ pending text (_check_call_span) rather than the live ctx; the attr pass
2069
+ stays off (its module-walk needs import-bound names the span never
2070
+ sees)."""
2071
+ try:
2072
+ tree = _parse_for_analysis(text)
2073
+ except IndentationError:
2074
+ # A method/nested span arrives at its class-body indent - dedent and
2075
+ # retry (line numbers survive; textwrap.dedent strips only the common
2076
+ # prefix). string_to_cst_module does its own dedent, but this is the
2077
+ # lint's mirror of the same normalization.
2078
+ import textwrap
2079
+ try:
2080
+ tree = _parse_for_analysis(textwrap.dedent(text))
2081
+ except (SyntaxError, ValueError):
2082
+ return []
2083
+ except (SyntaxError, ValueError):
2084
+ return []
2085
+ col = _Collector()
2086
+ try:
2087
+ col.run(tree)
2088
+ except RecursionError:
2089
+ return []
2090
+
2091
+ live_mod = _module_for(path)
2092
+ ctx = _LiveCtx(live_mod, col.declared_attrs)
2093
+ live_names = ctx.ns.keys() if ctx.ns is not None else ()
2094
+
2095
+ reports = []
2096
+ seen = set()
2097
+
2098
+ def report(line, msg):
2099
+ if (line, msg) not in seen:
2100
+ seen.add((line, msg))
2101
+ reports.append((line, msg))
2102
+
2103
+ if path:
2104
+ from meltygui.core.runtime.extensions import call
2105
+ for line, message in call('source_diagnostics', text, path) or ():
2106
+ report(line, message)
2107
+
2108
+ # Span mode checks "does the module bind this" against the file's
2109
+ # CURRENT text (pending/inclusive), not its live namespace: a module keeps
2110
+ # a live binding forever once an import happens, so live-ns suppression would
2111
+ # never re-flag an import the user removed. None (unreadable / mid-edit /
2112
+ # star import) falls back to the live namespace as usual.
2113
+ file_binds = (_module_text_binds(path)
2114
+ if only_missing_imports and path else None)
2115
+
2116
+ if not col.star_import:
2117
+ for scope in col.scopes:
2118
+ _analysis_checkpoint()
2119
+ for index, (name, lineno) in enumerate(scope.loads):
2120
+ if index % 128 == 0:
2121
+ _analysis_checkpoint()
2122
+ if name in _BUILTIN_NAMES:
2123
+ continue
2124
+ if _resolves(scope, name):
2125
+ continue
2126
+ if file_binds is not None and name in file_binds:
2127
+ continue # the module's current text binds it
2128
+ if file_binds is None and only_missing_imports and name in live_names:
2129
+ continue # no readable file text - old suppression
2130
+ try:
2131
+ fixable = bool(_suggest_import_for_path(name, path))
2132
+ except Exception:
2133
+ fixable = False # classification must never break the lint
2134
+ # A fixable name reports even when the live namespace still
2135
+ # carries it (a removed import, or an exec-injected binding
2136
+ # the SOURCE never declares): the file wouldn't run from
2137
+ # scratch. The fix itself rides the SEPARATE suggestions
2138
+ # channel (collect_import_suggestions), not the message.
2139
+ if not fixable and (name in live_names or only_missing_imports):
2140
+ continue # injected-at-runtime / a module global we
2141
+ # can't see - unfixable, stay silent
2142
+ report(lineno, f"name '{name}' is not defined")
2143
+
2144
+ if only_missing_imports:
2145
+ # Span buffers get the call-signature pass too, resolved through the
2146
+ # module file's PENDING text instead of the live namespace (which needs
2147
+ # import-bound names the span never sees) - see _check_call_span.
2148
+ try:
2149
+ from meltygui.core.runtime.toggles import Toggles
2150
+ _span_calls = Toggles.TextEditor.lint_span_calls
2151
+ except Exception:
2152
+ _span_calls = True
2153
+ if _span_calls and not col.star_import:
2154
+ for call, scope, guarded in col.calls:
2155
+ if guarded:
2156
+ continue
2157
+ msg = _check_call_static(call, scope)
2158
+ if msg is None:
2159
+ try:
2160
+ msg = _check_call_span(call, scope, path, file_binds)
2161
+ except Exception:
2162
+ msg = None # resolution must never break the lint
2163
+ if msg is not None:
2164
+ report(call.lineno, msg)
2165
+ reports.sort()
2166
+ return reports[:max_reports]
2167
+
2168
+ # Same toggle as the span pass - the pending-table fallback below is the
2169
+ # same feature surfaced in whole-file/region mode.
2170
+ try:
2171
+ from meltygui.core.runtime.toggles import Toggles
2172
+ _table_calls = Toggles.TextEditor.lint_span_calls
2173
+ except Exception:
2174
+ _table_calls = True
2175
+ for index, (call, scope, guarded) in enumerate(col.calls):
2176
+ if index % 128 == 0:
2177
+ _analysis_checkpoint()
2178
+ msg = _check_call_static(call, scope)
2179
+ if msg is None and not guarded:
2180
+ try:
2181
+ msg = _check_call_live(ctx, call, scope)
2182
+ except Exception:
2183
+ msg = None # live introspection should never break the lint
2184
+ if (msg is None and not guarded and _table_calls
2185
+ and path and not col.star_import):
2186
+ # Pending-table fallback: the INCREMENTAL whole-file path lints
2187
+ # one edited top-level block in isolation, where a sibling def
2188
+ # (flat_button) is neither in the buffer's scopes nor live-
2189
+ # resolvable; the module file's pending text alone knows it.
2190
+ # file_binds=None: full mode's live pass already covered builtins.
2191
+ try:
2192
+ msg = _check_call_span(call, scope, path, None)
2193
+ except Exception:
2194
+ msg = None
2195
+ if msg is not None:
2196
+ report(call.lineno, msg)
2197
+
2198
+ for base, attrs, scope, guarded in col.attr_chains:
2199
+ if guarded:
2200
+ continue
2201
+ try:
2202
+ _, rep = _walk_chain(ctx, scope, base, attrs)
2203
+ except Exception:
2204
+ rep = None
2205
+ if rep is not None:
2206
+ report(*rep)
2207
+
2208
+ reports.sort()
2209
+ return reports[:max_reports]